Skip to main content

rustial_engine/
cluster.rs

1//! Point clustering engine (supercluster-equivalent).
2//!
3//! Provides zoom-based point clustering for GeoJSON point sources,
4//! matching the behaviour of MapLibre/Mapbox's supercluster algorithm.
5//!
6//! # Algorithm
7//!
8//! 1. All input points are projected to a `[0, 1)` Mercator plane.
9//! 2. At each zoom level from `max_zoom` down to `min_zoom`, a spatial
10//!    grid is used to find nearby points within `radius` (in screen-pixel
11//!    units, converted to world-space for each zoom).
12//! 3. Nearby points are merged into cluster nodes. Each cluster carries
13//!    `point_count` and optional aggregated properties.
14//! 4. Querying for a specific zoom returns a mix of cluster features
15//!    (for merged groups) and individual point features (for isolated
16//!    points or at zooms above `max_zoom`).
17//!
18//! # Usage
19//!
20//! ```ignore
21//! use rustial_engine::cluster::{PointCluster, ClusterOptions};
22//! use rustial_engine::geometry::FeatureCollection;
23//!
24//! let options = ClusterOptions {
25//!     radius: 50.0,
26//!     max_zoom: 16,
27//!     min_zoom: 0,
28//!     min_points: 2,
29//!     ..Default::default()
30//! };
31//! let mut cluster = PointCluster::new(options);
32//! cluster.load(&features);
33//! let clustered = cluster.get_clusters_for_zoom(5);
34//! ```
35
36use crate::geometry::{Feature, FeatureCollection, Geometry, Point, PropertyValue};
37use rustial_math::GeoCoord;
38use std::collections::HashMap;
39
40// ---------------------------------------------------------------------------
41// Configuration
42// ---------------------------------------------------------------------------
43
44/// Options controlling point-clustering behaviour.
45#[derive(Debug, Clone)]
46pub struct ClusterOptions {
47    /// Screen-pixel clustering radius (default 50).
48    pub radius: f64,
49    /// Maximum zoom level at which clusters are generated.
50    /// At zooms above this, all points are returned individually.
51    pub max_zoom: u8,
52    /// Minimum zoom level at which clusters are generated.
53    pub min_zoom: u8,
54    /// Minimum number of points to form a cluster (default 2).
55    pub min_points: usize,
56    /// Tile extent in pixel units (default 512).
57    pub tile_extent: f64,
58}
59
60impl Default for ClusterOptions {
61    fn default() -> Self {
62        Self {
63            radius: 50.0,
64            max_zoom: 16,
65            min_zoom: 0,
66            min_points: 2,
67            tile_extent: 512.0,
68        }
69    }
70}
71
72// ---------------------------------------------------------------------------
73// Internal representation
74// ---------------------------------------------------------------------------
75
76/// Internal node — either an original point or a cluster.
77#[derive(Debug, Clone)]
78struct ClusterNode {
79    /// Mercator-projected `[x, y]` in `[0, 1)` range.
80    x: f64,
81    y: f64,
82    /// Weight (number of contained points).
83    point_count: u32,
84    /// Index into the *original* points array if this is a leaf (weight == 1).
85    /// For cluster nodes this is `u32::MAX`.
86    source_index: u32,
87    /// Unique cluster id assigned during clustering.
88    cluster_id: u32,
89    /// Zoom level at which this cluster was formed.
90    /// For leaf nodes this is `u8::MAX`.
91    cluster_zoom: u8,
92    /// Indices of the children (source points or other clusters) that
93    /// were merged to form this cluster.  Empty for leaf nodes.
94    children: Vec<u32>,
95    /// Aggregated properties for this cluster (e.g. sums, counts).
96    properties: HashMap<String, PropertyValue>,
97}
98
99// ---------------------------------------------------------------------------
100// Spatial grid index
101// ---------------------------------------------------------------------------
102
103/// Simple grid-based spatial index for a single zoom level.
104struct SpatialGrid {
105    cells: HashMap<u64, Vec<usize>>,
106    cell_size: f64,
107}
108
109impl SpatialGrid {
110    fn new(cell_size: f64) -> Self {
111        Self {
112            cells: HashMap::new(),
113            cell_size,
114        }
115    }
116
117    fn insert(&mut self, idx: usize, x: f64, y: f64) {
118        let key = self.cell_key(x, y);
119        self.cells.entry(key).or_default().push(idx);
120    }
121
122    /// Return all indices of nodes whose grid cell is within `radius`
123    /// distance of `(cx, cy)`.  Callers must do exact-distance checks.
124    fn query_radius(&self, cx: f64, cy: f64, radius: f64) -> Vec<usize> {
125        let min_cell_x = ((cx - radius) / self.cell_size).floor() as i32;
126        let max_cell_x = ((cx + radius) / self.cell_size).floor() as i32;
127        let min_cell_y = ((cy - radius) / self.cell_size).floor() as i32;
128        let max_cell_y = ((cy + radius) / self.cell_size).floor() as i32;
129
130        let mut result = Vec::new();
131        for gx in min_cell_x..=max_cell_x {
132            for gy in min_cell_y..=max_cell_y {
133                let key = Self::raw_cell_key(gx, gy);
134                if let Some(bucket) = self.cells.get(&key) {
135                    result.extend_from_slice(bucket);
136                }
137            }
138        }
139        result
140    }
141
142    #[inline]
143    fn cell_key(&self, x: f64, y: f64) -> u64 {
144        let gx = (x / self.cell_size).floor() as i32;
145        let gy = (y / self.cell_size).floor() as i32;
146        Self::raw_cell_key(gx, gy)
147    }
148
149    #[inline]
150    fn raw_cell_key(gx: i32, gy: i32) -> u64 {
151        // Pack two i32 into a u64 key.
152        let ux = gx as u32;
153        let uy = gy as u32;
154        (ux as u64) << 32 | (uy as u64)
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Mercator helpers
160// ---------------------------------------------------------------------------
161
162/// Convert longitude to Mercator `x` in `[0, 1)`.
163#[inline]
164fn lng_x(lng: f64) -> f64 {
165    lng / 360.0 + 0.5
166}
167
168/// Convert latitude to Mercator `y` in `[0, 1)` (clamped).
169#[inline]
170fn lat_y(lat: f64) -> f64 {
171    let sin_lat = (lat * std::f64::consts::PI / 180.0).sin();
172    let y = 0.5 - 0.25 * ((1.0 + sin_lat) / (1.0 - sin_lat)).ln() / std::f64::consts::PI;
173    y.clamp(0.0, 1.0)
174}
175
176/// Convert Mercator `x` back to longitude.
177#[inline]
178fn x_lng(x: f64) -> f64 {
179    (x - 0.5) * 360.0
180}
181
182/// Convert Mercator `y` back to latitude.
183#[inline]
184fn y_lat(y: f64) -> f64 {
185    let y2 = (180.0 - 360.0 * y) * std::f64::consts::PI / 180.0;
186    360.0 * y2.exp().atan() / std::f64::consts::PI - 90.0
187}
188
189// ---------------------------------------------------------------------------
190// PointCluster
191// ---------------------------------------------------------------------------
192
193/// A zoom-based point clustering index.
194///
195/// Load a set of point features, then query clustered results at any
196/// zoom level.  The algorithm mirrors MapLibre/Mapbox supercluster.
197pub struct PointCluster {
198    options: ClusterOptions,
199    /// All nodes.  Original points are indices `0..point_count`.
200    /// Cluster nodes follow.
201    nodes: Vec<ClusterNode>,
202    /// Number of original input points.
203    input_count: usize,
204    /// Original features (for returning unclustered results).
205    original_features: Vec<Feature>,
206    /// Per-zoom node index sets.  `zoom_nodes[z]` holds the node indices
207    /// that are active at zoom `z`.
208    zoom_nodes: Vec<Vec<usize>>,
209    /// Next cluster ID to assign.
210    next_cluster_id: u32,
211}
212
213impl PointCluster {
214    /// Create a new (empty) cluster index with the given options.
215    pub fn new(options: ClusterOptions) -> Self {
216        let zoom_range = (options.max_zoom as usize + 2).max(1);
217        Self {
218            options,
219            nodes: Vec::new(),
220            input_count: 0,
221            original_features: Vec::new(),
222            zoom_nodes: vec![Vec::new(); zoom_range],
223            next_cluster_id: 0,
224        }
225    }
226
227    /// Load (or replace) the point features and build the cluster index.
228    ///
229    /// Non-point features are silently ignored.
230    pub fn load(&mut self, features: &FeatureCollection) {
231        self.nodes.clear();
232        self.original_features.clear();
233        self.next_cluster_id = 0;
234        for v in &mut self.zoom_nodes {
235            v.clear();
236        }
237
238        // Extract points and project to Mercator.
239        for feature in &features.features {
240            if let Some(coord) = extract_point_coord(&feature.geometry) {
241                let node = ClusterNode {
242                    x: lng_x(coord.lon),
243                    y: lat_y(coord.lat),
244                    point_count: 1,
245                    source_index: self.nodes.len() as u32,
246                    cluster_id: u32::MAX,
247                    cluster_zoom: u8::MAX,
248                    children: Vec::new(),
249                    properties: HashMap::new(),
250                };
251                self.nodes.push(node);
252                self.original_features.push(feature.clone());
253            }
254        }
255        self.input_count = self.nodes.len();
256
257        if self.input_count == 0 {
258            return;
259        }
260
261        // Start with all input points active at max_zoom + 1.
262        let top_zoom = self.options.max_zoom as usize + 1;
263        if top_zoom < self.zoom_nodes.len() {
264            self.zoom_nodes[top_zoom] = (0..self.input_count).collect();
265        }
266
267        // Cluster from max_zoom down to min_zoom.
268        for z in (self.options.min_zoom..=self.options.max_zoom).rev() {
269            self.cluster_zoom(z);
270        }
271    }
272
273    /// Return the clustered features for a given zoom level.
274    ///
275    /// Features with `point_count > 1` are clusters; their properties
276    /// include `cluster: true`, `cluster_id`, and `point_count`.
277    /// Individual points carry their original properties.
278    pub fn get_clusters_for_zoom(&self, zoom: u8) -> FeatureCollection {
279        let z = zoom.min(self.options.max_zoom + 1) as usize;
280        if z >= self.zoom_nodes.len() {
281            // Above max_zoom — return all original points.
282            return FeatureCollection {
283                features: self.original_features.clone(),
284            };
285        }
286
287        let active = &self.zoom_nodes[z];
288        let mut out = Vec::with_capacity(active.len());
289        for &idx in active {
290            let node = &self.nodes[idx];
291            if node.point_count == 1 && (node.source_index as usize) < self.original_features.len()
292            {
293                // Unclustered original point.
294                out.push(self.original_features[node.source_index as usize].clone());
295            } else {
296                // Cluster feature — synthesize a Point at the weighted centroid.
297                let coord = GeoCoord::new(y_lat(node.y), x_lng(node.x), 0.0);
298                let mut props = node.properties.clone();
299                props.insert("cluster".into(), PropertyValue::Bool(true));
300                props.insert(
301                    "cluster_id".into(),
302                    PropertyValue::Number(node.cluster_id as f64),
303                );
304                props.insert(
305                    "point_count".into(),
306                    PropertyValue::Number(node.point_count as f64),
307                );
308                props.insert(
309                    "point_count_abbreviated".into(),
310                    PropertyValue::String(abbreviate_count(node.point_count)),
311                );
312                out.push(Feature {
313                    geometry: Geometry::Point(Point { coord }),
314                    properties: props,
315                });
316            }
317        }
318
319        FeatureCollection { features: out }
320    }
321
322    /// Return the zoom level at which a cluster expands (breaks into
323    /// its children).  Returns `None` if the id is not a cluster.
324    pub fn get_cluster_expansion_zoom(&self, cluster_id: u32) -> Option<u8> {
325        for node in &self.nodes {
326            if node.cluster_id == cluster_id && node.point_count > 1 {
327                return Some(node.cluster_zoom.saturating_add(1));
328            }
329        }
330        None
331    }
332
333    /// Return the immediate children of a cluster (may be sub-clusters
334    /// or original points).  Returns `None` if the id is not a cluster.
335    pub fn get_cluster_children(&self, cluster_id: u32) -> Option<Vec<Feature>> {
336        let node = self
337            .nodes
338            .iter()
339            .find(|n| n.cluster_id == cluster_id && n.point_count > 1)?;
340        let mut children = Vec::new();
341        for &child_idx in &node.children {
342            let child_idx = child_idx as usize;
343            if child_idx >= self.nodes.len() {
344                continue;
345            }
346            let child = &self.nodes[child_idx];
347            if child.point_count == 1
348                && (child.source_index as usize) < self.original_features.len()
349            {
350                children.push(self.original_features[child.source_index as usize].clone());
351            } else {
352                let coord = GeoCoord::new(y_lat(child.y), x_lng(child.x), 0.0);
353                let mut props = child.properties.clone();
354                props.insert("cluster".into(), PropertyValue::Bool(true));
355                props.insert(
356                    "cluster_id".into(),
357                    PropertyValue::Number(child.cluster_id as f64),
358                );
359                props.insert(
360                    "point_count".into(),
361                    PropertyValue::Number(child.point_count as f64),
362                );
363                children.push(Feature {
364                    geometry: Geometry::Point(Point { coord }),
365                    properties: props,
366                });
367            }
368        }
369        Some(children)
370    }
371
372    /// Return all original (leaf) points contained in a cluster.
373    ///
374    /// `limit` caps the result size. `offset` skips the first N leaves.
375    pub fn get_cluster_leaves(
376        &self,
377        cluster_id: u32,
378        limit: usize,
379        offset: usize,
380    ) -> Option<Vec<Feature>> {
381        let node = self
382            .nodes
383            .iter()
384            .find(|n| n.cluster_id == cluster_id && n.point_count > 1)?;
385
386        let mut leaves = Vec::new();
387        let mut skipped = 0usize;
388        self.collect_leaves(node, limit, offset, &mut leaves, &mut skipped);
389        Some(leaves)
390    }
391
392    /// Number of original input points loaded.
393    pub fn input_count(&self) -> usize {
394        self.input_count
395    }
396
397    // -- Internal ---------------------------------------------------------
398
399    fn cluster_zoom(&mut self, zoom: u8) {
400        let z = zoom as usize;
401        let parent_z = z + 1;
402        if parent_z >= self.zoom_nodes.len() {
403            return;
404        }
405
406        // Clustering radius in Mercator coordinates for this zoom.
407        let r = self.options.radius / (self.options.tile_extent * (1u64 << zoom) as f64);
408
409        // Build spatial grid from the parent zoom's active nodes.
410        let parent_indices = self.zoom_nodes[parent_z].clone();
411        let mut grid = SpatialGrid::new(r);
412        for &idx in &parent_indices {
413            let node = &self.nodes[idx];
414            grid.insert(idx, node.x, node.y);
415        }
416
417        // Track which parent nodes have been consumed into a cluster.
418        let mut consumed = vec![false; self.nodes.len()];
419        let mut active_at_zoom: Vec<usize> = Vec::new();
420
421        for &idx in &parent_indices {
422            if consumed[idx] {
423                continue;
424            }
425
426            let node = &self.nodes[idx];
427            let cx = node.x;
428            let cy = node.y;
429
430            // Find neighbours within radius.
431            let candidates = grid.query_radius(cx, cy, r);
432            let mut neighbours: Vec<usize> = Vec::new();
433            for &cand_idx in &candidates {
434                if cand_idx == idx || consumed[cand_idx] {
435                    continue;
436                }
437                let cand = &self.nodes[cand_idx];
438                let dx = cand.x - cx;
439                let dy = cand.y - cy;
440                if dx * dx + dy * dy <= r * r {
441                    neighbours.push(cand_idx);
442                }
443            }
444
445            let total_count: u32 = node.point_count
446                + neighbours
447                    .iter()
448                    .map(|&i| self.nodes[i].point_count)
449                    .sum::<u32>();
450
451            if total_count < self.options.min_points as u32 || neighbours.is_empty() {
452                // Not enough points — keep as-is.
453                active_at_zoom.push(idx);
454                continue;
455            }
456
457            // Form a cluster.
458            let mut wx = cx * node.point_count as f64;
459            let mut wy = cy * node.point_count as f64;
460            let mut children = vec![idx as u32];
461            consumed[idx] = true;
462
463            for &ni in &neighbours {
464                let n = &self.nodes[ni];
465                wx += n.x * n.point_count as f64;
466                wy += n.y * n.point_count as f64;
467                children.push(ni as u32);
468                consumed[ni] = true;
469            }
470
471            let cluster_id = self.next_cluster_id;
472            self.next_cluster_id += 1;
473
474            let cluster_node = ClusterNode {
475                x: wx / total_count as f64,
476                y: wy / total_count as f64,
477                point_count: total_count,
478                source_index: u32::MAX,
479                cluster_id,
480                cluster_zoom: zoom,
481                children,
482                properties: HashMap::new(),
483            };
484
485            let cluster_idx = self.nodes.len();
486            self.nodes.push(cluster_node);
487            // Extend consumed to cover the new node.
488            consumed.push(false);
489            active_at_zoom.push(cluster_idx);
490        }
491
492        // Nodes not consumed stay active at this zoom.
493        for &idx in &parent_indices {
494            if !consumed[idx] {
495                // Already pushed during the main loop.
496            }
497        }
498
499        if z < self.zoom_nodes.len() {
500            self.zoom_nodes[z] = active_at_zoom;
501        }
502    }
503
504    fn collect_leaves(
505        &self,
506        node: &ClusterNode,
507        limit: usize,
508        offset: usize,
509        leaves: &mut Vec<Feature>,
510        skipped: &mut usize,
511    ) {
512        if leaves.len() >= limit {
513            return;
514        }
515        for &child_idx in &node.children {
516            let ci = child_idx as usize;
517            if ci >= self.nodes.len() {
518                continue;
519            }
520            let child = &self.nodes[ci];
521            if child.point_count == 1 {
522                if *skipped < offset {
523                    *skipped += 1;
524                } else if leaves.len() < limit {
525                    if let Some(f) = self.original_features.get(child.source_index as usize) {
526                        leaves.push(f.clone());
527                    }
528                }
529            } else {
530                self.collect_leaves(child, limit, offset, leaves, skipped);
531            }
532        }
533    }
534}
535
536// ---------------------------------------------------------------------------
537// Helpers
538// ---------------------------------------------------------------------------
539
540/// Extract the coordinate from a Point or the first point of a MultiPoint.
541fn extract_point_coord(geometry: &Geometry) -> Option<GeoCoord> {
542    match geometry {
543        Geometry::Point(p) => Some(p.coord),
544        Geometry::MultiPoint(mp) => mp.points.first().map(|p| p.coord),
545        _ => None,
546    }
547}
548
549/// Abbreviate a point count: "1234" → "1.2k", "1234567" → "1.2M".
550fn abbreviate_count(n: u32) -> String {
551    if n >= 1_000_000 {
552        format!("{:.1}M", n as f64 / 1_000_000.0)
553    } else if n >= 10_000 {
554        format!("{:.0}k", n as f64 / 1_000.0)
555    } else if n >= 1_000 {
556        format!("{:.1}k", n as f64 / 1_000.0)
557    } else {
558        n.to_string()
559    }
560}
561
562// ---------------------------------------------------------------------------
563// Tests
564// ---------------------------------------------------------------------------
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn make_point_feature(lat: f64, lon: f64, name: &str) -> Feature {
571        let mut props = HashMap::new();
572        props.insert("name".into(), PropertyValue::String(name.into()));
573        Feature {
574            geometry: Geometry::Point(Point {
575                coord: GeoCoord::new(lat, lon, 0.0),
576            }),
577            properties: props,
578        }
579    }
580
581    fn sample_features() -> FeatureCollection {
582        FeatureCollection {
583            features: vec![
584                make_point_feature(40.7128, -74.0060, "New York"),
585                make_point_feature(40.7138, -74.0070, "Near NY 1"),
586                make_point_feature(40.7118, -74.0050, "Near NY 2"),
587                make_point_feature(34.0522, -118.2437, "Los Angeles"),
588                make_point_feature(41.8781, -87.6298, "Chicago"),
589                make_point_feature(41.8791, -87.6308, "Near Chicago"),
590            ],
591        }
592    }
593
594    #[test]
595    fn empty_input_produces_empty_output() {
596        let mut cluster = PointCluster::new(ClusterOptions::default());
597        cluster.load(&FeatureCollection::default());
598        assert_eq!(cluster.input_count(), 0);
599        let result = cluster.get_clusters_for_zoom(5);
600        assert!(result.is_empty());
601    }
602
603    #[test]
604    fn single_point_never_clusters() {
605        let fc = FeatureCollection {
606            features: vec![make_point_feature(51.5074, -0.1278, "London")],
607        };
608        let mut cluster = PointCluster::new(ClusterOptions::default());
609        cluster.load(&fc);
610
611        for z in 0..=20 {
612            let result = cluster.get_clusters_for_zoom(z);
613            assert_eq!(result.len(), 1, "zoom {z} should have 1 feature");
614        }
615    }
616
617    #[test]
618    fn nearby_points_cluster_at_low_zoom() {
619        let fc = sample_features();
620        let mut cluster = PointCluster::new(ClusterOptions {
621            radius: 80.0,
622            max_zoom: 14,
623            min_points: 2,
624            ..Default::default()
625        });
626        cluster.load(&fc);
627        assert_eq!(cluster.input_count(), 6);
628
629        // At very low zoom, nearby points should merge.
630        let low = cluster.get_clusters_for_zoom(2);
631        assert!(
632            low.len() < 6,
633            "at zoom 2 some points should cluster (got {} features)",
634            low.len()
635        );
636
637        // At max_zoom+1, all original points returned.
638        let high = cluster.get_clusters_for_zoom(20);
639        assert_eq!(high.len(), 6);
640    }
641
642    #[test]
643    fn cluster_features_have_expected_properties() {
644        let fc = sample_features();
645        let mut cluster = PointCluster::new(ClusterOptions {
646            radius: 80.0,
647            max_zoom: 14,
648            min_points: 2,
649            ..Default::default()
650        });
651        cluster.load(&fc);
652
653        let result = cluster.get_clusters_for_zoom(2);
654        for feature in &result.features {
655            if let Some(PropertyValue::Bool(true)) = feature.properties.get("cluster") {
656                // Cluster should have point_count > 1.
657                let count = feature
658                    .properties
659                    .get("point_count")
660                    .and_then(|v| v.as_f64())
661                    .unwrap_or(0.0);
662                assert!(count >= 2.0, "cluster point_count should be >= 2");
663
664                // Should have cluster_id.
665                assert!(
666                    feature.properties.contains_key("cluster_id"),
667                    "cluster should have cluster_id"
668                );
669
670                // Should have abbreviated count.
671                assert!(
672                    feature.properties.contains_key("point_count_abbreviated"),
673                    "cluster should have point_count_abbreviated"
674                );
675            }
676        }
677    }
678
679    #[test]
680    fn cluster_expansion_zoom() {
681        let fc = sample_features();
682        let mut cluster = PointCluster::new(ClusterOptions {
683            radius: 80.0,
684            max_zoom: 14,
685            min_points: 2,
686            ..Default::default()
687        });
688        cluster.load(&fc);
689
690        // Find a cluster at low zoom.
691        let result = cluster.get_clusters_for_zoom(2);
692        for feature in &result.features {
693            if let Some(PropertyValue::Bool(true)) = feature.properties.get("cluster") {
694                let cid = feature
695                    .properties
696                    .get("cluster_id")
697                    .and_then(|v| v.as_f64())
698                    .unwrap_or(0.0) as u32;
699                let exp_zoom = cluster.get_cluster_expansion_zoom(cid);
700                assert!(exp_zoom.is_some(), "expansion zoom should exist");
701                let ez = exp_zoom.unwrap();
702                assert!(ez > 2, "expansion zoom should be above query zoom");
703            }
704        }
705    }
706
707    #[test]
708    fn cluster_children_and_leaves() {
709        let fc = sample_features();
710        let mut cluster = PointCluster::new(ClusterOptions {
711            radius: 80.0,
712            max_zoom: 14,
713            min_points: 2,
714            ..Default::default()
715        });
716        cluster.load(&fc);
717
718        let result = cluster.get_clusters_for_zoom(2);
719        for feature in &result.features {
720            if let Some(PropertyValue::Bool(true)) = feature.properties.get("cluster") {
721                let cid = feature
722                    .properties
723                    .get("cluster_id")
724                    .and_then(|v| v.as_f64())
725                    .unwrap_or(0.0) as u32;
726
727                let children = cluster.get_cluster_children(cid);
728                assert!(children.is_some(), "children should exist");
729                assert!(
730                    !children.unwrap().is_empty(),
731                    "children should not be empty"
732                );
733
734                let leaves = cluster.get_cluster_leaves(cid, 100, 0);
735                assert!(leaves.is_some(), "leaves should exist");
736                assert!(!leaves.unwrap().is_empty(), "leaves should not be empty");
737            }
738        }
739    }
740
741    #[test]
742    fn high_zoom_returns_all_originals() {
743        let fc = sample_features();
744        let mut cluster = PointCluster::new(ClusterOptions::default());
745        cluster.load(&fc);
746
747        let result = cluster.get_clusters_for_zoom(20);
748        assert_eq!(result.len(), fc.len());
749    }
750
751    #[test]
752    fn non_point_features_are_ignored() {
753        let fc = FeatureCollection {
754            features: vec![
755                make_point_feature(40.0, -74.0, "point"),
756                Feature {
757                    geometry: Geometry::LineString(crate::geometry::LineString {
758                        coords: vec![
759                            GeoCoord::new(40.0, -74.0, 0.0),
760                            GeoCoord::new(41.0, -73.0, 0.0),
761                        ],
762                    }),
763                    properties: HashMap::new(),
764                },
765            ],
766        };
767        let mut cluster = PointCluster::new(ClusterOptions::default());
768        cluster.load(&fc);
769        assert_eq!(cluster.input_count(), 1);
770    }
771
772    #[test]
773    fn abbreviate_count_formatting() {
774        assert_eq!(abbreviate_count(1), "1");
775        assert_eq!(abbreviate_count(999), "999");
776        assert_eq!(abbreviate_count(1_000), "1.0k");
777        assert_eq!(abbreviate_count(5_432), "5.4k");
778        assert_eq!(abbreviate_count(10_000), "10k");
779        assert_eq!(abbreviate_count(99_999), "100k");
780        assert_eq!(abbreviate_count(1_000_000), "1.0M");
781        assert_eq!(abbreviate_count(1_500_000), "1.5M");
782    }
783
784    #[test]
785    fn mercator_round_trip() {
786        let coords = [
787            (0.0, 0.0),
788            (51.5, -0.12),
789            (40.71, -74.0),
790            (-33.87, 151.21),
791            (85.0, 180.0),
792            (-85.0, -180.0),
793        ];
794        for (lat, lon) in coords {
795            let x = lng_x(lon);
796            let y = lat_y(lat);
797            let lon2 = x_lng(x);
798            let lat2 = y_lat(y);
799            assert!(
800                (lat - lat2).abs() < 0.01,
801                "lat round-trip failed: {lat} -> {lat2}"
802            );
803            assert!(
804                (lon - lon2).abs() < 0.01,
805                "lon round-trip failed: {lon} -> {lon2}"
806            );
807        }
808    }
809}