1#![doc = include_str!("../README.md")]
2
3mod scalar_field;
4pub mod surface;
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6
7use geo_clustering::{ClusterIndex, ClusterItem, ClusterOptions, ClusterPoint};
8use geo_core::{
9 simplify_geometry, BBox, Coordinate, GeoFeature, GeoFeatureCollection,
10 Geometry as GeoDataGeometry,
11};
12use geo_io_geojson::{parse_geojson, to_geojson_feature_collection, GeoJsonDocument};
13use rstar::{PointDistance, RTree, RTreeObject, AABB};
14use serde::{Deserialize, Serialize};
15use video_analysis_core::{DetectError, Result};
16
17pub type GeoVizMetricRecord = BTreeMap<String, f64>;
19
20pub type GeoVizBounds = [f64; 4];
22
23pub type GeoVizFeatureCollectionValue = serde_json::Value;
25
26pub use scalar_field::{
27 create_scalar_field_grid, GeoVizScalarFieldGrid, GeoVizScalarFieldIndex,
28 GeoVizScalarFieldOptions,
29};
30
31fn invalid_argument(message: impl Into<String>) -> DetectError {
32 DetectError::InvalidArgument(message.into())
33}
34
35#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct GeoVizPoint {
39 pub id: Option<String>,
41 pub label: Option<String>,
43 pub longitude: f64,
45 pub latitude: f64,
47 #[serde(default)]
49 pub metrics: GeoVizMetricRecord,
50 #[serde(default)]
52 pub properties: serde_json::Value,
53}
54
55#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct GeoVizIndexedPoint {
59 pub id: String,
61 pub source_index: usize,
63 pub label: String,
65 pub longitude: f64,
67 pub latitude: f64,
69 pub metrics: GeoVizMetricRecord,
71 pub properties: serde_json::Value,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct GeoVizViewportQuery {
79 pub bounds: GeoVizBounds,
81 pub zoom: f64,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
86#[serde(rename_all = "camelCase")]
87pub struct GeoVizAggregationOptions {
89 pub radius: Option<f64>,
91 pub extent: Option<f64>,
93 pub min_zoom: Option<u8>,
95 pub max_zoom: Option<u8>,
97}
98
99impl Default for GeoVizAggregationOptions {
100 fn default() -> Self {
101 Self {
102 radius: Some(72.0),
103 extent: Some(512.0),
104 min_zoom: Some(0),
105 max_zoom: Some(16),
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
111#[serde(
112 tag = "kind",
113 rename_all = "kebab-case",
114 rename_all_fields = "camelCase"
115)]
116pub enum GeoVizAggregationFeature {
118 Point {
120 coordinates: [f64; 2],
122 metrics: GeoVizMetricRecord,
124 point: GeoVizIndexedPoint,
126 },
127 Cluster {
129 cluster_id: String,
131 coordinates: [f64; 2],
133 expansion_zoom: usize,
135 metrics: GeoVizMetricRecord,
137 point_count: usize,
139 point_count_abbreviated: String,
141 },
142}
143
144#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
145#[serde(rename_all = "camelCase")]
146pub struct GeoVizAggregationSummary {
148 pub bounds: GeoVizBounds,
150 pub zoom: f64,
152 pub metrics: GeoVizMetricRecord,
154 pub visible_point_count: usize,
156 pub visible_cluster_count: usize,
158 pub visible_unclustered_count: usize,
160}
161
162#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
163#[serde(rename_all = "camelCase")]
164pub struct GeoVizAggregation {
166 pub features: Vec<GeoVizAggregationFeature>,
168 pub summary: GeoVizAggregationSummary,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
173#[serde(rename_all = "camelCase")]
174pub struct GeoVizNearestPointQuery {
176 pub longitude: f64,
178 pub latitude: f64,
180 #[serde(default)]
182 pub max_distance: Option<f64>,
183}
184
185#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
186#[serde(rename_all = "camelCase")]
187pub struct GeoVizHeatOptions {
189 #[serde(default)]
191 pub radius_meters: Option<f64>,
192 #[serde(default)]
194 pub weight_metric: Option<String>,
195}
196
197#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
198#[serde(rename_all = "camelCase")]
199pub struct GeoVizHeatFeature {
201 pub coordinates: [f64; 2],
203 pub id: String,
205 pub label: String,
207 pub metrics: GeoVizMetricRecord,
209 pub point: GeoVizIndexedPoint,
211 pub point_count: usize,
213 pub raw_weight: f64,
215 pub value: f64,
217}
218
219#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
220#[serde(rename_all = "camelCase")]
221pub struct GeoVizHeatSummary {
223 pub bounds: GeoVizBounds,
225 pub zoom: f64,
227 pub metrics: GeoVizMetricRecord,
229 pub max_weight: f64,
231 pub visible_point_count: usize,
233}
234
235#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
236#[serde(rename_all = "camelCase")]
237pub struct GeoVizHeatAggregation {
239 pub features: Vec<GeoVizHeatFeature>,
241 pub summary: GeoVizHeatSummary,
243}
244
245#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
246#[serde(rename_all = "camelCase")]
247pub struct GeoVizFlow {
249 pub id: Option<String>,
251 pub label: Option<String>,
253 pub from: [f64; 2],
255 pub to: [f64; 2],
257 #[serde(default)]
259 pub metrics: GeoVizMetricRecord,
260 #[serde(default)]
262 pub properties: serde_json::Value,
263}
264
265#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
266#[serde(rename_all = "camelCase")]
267pub struct GeoVizIndexedFlow {
269 pub id: String,
271 pub source_index: usize,
273 pub label: String,
275 pub from: [f64; 2],
277 pub to: [f64; 2],
279 pub metrics: GeoVizMetricRecord,
281 pub properties: serde_json::Value,
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
286#[serde(rename_all = "kebab-case")]
287pub enum GeoVizFlowAggregateMode {
289 #[default]
291 None,
292 OriginDestination,
294 Grid,
296}
297
298#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
299#[serde(rename_all = "camelCase")]
300pub struct GeoVizFlowOptions {
302 #[serde(default)]
304 pub aggregate: GeoVizFlowAggregateMode,
305 #[serde(default)]
307 pub min_weight: Option<f64>,
308 #[serde(default)]
310 pub weight_metric: Option<String>,
311}
312
313impl Default for GeoVizFlowOptions {
314 fn default() -> Self {
315 Self {
316 aggregate: GeoVizFlowAggregateMode::None,
317 min_weight: None,
318 weight_metric: None,
319 }
320 }
321}
322
323#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
324#[serde(rename_all = "camelCase")]
325pub struct GeoVizFlowFeature {
327 pub flow: GeoVizIndexedFlow,
329 pub raw_weight: f64,
331 pub value: f64,
333}
334
335#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
336#[serde(rename_all = "camelCase")]
337pub struct GeoVizFlowSummary {
339 pub bounds: Option<GeoVizBounds>,
341 pub viewport_bounds: GeoVizBounds,
343 pub zoom: f64,
345 pub metrics: GeoVizMetricRecord,
347 pub max_weight: f64,
349 pub visible_flow_count: usize,
351}
352
353#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
354#[serde(rename_all = "camelCase")]
355pub struct GeoVizFlowAggregation {
357 pub features: Vec<GeoVizFlowFeature>,
359 pub summary: GeoVizFlowSummary,
361}
362
363#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
364#[serde(rename_all = "camelCase")]
365pub struct GeoVizGeoJsonOptions {
367 #[serde(default = "default_clip_to_viewport")]
369 pub clip_to_viewport: bool,
370 #[serde(default)]
372 pub simplify_tolerance: Option<f64>,
373}
374
375impl Default for GeoVizGeoJsonOptions {
376 fn default() -> Self {
377 Self {
378 clip_to_viewport: true,
379 simplify_tolerance: None,
380 }
381 }
382}
383
384#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
385#[serde(rename_all = "camelCase")]
386pub struct GeoVizGeoJsonViewport {
388 pub bounds: Option<GeoVizBounds>,
390 pub feature_collection: GeoVizFeatureCollectionValue,
392 pub feature_count: usize,
394 pub viewport_bounds: GeoVizBounds,
396 pub zoom: f64,
398}
399
400#[derive(Debug, Clone)]
402pub struct GeoPointIndex {
403 points: Vec<GeoVizIndexedPoint>,
404 point_lookup: HashMap<String, GeoVizIndexedPoint>,
405 metric_keys: Vec<String>,
406 spatial_index: RTree<SpatialPoint>,
407 clusters: ClusterIndex<String>,
408}
409
410impl GeoPointIndex {
411 pub fn new(
413 points: impl IntoIterator<Item = GeoVizPoint>,
414 options: GeoVizAggregationOptions,
415 ) -> Result<Self> {
416 let normalized = points
417 .into_iter()
418 .enumerate()
419 .map(|(index, point)| normalize_point(point, index))
420 .collect::<Result<Vec<_>>>()?;
421 let metric_keys = collect_metric_keys(&normalized);
422 let point_lookup = normalized
423 .iter()
424 .cloned()
425 .map(|point| (point.id.clone(), point))
426 .collect::<HashMap<_, _>>();
427 let spatial_index = RTree::bulk_load(
428 normalized
429 .iter()
430 .map(|point| SpatialPoint {
431 point: [point.longitude, point.latitude],
432 point_id: point.id.clone(),
433 })
434 .collect(),
435 );
436 let clusters = ClusterIndex::new(
437 normalized.iter().map(|point| ClusterPoint {
438 id: point.id.clone(),
439 longitude: point.longitude,
440 latitude: point.latitude,
441 properties: point.id.clone(),
442 }),
443 ClusterOptions {
444 min_zoom: options.min_zoom.unwrap_or(0),
445 max_zoom: options.max_zoom.unwrap_or(16),
446 base_cell_count: 1,
447 },
448 )?;
449
450 Ok(Self {
451 points: normalized,
452 point_lookup,
453 metric_keys,
454 spatial_index,
455 clusters,
456 })
457 }
458
459 pub fn get_bounds(&self) -> Option<GeoVizBounds> {
461 bounds_for_points(&self.points)
462 }
463
464 pub fn get_point_by_id(&self, point_id: &str) -> Option<GeoVizIndexedPoint> {
466 self.point_lookup.get(point_id).cloned()
467 }
468
469 pub fn get_viewport_aggregation(
471 &self,
472 query: GeoVizViewportQuery,
473 ) -> Result<GeoVizAggregation> {
474 validate_bounds(query.bounds)?;
475 let zoom = query.zoom.round().clamp(0.0, u8::MAX as f64) as u8;
476 let raw_features = self.clusters.get_clusters(query.bounds, zoom)?;
477 let mut seen = BTreeSet::new();
478 let features = raw_features
479 .into_iter()
480 .filter_map(|feature| self.to_aggregation_feature(feature).transpose())
481 .collect::<Result<Vec<_>>>()?
482 .into_iter()
483 .filter(|feature| {
484 let key = feature_key(feature);
485 if seen.contains(&key) {
486 return false;
487 }
488 seen.insert(key);
489 true
490 })
491 .collect::<Vec<_>>();
492
493 Ok(GeoVizAggregation {
494 summary: summarize_features(query, &features, &self.metric_keys),
495 features,
496 })
497 }
498
499 pub fn get_heat_features(
501 &self,
502 query: GeoVizViewportQuery,
503 options: GeoVizHeatOptions,
504 ) -> Result<GeoVizHeatAggregation> {
505 validate_bounds(query.bounds)?;
506 let points = self
507 .points
508 .iter()
509 .filter(|point| point_in_bounds(point.longitude, point.latitude, query.bounds))
510 .cloned()
511 .collect::<Vec<_>>();
512 let weighted = points
513 .into_iter()
514 .filter_map(|point| {
515 let raw_weight = geo_weight(&point.metrics, options.weight_metric.as_deref());
516 (raw_weight > 0.0).then_some((point, raw_weight))
517 })
518 .collect::<Vec<_>>();
519 let max_weight = weighted
520 .iter()
521 .map(|(_, weight)| *weight)
522 .fold(1.0_f64, f64::max);
523 let features = weighted
524 .into_iter()
525 .map(|(point, raw_weight)| GeoVizHeatFeature {
526 coordinates: [point.longitude, point.latitude],
527 id: point.id.clone(),
528 label: point.label.clone(),
529 metrics: point.metrics.clone(),
530 point,
531 point_count: 1,
532 raw_weight,
533 value: raw_weight / max_weight,
534 })
535 .collect::<Vec<_>>();
536
537 Ok(GeoVizHeatAggregation {
538 summary: GeoVizHeatSummary {
539 bounds: query.bounds,
540 zoom: query.zoom,
541 metrics: sum_metrics(
542 features.iter().map(|feature| &feature.metrics),
543 &self.metric_keys,
544 ),
545 max_weight,
546 visible_point_count: features.len(),
547 },
548 features,
549 })
550 }
551
552 pub fn nearest_point(
554 &self,
555 query: GeoVizNearestPointQuery,
556 ) -> Result<Option<GeoVizIndexedPoint>> {
557 Coordinate::new(query.longitude, query.latitude)?.validate_geographic()?;
558 if let Some(max_distance) = query.max_distance {
559 if !max_distance.is_finite() || max_distance < 0.0 {
560 return Err(invalid_argument(
561 "maxDistance must be finite and non-negative",
562 ));
563 }
564 }
565 let nearest = self
566 .spatial_index
567 .nearest_neighbor([query.longitude, query.latitude]);
568 let Some(nearest) = nearest else {
569 return Ok(None);
570 };
571 if let Some(max_distance) = query.max_distance {
572 if nearest.distance_2(&[query.longitude, query.latitude]) > max_distance * max_distance
573 {
574 return Ok(None);
575 }
576 }
577
578 Ok(self.point_lookup.get(&nearest.point_id).cloned())
579 }
580
581 pub fn get_cluster_expansion_zoom(&self, cluster_id: &str) -> usize {
583 self.clusters.get_cluster_expansion_zoom(cluster_id)
584 }
585
586 pub fn get_cluster_leaves(
588 &self,
589 cluster_id: &str,
590 limit: usize,
591 offset: usize,
592 ) -> Vec<GeoVizIndexedPoint> {
593 self.clusters
594 .get_leaves(cluster_id, limit, offset)
595 .into_iter()
596 .map(|point| point.id)
597 .filter_map(|point_id| self.point_lookup.get(&point_id).cloned())
598 .collect()
599 }
600
601 fn to_aggregation_feature(
602 &self,
603 item: ClusterItem<String>,
604 ) -> Result<Option<GeoVizAggregationFeature>> {
605 match item {
606 ClusterItem::Cluster(cluster) => {
607 let cluster_id = cluster.id;
608 let point_count = cluster.point_count;
609 let point_count_abbreviated = abbreviate_count(point_count);
610 let leaves = self.get_cluster_leaves(&cluster_id, point_count, 0);
611 let metrics =
612 sum_metrics(leaves.iter().map(|point| &point.metrics), &self.metric_keys);
613
614 Ok(Some(GeoVizAggregationFeature::Cluster {
615 expansion_zoom: self.get_cluster_expansion_zoom(&cluster_id),
616 cluster_id,
617 coordinates: [cluster.longitude, cluster.latitude],
618 metrics,
619 point_count,
620 point_count_abbreviated,
621 }))
622 }
623 ClusterItem::Point(cluster_point) => {
624 let Some(point) = self.point_lookup.get(&cluster_point.id).cloned() else {
625 return Ok(None);
626 };
627 Ok(Some(GeoVizAggregationFeature::Point {
628 coordinates: [point.longitude, point.latitude],
629 metrics: point.metrics.clone(),
630 point,
631 }))
632 }
633 }
634 }
635}
636
637#[derive(Debug, Clone)]
638struct SpatialPoint {
639 point: [f64; 2],
640 point_id: String,
641}
642
643impl RTreeObject for SpatialPoint {
644 type Envelope = AABB<[f64; 2]>;
645
646 fn envelope(&self) -> Self::Envelope {
647 AABB::from_point(self.point)
648 }
649}
650
651impl PointDistance for SpatialPoint {
652 fn distance_2(&self, point: &[f64; 2]) -> f64 {
653 let dx = self.point[0] - point[0];
654 let dy = self.point[1] - point[1];
655 dx * dx + dy * dy
656 }
657}
658
659#[derive(Debug, Clone)]
661pub struct GeoFlowIndex {
662 flows: Vec<GeoVizIndexedFlow>,
663 metric_keys: Vec<String>,
664}
665
666impl GeoFlowIndex {
667 pub fn new(flows: impl IntoIterator<Item = GeoVizFlow>) -> Result<Self> {
669 let flows = flows
670 .into_iter()
671 .enumerate()
672 .map(|(index, flow)| normalize_flow(flow, index))
673 .collect::<Result<Vec<_>>>()?;
674 let metric_keys = collect_flow_metric_keys(&flows);
675
676 Ok(Self { flows, metric_keys })
677 }
678
679 pub fn get_bounds(&self) -> Option<GeoVizBounds> {
681 bounds_for_flows(&self.flows)
682 }
683
684 pub fn get_viewport_flows(
686 &self,
687 query: GeoVizViewportQuery,
688 options: GeoVizFlowOptions,
689 ) -> Result<GeoVizFlowAggregation> {
690 validate_bounds(query.bounds)?;
691 let min_weight = options.min_weight.unwrap_or(0.0);
692 if !min_weight.is_finite() || min_weight < 0.0 {
693 return Err(invalid_argument(
694 "minWeight must be finite and non-negative",
695 ));
696 }
697 let mut weighted = self
698 .flows
699 .iter()
700 .filter(|flow| flow_intersects_bounds(flow, query.bounds))
701 .filter_map(|flow| {
702 let raw_weight = geo_weight(&flow.metrics, options.weight_metric.as_deref());
703 (raw_weight >= min_weight && raw_weight > 0.0).then_some((flow.clone(), raw_weight))
704 })
705 .collect::<Vec<_>>();
706
707 if options.aggregate != GeoVizFlowAggregateMode::None {
708 weighted = aggregate_flows(weighted, &self.metric_keys);
709 }
710
711 let max_weight = weighted
712 .iter()
713 .map(|(_, weight)| *weight)
714 .fold(1.0_f64, f64::max);
715 let features = weighted
716 .into_iter()
717 .map(|(flow, raw_weight)| GeoVizFlowFeature {
718 flow,
719 raw_weight,
720 value: raw_weight / max_weight,
721 })
722 .collect::<Vec<_>>();
723
724 Ok(GeoVizFlowAggregation {
725 summary: GeoVizFlowSummary {
726 bounds: bounds_for_flows(features.iter().map(|feature| &feature.flow)),
727 viewport_bounds: query.bounds,
728 zoom: query.zoom,
729 metrics: sum_metrics(
730 features.iter().map(|feature| &feature.flow.metrics),
731 &self.metric_keys,
732 ),
733 max_weight,
734 visible_flow_count: features.len(),
735 },
736 features,
737 })
738 }
739}
740
741#[derive(Debug, Clone)]
743pub struct GeoJsonIndex {
744 collection: GeoFeatureCollection,
745}
746
747impl GeoJsonIndex {
748 pub fn new(geo_json: serde_json::Value) -> Result<Self> {
750 let text = match geo_json {
751 serde_json::Value::String(text) => text,
752 value if value.is_object() => value.to_string(),
753 _ => return Err(invalid_argument("geoJson must be an object or string")),
754 };
755 let collection = match parse_geojson(&text)? {
756 GeoJsonDocument::Geometry(geometry) => {
757 GeoFeatureCollection::new(vec![GeoFeature::new(Some(geometry))])
758 }
759 GeoJsonDocument::Feature(feature) => GeoFeatureCollection::new(vec![feature]),
760 GeoJsonDocument::FeatureCollection(collection) => collection,
761 };
762
763 Ok(Self { collection })
764 }
765
766 pub fn get_bounds(&self) -> Option<GeoVizBounds> {
768 bounds_for_collection(&self.collection)
769 }
770
771 pub fn get_viewport_features(
773 &self,
774 query: GeoVizViewportQuery,
775 options: GeoVizGeoJsonOptions,
776 ) -> Result<GeoVizGeoJsonViewport> {
777 validate_bounds(query.bounds)?;
778 let mut collection = if options.clip_to_viewport {
779 filter_collection_for_bounds(&self.collection, query.bounds)?
780 } else {
781 self.collection.clone()
782 };
783
784 if let Some(tolerance) = options.simplify_tolerance {
785 collection.features = collection
786 .features
787 .into_iter()
788 .map(|mut feature| {
789 feature.geometry = feature
790 .geometry
791 .as_ref()
792 .map(|geometry| simplify_geometry(geometry, tolerance))
793 .transpose()?;
794 Ok(feature)
795 })
796 .collect::<Result<Vec<_>>>()?;
797 }
798
799 let feature_collection =
800 serde_json::to_value(to_geojson_feature_collection(&collection.features))
801 .map_err(|error| invalid_argument(error.to_string()))?;
802
803 Ok(GeoVizGeoJsonViewport {
804 bounds: bounds_for_collection(&collection),
805 feature_collection,
806 feature_count: collection.features.len(),
807 viewport_bounds: query.bounds,
808 zoom: query.zoom,
809 })
810 }
811}
812
813fn normalize_point(point: GeoVizPoint, source_index: usize) -> Result<GeoVizIndexedPoint> {
814 Coordinate::new(point.longitude, point.latitude)?.validate_geographic()?;
815 Ok(GeoVizIndexedPoint {
816 id: point.id.unwrap_or_else(|| source_index.to_string()),
817 source_index,
818 label: point.label.unwrap_or_default(),
819 longitude: point.longitude,
820 latitude: point.latitude,
821 metrics: point
822 .metrics
823 .into_iter()
824 .filter(|(_, value)| value.is_finite())
825 .collect(),
826 properties: point.properties,
827 })
828}
829
830fn normalize_flow(flow: GeoVizFlow, source_index: usize) -> Result<GeoVizIndexedFlow> {
831 Coordinate::from_position(flow.from)?.validate_geographic()?;
832 Coordinate::from_position(flow.to)?.validate_geographic()?;
833 Ok(GeoVizIndexedFlow {
834 id: flow.id.unwrap_or_else(|| source_index.to_string()),
835 source_index,
836 label: flow.label.unwrap_or_default(),
837 from: flow.from,
838 to: flow.to,
839 metrics: flow
840 .metrics
841 .into_iter()
842 .filter(|(_, value)| value.is_finite())
843 .collect(),
844 properties: flow.properties,
845 })
846}
847
848fn collect_metric_keys(points: &[GeoVizIndexedPoint]) -> Vec<String> {
849 let mut keys = BTreeSet::new();
850 for point in points {
851 for key in point.metrics.keys() {
852 keys.insert(key.clone());
853 }
854 }
855 keys.into_iter().collect()
856}
857
858fn collect_flow_metric_keys(flows: &[GeoVizIndexedFlow]) -> Vec<String> {
859 let mut keys = BTreeSet::new();
860 for flow in flows {
861 for key in flow.metrics.keys() {
862 keys.insert(key.clone());
863 }
864 }
865 keys.into_iter().collect()
866}
867
868fn bounds_for_points(points: &[GeoVizIndexedPoint]) -> Option<GeoVizBounds> {
869 let first = points.first()?;
870 let mut west = first.longitude;
871 let mut south = first.latitude;
872 let mut east = first.longitude;
873 let mut north = first.latitude;
874
875 for point in points.iter().skip(1) {
876 west = west.min(point.longitude);
877 south = south.min(point.latitude);
878 east = east.max(point.longitude);
879 north = north.max(point.latitude);
880 }
881
882 Some([west, south, east, north])
883}
884
885fn bounds_for_flows<'a>(
886 flows: impl IntoIterator<Item = &'a GeoVizIndexedFlow>,
887) -> Option<GeoVizBounds> {
888 let mut coordinates = flows
889 .into_iter()
890 .flat_map(|flow| [flow.from, flow.to])
891 .collect::<Vec<_>>();
892 let first = coordinates.pop()?;
893 let mut west = first[0];
894 let mut south = first[1];
895 let mut east = first[0];
896 let mut north = first[1];
897
898 for coordinate in coordinates {
899 west = west.min(coordinate[0]);
900 south = south.min(coordinate[1]);
901 east = east.max(coordinate[0]);
902 north = north.max(coordinate[1]);
903 }
904
905 Some([west, south, east, north])
906}
907
908fn bounds_for_collection(collection: &GeoFeatureCollection) -> Option<GeoVizBounds> {
909 let mut bounds: Option<GeoVizBounds> = None;
910 for feature in &collection.features {
911 if let Some(geometry) = &feature.geometry {
912 visit_geometry_positions(geometry, &mut |position| {
913 bounds = Some(match bounds {
914 Some([west, south, east, north]) => [
915 west.min(position[0]),
916 south.min(position[1]),
917 east.max(position[0]),
918 north.max(position[1]),
919 ],
920 None => [position[0], position[1], position[0], position[1]],
921 });
922 });
923 }
924 }
925 bounds
926}
927
928fn filter_collection_for_bounds(
929 collection: &GeoFeatureCollection,
930 bounds: GeoVizBounds,
931) -> Result<GeoFeatureCollection> {
932 if bounds[0] <= bounds[2] {
933 let bbox = BBox::new(bounds)?;
934 return Ok(collection.filter_intersecting_bbox(bbox));
935 }
936
937 let west = BBox::new([bounds[0], bounds[1], 180.0, bounds[3]])?;
938 let east = BBox::new([-180.0, bounds[1], bounds[2], bounds[3]])?;
939 let mut seen = BTreeSet::new();
940 let features = collection
941 .features
942 .iter()
943 .filter(|feature| {
944 let intersects = feature.geometry.as_ref().is_some_and(|geometry| {
945 west.intersects_geometry(geometry) || east.intersects_geometry(geometry)
946 });
947 if !intersects {
948 return false;
949 }
950 let key = feature
951 .id
952 .clone()
953 .unwrap_or_else(|| serde_json::to_string(&feature.geometry).unwrap_or_default());
954 if seen.contains(&key) {
955 return false;
956 }
957 seen.insert(key);
958 true
959 })
960 .cloned()
961 .collect();
962
963 Ok(GeoFeatureCollection {
964 bbox: collection.bbox,
965 features,
966 })
967}
968
969fn visit_geometry_positions(geometry: &GeoDataGeometry, visit: &mut dyn FnMut([f64; 2])) {
970 match geometry {
971 GeoDataGeometry::Point { coordinates } => visit(*coordinates),
972 GeoDataGeometry::MultiPoint { coordinates }
973 | GeoDataGeometry::LineString { coordinates } => {
974 for position in coordinates {
975 visit(*position);
976 }
977 }
978 GeoDataGeometry::MultiLineString { coordinates }
979 | GeoDataGeometry::Polygon { coordinates } => {
980 for line in coordinates {
981 for position in line {
982 visit(*position);
983 }
984 }
985 }
986 GeoDataGeometry::MultiPolygon { coordinates } => {
987 for polygon in coordinates {
988 for ring in polygon {
989 for position in ring {
990 visit(*position);
991 }
992 }
993 }
994 }
995 GeoDataGeometry::GeometryCollection { geometries } => {
996 for geometry in geometries {
997 visit_geometry_positions(geometry, visit);
998 }
999 }
1000 }
1001}
1002
1003fn validate_bounds(bounds: GeoVizBounds) -> Result<()> {
1004 if bounds.iter().any(|value| !value.is_finite()) {
1005 return Err(invalid_argument("viewport bounds must be finite"));
1006 }
1007 if bounds[1] > bounds[3] {
1008 return Err(invalid_argument("viewport south must be <= north"));
1009 }
1010 if bounds[1] < -90.0 || bounds[3] > 90.0 {
1011 return Err(invalid_argument(
1012 "viewport latitude bounds must stay between -90 and 90",
1013 ));
1014 }
1015 Ok(())
1016}
1017
1018fn point_in_bounds(longitude: f64, latitude: f64, bounds: GeoVizBounds) -> bool {
1019 let longitude_visible = if bounds[0] <= bounds[2] {
1020 longitude >= bounds[0] && longitude <= bounds[2]
1021 } else {
1022 longitude >= bounds[0] || longitude <= bounds[2]
1023 };
1024
1025 longitude_visible && latitude >= bounds[1] && latitude <= bounds[3]
1026}
1027
1028fn flow_intersects_bounds(flow: &GeoVizIndexedFlow, bounds: GeoVizBounds) -> bool {
1029 point_in_bounds(flow.from[0], flow.from[1], bounds)
1030 || point_in_bounds(flow.to[0], flow.to[1], bounds)
1031 || flow_bbox_intersects_bounds(flow, bounds)
1032}
1033
1034fn flow_bbox_intersects_bounds(flow: &GeoVizIndexedFlow, bounds: GeoVizBounds) -> bool {
1035 let flow_west = flow.from[0].min(flow.to[0]);
1036 let flow_east = flow.from[0].max(flow.to[0]);
1037 let flow_south = flow.from[1].min(flow.to[1]);
1038 let flow_north = flow.from[1].max(flow.to[1]);
1039 let latitude_intersects = flow_south <= bounds[3] && flow_north >= bounds[1];
1040
1041 if !latitude_intersects {
1042 return false;
1043 }
1044
1045 if bounds[0] <= bounds[2] {
1046 flow_west <= bounds[2] && flow_east >= bounds[0]
1047 } else {
1048 flow_east >= bounds[0] || flow_west <= bounds[2]
1049 }
1050}
1051
1052fn geo_weight(metrics: &GeoVizMetricRecord, weight_metric: Option<&str>) -> f64 {
1053 let weight = weight_metric
1054 .and_then(|key| metrics.get(key))
1055 .copied()
1056 .or_else(|| metrics.get("weight").copied())
1057 .unwrap_or(1.0);
1058
1059 if weight.is_finite() {
1060 weight.max(0.0)
1061 } else {
1062 0.0
1063 }
1064}
1065
1066fn aggregate_flows(
1067 weighted: Vec<(GeoVizIndexedFlow, f64)>,
1068 metric_keys: &[String],
1069) -> Vec<(GeoVizIndexedFlow, f64)> {
1070 let mut grouped = BTreeMap::<String, (GeoVizIndexedFlow, f64)>::new();
1071
1072 for (flow, raw_weight) in weighted {
1073 let key = format!(
1074 "{:.6},{:.6}->{:.6},{:.6}",
1075 flow.from[0], flow.from[1], flow.to[0], flow.to[1]
1076 );
1077 let entry = grouped.entry(key).or_insert_with(|| {
1078 let mut flow = flow.clone();
1079 flow.id = format!(
1080 "{}:{}:{}:{}",
1081 flow.from[0], flow.from[1], flow.to[0], flow.to[1]
1082 );
1083 flow.label.clear();
1084 flow.metrics = metric_keys.iter().map(|key| (key.clone(), 0.0)).collect();
1085 (flow, 0.0)
1086 });
1087 entry.1 += raw_weight;
1088 for key in metric_keys {
1089 *entry.0.metrics.entry(key.clone()).or_insert(0.0) +=
1090 flow.metrics.get(key).copied().unwrap_or(0.0);
1091 }
1092 }
1093
1094 grouped.into_values().collect()
1095}
1096
1097fn default_clip_to_viewport() -> bool {
1098 true
1099}
1100
1101fn sum_metrics<'a>(
1102 records: impl IntoIterator<Item = &'a GeoVizMetricRecord>,
1103 metric_keys: &[String],
1104) -> GeoVizMetricRecord {
1105 let mut metrics = metric_keys
1106 .iter()
1107 .map(|key| (key.clone(), 0.0))
1108 .collect::<GeoVizMetricRecord>();
1109
1110 for record in records {
1111 for key in metric_keys {
1112 *metrics.entry(key.clone()).or_insert(0.0) += record.get(key).copied().unwrap_or(0.0);
1113 }
1114 }
1115
1116 metrics
1117}
1118
1119fn summarize_features(
1120 query: GeoVizViewportQuery,
1121 features: &[GeoVizAggregationFeature],
1122 metric_keys: &[String],
1123) -> GeoVizAggregationSummary {
1124 let mut metrics = metric_keys
1125 .iter()
1126 .map(|key| (key.clone(), 0.0))
1127 .collect::<GeoVizMetricRecord>();
1128 let mut visible_point_count = 0;
1129 let mut visible_cluster_count = 0;
1130 let mut visible_unclustered_count = 0;
1131
1132 for feature in features {
1133 let (point_count, feature_metrics) = match feature {
1134 GeoVizAggregationFeature::Point { metrics, .. } => {
1135 visible_unclustered_count += 1;
1136 (1, metrics)
1137 }
1138 GeoVizAggregationFeature::Cluster {
1139 metrics,
1140 point_count,
1141 ..
1142 } => {
1143 visible_cluster_count += 1;
1144 (*point_count, metrics)
1145 }
1146 };
1147 visible_point_count += point_count;
1148 for key in metric_keys {
1149 *metrics.entry(key.clone()).or_insert(0.0) +=
1150 feature_metrics.get(key).copied().unwrap_or(0.0);
1151 }
1152 }
1153
1154 GeoVizAggregationSummary {
1155 bounds: query.bounds,
1156 zoom: query.zoom,
1157 metrics,
1158 visible_point_count,
1159 visible_cluster_count,
1160 visible_unclustered_count,
1161 }
1162}
1163
1164fn feature_key(feature: &GeoVizAggregationFeature) -> String {
1165 match feature {
1166 GeoVizAggregationFeature::Point { point, .. } => format!("point:{}", point.id),
1167 GeoVizAggregationFeature::Cluster { cluster_id, .. } => format!("cluster:{cluster_id}"),
1168 }
1169}
1170
1171fn abbreviate_count(count: usize) -> String {
1172 if count >= 10_000 {
1173 format!("{}k", count / 1_000)
1174 } else if count >= 1_000 {
1175 format!("{:.1}k", count as f64 / 1_000.0)
1176 } else {
1177 count.to_string()
1178 }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184 use serde_json::json;
1185
1186 fn point(id: &str, longitude: f64, latitude: f64, value: f64) -> GeoVizPoint {
1187 GeoVizPoint {
1188 id: Some(id.to_string()),
1189 label: Some(id.to_string()),
1190 longitude,
1191 latitude,
1192 metrics: BTreeMap::from([("value".to_string(), value)]),
1193 properties: json!({"id": id}),
1194 }
1195 }
1196
1197 #[test]
1198 fn normalize_point_rejects_invalid_coordinates_and_preserves_finite_metrics() {
1199 let normalized = normalize_point(
1200 GeoVizPoint {
1201 id: Some("a".to_string()),
1202 label: Some("Alpha".to_string()),
1203 longitude: 13.0,
1204 latitude: 52.0,
1205 metrics: BTreeMap::from([
1206 ("value".to_string(), 2.0),
1207 ("bad".to_string(), f64::NAN),
1208 ]),
1209 properties: json!({"source": "test"}),
1210 },
1211 7,
1212 )
1213 .expect("normalized point");
1214
1215 assert_eq!(normalized.id, "a");
1216 assert_eq!(normalized.source_index, 7);
1217 assert_eq!(
1218 normalized.metrics,
1219 BTreeMap::from([("value".to_string(), 2.0)])
1220 );
1221 assert_eq!(normalized.properties["source"], "test");
1222 assert!(normalize_point(point("bad", 181.0, 52.0, 1.0), 0).is_err());
1223 }
1224
1225 #[test]
1226 fn bounds_for_collection_handles_geometry_types_and_empty_collections() {
1227 let collection = GeoFeatureCollection::new(vec![
1228 GeoFeature::new(Some(GeoDataGeometry::Point {
1229 coordinates: [13.0, 52.0],
1230 })),
1231 GeoFeature::new(Some(GeoDataGeometry::LineString {
1232 coordinates: vec![[12.0, 51.0], [14.0, 53.0]],
1233 })),
1234 GeoFeature::new(Some(GeoDataGeometry::Polygon {
1235 coordinates: vec![vec![
1236 [11.0, 50.0],
1237 [15.0, 50.0],
1238 [15.0, 54.0],
1239 [11.0, 54.0],
1240 [11.0, 50.0],
1241 ]],
1242 })),
1243 ]);
1244
1245 assert_eq!(
1246 bounds_for_collection(&collection),
1247 Some([11.0, 50.0, 15.0, 54.0])
1248 );
1249 assert_eq!(
1250 bounds_for_collection(&GeoFeatureCollection::new(Vec::new())),
1251 None
1252 );
1253 }
1254
1255 #[test]
1256 fn flow_bbox_intersection_detects_crossing_and_non_crossing_flows() {
1257 let crossing = normalize_flow(
1258 GeoVizFlow {
1259 id: Some("crossing".to_string()),
1260 label: None,
1261 from: [10.0, 52.0],
1262 to: [16.0, 52.0],
1263 metrics: BTreeMap::new(),
1264 properties: json!({}),
1265 },
1266 0,
1267 )
1268 .unwrap();
1269 let outside = normalize_flow(
1270 GeoVizFlow {
1271 id: Some("outside".to_string()),
1272 label: None,
1273 from: [20.0, 60.0],
1274 to: [21.0, 61.0],
1275 metrics: BTreeMap::new(),
1276 properties: json!({}),
1277 },
1278 1,
1279 )
1280 .unwrap();
1281 let bounds = [12.0, 51.0, 14.0, 53.0];
1282
1283 assert!(flow_bbox_intersects_bounds(&crossing, bounds));
1284 assert!(!flow_bbox_intersects_bounds(&outside, bounds));
1285 }
1286
1287 #[test]
1288 fn feature_key_is_stable_for_points_and_clusters() {
1289 let point_feature = GeoVizAggregationFeature::Point {
1290 coordinates: [13.0, 52.0],
1291 metrics: BTreeMap::new(),
1292 point: normalize_point(point("a", 13.0, 52.0, 1.0), 0).unwrap(),
1293 };
1294 let cluster_feature = GeoVizAggregationFeature::Cluster {
1295 cluster_id: "z1:2:3".to_string(),
1296 coordinates: [13.0, 52.0],
1297 expansion_zoom: 2,
1298 metrics: BTreeMap::new(),
1299 point_count: 2,
1300 point_count_abbreviated: "2".to_string(),
1301 };
1302
1303 assert_eq!(feature_key(&point_feature), "point:a");
1304 assert_eq!(feature_key(&point_feature), "point:a");
1305 assert_eq!(feature_key(&cluster_feature), "cluster:z1:2:3");
1306 }
1307
1308 #[test]
1309 fn reports_bounds_and_lookup() {
1310 let index = GeoPointIndex::new(
1311 [point("a", 13.0, 52.0, 2.0), point("b", 14.0, 53.0, 3.0)],
1312 GeoVizAggregationOptions::default(),
1313 )
1314 .expect("index");
1315
1316 assert_eq!(index.get_bounds(), Some([13.0, 52.0, 14.0, 53.0]));
1317 assert_eq!(index.get_point_by_id("a").unwrap().metrics["value"], 2.0);
1318 }
1319
1320 #[test]
1321 fn rejects_invalid_coordinates() {
1322 let error = GeoPointIndex::new(
1323 [point("bad", 181.0, 52.0, 1.0)],
1324 GeoVizAggregationOptions::default(),
1325 )
1326 .expect_err("invalid longitude");
1327 assert!(error.to_string().contains("longitude"));
1328 }
1329
1330 #[test]
1331 fn aggregates_cluster_metrics_and_leaves() {
1332 let index = GeoPointIndex::new(
1333 [
1334 point("a", 13.0, 52.0, 2.0),
1335 point("b", 13.0001, 52.0001, 3.0),
1336 point("c", 13.0002, 52.0002, 5.0),
1337 ],
1338 GeoVizAggregationOptions {
1339 radius: Some(80.0),
1340 ..GeoVizAggregationOptions::default()
1341 },
1342 )
1343 .expect("index");
1344 let aggregation = index
1345 .get_viewport_aggregation(GeoVizViewportQuery {
1346 bounds: [12.9, 51.9, 13.1, 52.1],
1347 zoom: 1.0,
1348 })
1349 .expect("aggregation");
1350 let cluster = aggregation
1351 .features
1352 .iter()
1353 .find_map(|feature| match feature {
1354 GeoVizAggregationFeature::Cluster {
1355 cluster_id,
1356 metrics,
1357 point_count,
1358 ..
1359 } => Some((cluster_id.clone(), metrics.clone(), *point_count)),
1360 _ => None,
1361 })
1362 .expect("cluster");
1363
1364 assert_eq!(cluster.1["value"], 10.0);
1365 assert_eq!(cluster.2, 3);
1366 assert_eq!(index.get_cluster_leaves(&cluster.0, 2, 1).len(), 2);
1367 assert!(index.get_cluster_expansion_zoom(&cluster.0) >= 1);
1368 }
1369
1370 #[test]
1371 fn supports_antimeridian_bounds() {
1372 let index = GeoPointIndex::new(
1373 [
1374 point("west", -179.8, 10.0, 2.0),
1375 point("east", 179.8, 10.0, 3.0),
1376 ],
1377 GeoVizAggregationOptions::default(),
1378 )
1379 .expect("index");
1380 let aggregation = index
1381 .get_viewport_aggregation(GeoVizViewportQuery {
1382 bounds: [179.0, 0.0, -179.0, 20.0],
1383 zoom: 8.0,
1384 })
1385 .expect("aggregation");
1386
1387 assert_eq!(aggregation.summary.visible_point_count, 2);
1388 }
1389
1390 #[test]
1391 fn returns_heat_features_and_nearest_points() {
1392 let index = GeoPointIndex::new(
1393 [point("a", 13.0, 52.0, 2.0), point("b", 14.0, 53.0, 4.0)],
1394 GeoVizAggregationOptions::default(),
1395 )
1396 .expect("index");
1397 let heat = index
1398 .get_heat_features(
1399 GeoVizViewportQuery {
1400 bounds: [12.0, 51.0, 14.5, 53.5],
1401 zoom: 7.0,
1402 },
1403 GeoVizHeatOptions {
1404 radius_meters: Some(1000.0),
1405 weight_metric: Some("value".to_string()),
1406 },
1407 )
1408 .expect("heat");
1409
1410 assert_eq!(heat.features.len(), 2);
1411 assert_eq!(heat.summary.max_weight, 4.0);
1412 assert_eq!(
1413 index
1414 .nearest_point(GeoVizNearestPointQuery {
1415 longitude: 13.1,
1416 latitude: 52.1,
1417 max_distance: Some(1.0),
1418 })
1419 .expect("nearest")
1420 .map(|point| point.id),
1421 Some("a".to_string())
1422 );
1423 }
1424
1425 #[test]
1426 fn filters_geojson_features_by_viewport() {
1427 let index = GeoJsonIndex::new(json!({
1428 "type": "FeatureCollection",
1429 "features": [
1430 {
1431 "type": "Feature",
1432 "id": "inside",
1433 "properties": {"name": "inside"},
1434 "geometry": {"type": "Point", "coordinates": [13.0, 52.0]}
1435 },
1436 {
1437 "type": "Feature",
1438 "id": "outside",
1439 "properties": {"name": "outside"},
1440 "geometry": {"type": "Point", "coordinates": [20.0, 60.0]}
1441 },
1442 {
1443 "type": "Feature",
1444 "id": "crossing",
1445 "properties": {"name": "crossing"},
1446 "geometry": {"type": "LineString", "coordinates": [[11.0, 52.0], [15.0, 52.0]]}
1447 }
1448 ]
1449 }))
1450 .expect("geojson index");
1451 let viewport = index
1452 .get_viewport_features(
1453 GeoVizViewportQuery {
1454 bounds: [12.0, 51.0, 14.0, 53.0],
1455 zoom: 5.0,
1456 },
1457 GeoVizGeoJsonOptions::default(),
1458 )
1459 .expect("viewport");
1460
1461 assert_eq!(index.get_bounds(), Some([11.0, 52.0, 20.0, 60.0]));
1462 assert_eq!(viewport.feature_count, 2);
1463 assert_eq!(viewport.feature_collection["features"][0]["id"], "inside");
1464 }
1465
1466 #[test]
1467 fn parsed_geojson_collection_feeds_viewport_index() {
1468 let GeoJsonDocument::FeatureCollection(collection) = parse_geojson(
1469 r#"{"type":"FeatureCollection","features":[{"type":"Feature","id":"inside","properties":{},"geometry":{"type":"Point","coordinates":[13,52]}},{"type":"Feature","id":"outside","properties":{},"geometry":{"type":"Point","coordinates":[20,60]}}]}"#,
1470 )
1471 .unwrap()
1472 else {
1473 panic!("expected feature collection");
1474 };
1475 let geojson = serde_json::to_value(to_geojson_feature_collection(&collection.features))
1476 .expect("geojson value");
1477 let index = GeoJsonIndex::new(geojson).expect("geojson index");
1478 let viewport = index
1479 .get_viewport_features(
1480 GeoVizViewportQuery {
1481 bounds: [12.0, 51.0, 14.0, 53.0],
1482 zoom: 5.0,
1483 },
1484 GeoVizGeoJsonOptions::default(),
1485 )
1486 .expect("viewport");
1487
1488 assert_eq!(viewport.feature_count, 1);
1489 assert_eq!(viewport.feature_collection["features"][0]["id"], "inside");
1490 }
1491
1492 #[test]
1493 fn clustering_output_can_feed_geo_viz_aggregation() {
1494 let cluster_index = ClusterIndex::new(
1495 [
1496 ClusterPoint {
1497 id: "a".to_string(),
1498 longitude: 13.0,
1499 latitude: 52.0,
1500 properties: BTreeMap::from([("value".to_string(), 2.0)]),
1501 },
1502 ClusterPoint {
1503 id: "b".to_string(),
1504 longitude: 13.0001,
1505 latitude: 52.0001,
1506 properties: BTreeMap::from([("value".to_string(), 3.0)]),
1507 },
1508 ],
1509 ClusterOptions::default(),
1510 )
1511 .expect("cluster index");
1512 let points = cluster_index
1513 .get_clusters([12.0, 51.0, 14.0, 53.0], 16)
1514 .unwrap()
1515 .into_iter()
1516 .filter_map(|item| match item {
1517 ClusterItem::Point(point) => Some(GeoVizPoint {
1518 id: Some(point.id),
1519 label: None,
1520 longitude: point.longitude,
1521 latitude: point.latitude,
1522 metrics: point.properties,
1523 properties: json!({}),
1524 }),
1525 ClusterItem::Cluster(_) => None,
1526 });
1527 let viz =
1528 GeoPointIndex::new(points, GeoVizAggregationOptions::default()).expect("viz index");
1529 let aggregation = viz
1530 .get_viewport_aggregation(GeoVizViewportQuery {
1531 bounds: [12.0, 51.0, 14.0, 53.0],
1532 zoom: 16.0,
1533 })
1534 .expect("aggregation");
1535
1536 assert_eq!(aggregation.summary.visible_point_count, 2);
1537 assert_eq!(aggregation.summary.metrics["value"], 5.0);
1538 }
1539
1540 #[test]
1541 fn filters_and_aggregates_flows() {
1542 let index = GeoFlowIndex::new([
1543 GeoVizFlow {
1544 id: Some("a".to_string()),
1545 label: None,
1546 from: [13.0, 52.0],
1547 to: [14.0, 53.0],
1548 metrics: BTreeMap::from([("value".to_string(), 2.0)]),
1549 properties: json!({}),
1550 },
1551 GeoVizFlow {
1552 id: Some("b".to_string()),
1553 label: None,
1554 from: [13.0, 52.0],
1555 to: [14.0, 53.0],
1556 metrics: BTreeMap::from([("value".to_string(), 3.0)]),
1557 properties: json!({}),
1558 },
1559 GeoVizFlow {
1560 id: Some("outside".to_string()),
1561 label: None,
1562 from: [40.0, 40.0],
1563 to: [41.0, 41.0],
1564 metrics: BTreeMap::from([("value".to_string(), 10.0)]),
1565 properties: json!({}),
1566 },
1567 ])
1568 .expect("flow index");
1569 let aggregation = index
1570 .get_viewport_flows(
1571 GeoVizViewportQuery {
1572 bounds: [12.0, 51.0, 15.0, 54.0],
1573 zoom: 4.0,
1574 },
1575 GeoVizFlowOptions {
1576 aggregate: GeoVizFlowAggregateMode::OriginDestination,
1577 min_weight: Some(1.0),
1578 weight_metric: Some("value".to_string()),
1579 },
1580 )
1581 .expect("flows");
1582
1583 assert_eq!(aggregation.features.len(), 1);
1584 assert_eq!(aggregation.features[0].raw_weight, 5.0);
1585 assert_eq!(aggregation.summary.visible_flow_count, 1);
1586 }
1587}