1use crate::geometry::{Feature, FeatureCollection, Geometry, Point, PropertyValue};
37use rustial_math::GeoCoord;
38use std::collections::HashMap;
39
40#[derive(Debug, Clone)]
46pub struct ClusterOptions {
47 pub radius: f64,
49 pub max_zoom: u8,
52 pub min_zoom: u8,
54 pub min_points: usize,
56 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#[derive(Debug, Clone)]
78struct ClusterNode {
79 x: f64,
81 y: f64,
82 point_count: u32,
84 source_index: u32,
87 cluster_id: u32,
89 cluster_zoom: u8,
92 children: Vec<u32>,
95 properties: HashMap<String, PropertyValue>,
97}
98
99struct 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 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 let ux = gx as u32;
153 let uy = gy as u32;
154 (ux as u64) << 32 | (uy as u64)
155 }
156}
157
158#[inline]
164fn lng_x(lng: f64) -> f64 {
165 lng / 360.0 + 0.5
166}
167
168#[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#[inline]
178fn x_lng(x: f64) -> f64 {
179 (x - 0.5) * 360.0
180}
181
182#[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
189pub struct PointCluster {
198 options: ClusterOptions,
199 nodes: Vec<ClusterNode>,
202 input_count: usize,
204 original_features: Vec<Feature>,
206 zoom_nodes: Vec<Vec<usize>>,
209 next_cluster_id: u32,
211}
212
213impl PointCluster {
214 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 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 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 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 for z in (self.options.min_zoom..=self.options.max_zoom).rev() {
269 self.cluster_zoom(z);
270 }
271 }
272
273 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 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 out.push(self.original_features[node.source_index as usize].clone());
295 } else {
296 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 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 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 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 pub fn input_count(&self) -> usize {
394 self.input_count
395 }
396
397 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 let r = self.options.radius / (self.options.tile_extent * (1u64 << zoom) as f64);
408
409 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 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 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 active_at_zoom.push(idx);
454 continue;
455 }
456
457 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 consumed.push(false);
489 active_at_zoom.push(cluster_idx);
490 }
491
492 for &idx in &parent_indices {
494 if !consumed[idx] {
495 }
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
536fn 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
549fn 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#[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 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 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 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 assert!(
666 feature.properties.contains_key("cluster_id"),
667 "cluster should have cluster_id"
668 );
669
670 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 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}