1use crate::geometry::{
37 Feature, FeatureCollection, Geometry, Point, PropertyValue,
38};
39use rustial_math::GeoCoord;
40use std::collections::HashMap;
41
42#[derive(Debug, Clone)]
48pub struct ClusterOptions {
49 pub radius: f64,
51 pub max_zoom: u8,
54 pub min_zoom: u8,
56 pub min_points: usize,
58 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#[derive(Debug, Clone)]
80struct ClusterNode {
81 x: f64,
83 y: f64,
84 point_count: u32,
86 source_index: u32,
89 cluster_id: u32,
91 cluster_zoom: u8,
94 children: Vec<u32>,
97 properties: HashMap<String, PropertyValue>,
99}
100
101struct 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 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 let ux = gx as u32;
155 let uy = gy as u32;
156 (ux as u64) << 32 | (uy as u64)
157 }
158}
159
160#[inline]
166fn lng_x(lng: f64) -> f64 {
167 lng / 360.0 + 0.5
168}
169
170#[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#[inline]
180fn x_lng(x: f64) -> f64 {
181 (x - 0.5) * 360.0
182}
183
184#[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
191pub struct PointCluster {
200 options: ClusterOptions,
201 nodes: Vec<ClusterNode>,
204 input_count: usize,
206 original_features: Vec<Feature>,
208 zoom_nodes: Vec<Vec<usize>>,
211 next_cluster_id: u32,
213}
214
215impl PointCluster {
216 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 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 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 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 for z in (self.options.min_zoom..=self.options.max_zoom).rev() {
271 self.cluster_zoom(z);
272 }
273 }
274
275 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 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 out.push(self.original_features[node.source_index as usize].clone());
297 } else {
298 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 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 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 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 pub fn input_count(&self) -> usize {
396 self.input_count
397 }
398
399 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 let r = self.options.radius / (self.options.tile_extent * (1u64 << zoom) as f64);
410
411 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 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 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 active_at_zoom.push(idx);
456 continue;
457 }
458
459 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 consumed.push(false);
491 active_at_zoom.push(cluster_idx);
492 }
493
494 for &idx in &parent_indices {
496 if !consumed[idx] {
497 }
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
541fn 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
554fn 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#[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 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 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 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 assert!(
671 feature.properties.contains_key("cluster_id"),
672 "cluster should have cluster_id"
673 );
674
675 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 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}