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