Skip to main content

geo_viz/
lib.rs

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
17/// Numeric metric bag attached to points and aggregated features.
18pub type GeoVizMetricRecord = BTreeMap<String, f64>;
19
20/// Geographic bounding box in `[west, south, east, north]` order.
21pub type GeoVizBounds = [f64; 4];
22
23/// GeoJSON-compatible feature collection value returned to renderer adapters.
24pub 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")]
37/// Input point for map visualization indexes.
38pub struct GeoVizPoint {
39    /// Caller-owned optional identifier.
40    pub id: Option<String>,
41    /// Optional human-readable label.
42    pub label: Option<String>,
43    /// Longitude in degrees.
44    pub longitude: f64,
45    /// Latitude in degrees.
46    pub latitude: f64,
47    /// Finite numeric metrics.
48    #[serde(default)]
49    pub metrics: GeoVizMetricRecord,
50    /// Caller-owned JSON properties.
51    #[serde(default)]
52    pub properties: serde_json::Value,
53}
54
55#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
56#[serde(rename_all = "camelCase")]
57/// Indexed, normalized map point.
58pub struct GeoVizIndexedPoint {
59    /// Stable point id.
60    pub id: String,
61    /// Source index from the input array.
62    pub source_index: usize,
63    /// Label, defaulting to an empty string.
64    pub label: String,
65    /// Longitude in degrees.
66    pub longitude: f64,
67    /// Latitude in degrees.
68    pub latitude: f64,
69    /// Finite numeric metrics.
70    pub metrics: GeoVizMetricRecord,
71    /// Caller-owned JSON properties.
72    pub properties: serde_json::Value,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
76#[serde(rename_all = "camelCase")]
77/// Query for a geographic viewport.
78pub struct GeoVizViewportQuery {
79    /// Viewport bounds in `[west, south, east, north]` order.
80    pub bounds: GeoVizBounds,
81    /// Map zoom level.
82    pub zoom: f64,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
86#[serde(rename_all = "camelCase")]
87/// Point aggregation configuration.
88pub struct GeoVizAggregationOptions {
89    /// Cluster radius in pixels.
90    pub radius: Option<f64>,
91    /// Tile extent hint retained for renderer compatibility.
92    pub extent: Option<f64>,
93    /// Minimum clustering zoom.
94    pub min_zoom: Option<u8>,
95    /// Maximum clustering zoom.
96    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)]
116/// Aggregated viewport feature.
117pub enum GeoVizAggregationFeature {
118    /// Individual visible point.
119    Point {
120        /// `[longitude, latitude]`.
121        coordinates: [f64; 2],
122        /// Aggregated metrics.
123        metrics: GeoVizMetricRecord,
124        /// Original point.
125        point: GeoVizIndexedPoint,
126    },
127    /// Visible cluster.
128    Cluster {
129        /// Stable cluster id for this query.
130        cluster_id: String,
131        /// `[longitude, latitude]`.
132        coordinates: [f64; 2],
133        /// Zoom where this cluster expands.
134        expansion_zoom: usize,
135        /// Aggregated metrics.
136        metrics: GeoVizMetricRecord,
137        /// Number of source points represented.
138        point_count: usize,
139        /// Compact count label.
140        point_count_abbreviated: String,
141    },
142}
143
144#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
145#[serde(rename_all = "camelCase")]
146/// Summary for visible aggregated features.
147pub struct GeoVizAggregationSummary {
148    /// Queried bounds.
149    pub bounds: GeoVizBounds,
150    /// Queried zoom.
151    pub zoom: f64,
152    /// Aggregated visible metrics.
153    pub metrics: GeoVizMetricRecord,
154    /// Source point count represented by visible features.
155    pub visible_point_count: usize,
156    /// Visible cluster count.
157    pub visible_cluster_count: usize,
158    /// Visible unclustered point count.
159    pub visible_unclustered_count: usize,
160}
161
162#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
163#[serde(rename_all = "camelCase")]
164/// Aggregation result for one viewport.
165pub struct GeoVizAggregation {
166    /// Visible features.
167    pub features: Vec<GeoVizAggregationFeature>,
168    /// Visible summary.
169    pub summary: GeoVizAggregationSummary,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
173#[serde(rename_all = "camelCase")]
174/// Query for the nearest indexed point.
175pub struct GeoVizNearestPointQuery {
176    /// Longitude in degrees.
177    pub longitude: f64,
178    /// Latitude in degrees.
179    pub latitude: f64,
180    /// Optional maximum distance in coordinate degrees.
181    #[serde(default)]
182    pub max_distance: Option<f64>,
183}
184
185#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
186#[serde(rename_all = "camelCase")]
187/// Point heat feature options.
188pub struct GeoVizHeatOptions {
189    /// Optional radius hint for renderers.
190    #[serde(default)]
191    pub radius_meters: Option<f64>,
192    /// Metric key used as heat weight. Defaults to `weight`, then `1`.
193    #[serde(default)]
194    pub weight_metric: Option<String>,
195}
196
197#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
198#[serde(rename_all = "camelCase")]
199/// Weighted point feature for geographic heat layers.
200pub struct GeoVizHeatFeature {
201    /// `[longitude, latitude]`.
202    pub coordinates: [f64; 2],
203    /// Stable point id.
204    pub id: String,
205    /// Point label.
206    pub label: String,
207    /// Point metrics.
208    pub metrics: GeoVizMetricRecord,
209    /// Source point.
210    pub point: GeoVizIndexedPoint,
211    /// Number of represented points.
212    pub point_count: usize,
213    /// Unnormalized feature weight.
214    pub raw_weight: f64,
215    /// Weight normalized to `[0, 1]`.
216    pub value: f64,
217}
218
219#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
220#[serde(rename_all = "camelCase")]
221/// Summary for geographic heat features.
222pub struct GeoVizHeatSummary {
223    /// Queried bounds.
224    pub bounds: GeoVizBounds,
225    /// Queried zoom.
226    pub zoom: f64,
227    /// Aggregated visible metrics.
228    pub metrics: GeoVizMetricRecord,
229    /// Maximum raw feature weight.
230    pub max_weight: f64,
231    /// Visible weighted point count.
232    pub visible_point_count: usize,
233}
234
235#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
236#[serde(rename_all = "camelCase")]
237/// Heat features for one viewport.
238pub struct GeoVizHeatAggregation {
239    /// Visible heat features.
240    pub features: Vec<GeoVizHeatFeature>,
241    /// Visible summary.
242    pub summary: GeoVizHeatSummary,
243}
244
245#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
246#[serde(rename_all = "camelCase")]
247/// Input flow between two geographic coordinates.
248pub struct GeoVizFlow {
249    /// Caller-owned optional identifier.
250    pub id: Option<String>,
251    /// Optional human-readable label.
252    pub label: Option<String>,
253    /// Origin `[longitude, latitude]`.
254    pub from: [f64; 2],
255    /// Destination `[longitude, latitude]`.
256    pub to: [f64; 2],
257    /// Finite numeric metrics.
258    #[serde(default)]
259    pub metrics: GeoVizMetricRecord,
260    /// Caller-owned JSON properties.
261    #[serde(default)]
262    pub properties: serde_json::Value,
263}
264
265#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
266#[serde(rename_all = "camelCase")]
267/// Indexed, normalized geographic flow.
268pub struct GeoVizIndexedFlow {
269    /// Stable flow id.
270    pub id: String,
271    /// Source index from the input array.
272    pub source_index: usize,
273    /// Label, defaulting to an empty string.
274    pub label: String,
275    /// Origin `[longitude, latitude]`.
276    pub from: [f64; 2],
277    /// Destination `[longitude, latitude]`.
278    pub to: [f64; 2],
279    /// Finite numeric metrics.
280    pub metrics: GeoVizMetricRecord,
281    /// Caller-owned JSON properties.
282    pub properties: serde_json::Value,
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
286#[serde(rename_all = "kebab-case")]
287/// Flow aggregation mode.
288pub enum GeoVizFlowAggregateMode {
289    /// Return individual flows.
290    #[default]
291    None,
292    /// Combine identical origin/destination pairs.
293    OriginDestination,
294    /// Reserved for future grid aggregation; currently equivalent to origin/destination.
295    Grid,
296}
297
298#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
299#[serde(rename_all = "camelCase")]
300/// Geographic flow query options.
301pub struct GeoVizFlowOptions {
302    /// Aggregation mode.
303    #[serde(default)]
304    pub aggregate: GeoVizFlowAggregateMode,
305    /// Minimum raw weight to include.
306    #[serde(default)]
307    pub min_weight: Option<f64>,
308    /// Metric key used as flow weight. Defaults to `weight`, then `1`.
309    #[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")]
325/// Weighted geographic flow feature.
326pub struct GeoVizFlowFeature {
327    /// Source flow.
328    pub flow: GeoVizIndexedFlow,
329    /// Unnormalized feature weight.
330    pub raw_weight: f64,
331    /// Weight normalized to `[0, 1]`.
332    pub value: f64,
333}
334
335#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
336#[serde(rename_all = "camelCase")]
337/// Summary for geographic flow features.
338pub struct GeoVizFlowSummary {
339    /// Bounds for all returned flows.
340    pub bounds: Option<GeoVizBounds>,
341    /// Queried bounds.
342    pub viewport_bounds: GeoVizBounds,
343    /// Queried zoom.
344    pub zoom: f64,
345    /// Aggregated visible metrics.
346    pub metrics: GeoVizMetricRecord,
347    /// Maximum raw feature weight.
348    pub max_weight: f64,
349    /// Visible flow count.
350    pub visible_flow_count: usize,
351}
352
353#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
354#[serde(rename_all = "camelCase")]
355/// Flow features for one viewport.
356pub struct GeoVizFlowAggregation {
357    /// Visible flow features.
358    pub features: Vec<GeoVizFlowFeature>,
359    /// Visible summary.
360    pub summary: GeoVizFlowSummary,
361}
362
363#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
364#[serde(rename_all = "camelCase")]
365/// GeoJSON viewport query options.
366pub struct GeoVizGeoJsonOptions {
367    /// Whether viewport filtering should be applied.
368    #[serde(default = "default_clip_to_viewport")]
369    pub clip_to_viewport: bool,
370    /// Optional simplification tolerance in coordinate units.
371    #[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")]
386/// GeoJSON features for one viewport.
387pub struct GeoVizGeoJsonViewport {
388    /// Bounds for returned features.
389    pub bounds: Option<GeoVizBounds>,
390    /// GeoJSON FeatureCollection value.
391    pub feature_collection: GeoVizFeatureCollectionValue,
392    /// Number of returned features.
393    pub feature_count: usize,
394    /// Queried bounds.
395    pub viewport_bounds: GeoVizBounds,
396    /// Queried zoom.
397    pub zoom: f64,
398}
399
400/// Geographic point aggregation index.
401#[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    /// Builds a new point index.
412    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    /// Returns bounds for all indexed points.
460    pub fn get_bounds(&self) -> Option<GeoVizBounds> {
461        bounds_for_points(&self.points)
462    }
463
464    /// Returns one point by id.
465    pub fn get_point_by_id(&self, point_id: &str) -> Option<GeoVizIndexedPoint> {
466        self.point_lookup.get(point_id).cloned()
467    }
468
469    /// Returns visible features for a viewport.
470    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    /// Returns weighted heat features for a viewport.
500    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    /// Returns the nearest point to a coordinate.
553    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    /// Returns the zoom where a cluster expands.
582    pub fn get_cluster_expansion_zoom(&self, cluster_id: &str) -> usize {
583        self.clusters.get_cluster_expansion_zoom(cluster_id)
584    }
585
586    /// Returns source leaves for a cluster.
587    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/// Geographic flow index.
660#[derive(Debug, Clone)]
661pub struct GeoFlowIndex {
662    flows: Vec<GeoVizIndexedFlow>,
663    metric_keys: Vec<String>,
664}
665
666impl GeoFlowIndex {
667    /// Builds a new flow index.
668    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    /// Returns bounds for all indexed flows.
680    pub fn get_bounds(&self) -> Option<GeoVizBounds> {
681        bounds_for_flows(&self.flows)
682    }
683
684    /// Returns visible weighted flow features for a viewport.
685    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/// GeoJSON viewport index.
742#[derive(Debug, Clone)]
743pub struct GeoJsonIndex {
744    collection: GeoFeatureCollection,
745}
746
747impl GeoJsonIndex {
748    /// Builds a new GeoJSON index from a GeoJSON object or string value.
749    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    /// Returns bounds for all indexed features.
767    pub fn get_bounds(&self) -> Option<GeoVizBounds> {
768        bounds_for_collection(&self.collection)
769    }
770
771    /// Returns GeoJSON features for a viewport.
772    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}