Skip to main content

stt_optimize/analysis/
spatial.rs

1//! Spatial density and coverage analysis
2//!
3//! Analyzes the spatial distribution of features to recommend zoom levels
4//! and identify hotspots.
5
6use crate::loader::LoadedData;
7use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use stt_core::projection;
11
12/// Spatial analysis results
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SpatialAnalysis {
15    /// Coverage statistics per zoom level
16    pub zoom_coverage: Vec<ZoomCoverage>,
17    /// Identified hotspots (regions with high density)
18    pub hotspots: Vec<Hotspot>,
19    /// Recommended minimum zoom level
20    pub recommended_min_zoom: u8,
21    /// Recommended maximum zoom level
22    pub recommended_max_zoom: u8,
23    /// Spatial distribution classification
24    pub distribution: SpatialDistribution,
25}
26
27/// Coverage statistics for a zoom level
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ZoomCoverage {
30    /// Zoom level
31    pub zoom: u8,
32    /// Total possible tiles at this zoom
33    pub total_tiles: u64,
34    /// Tiles containing at least one feature
35    pub occupied_tiles: u64,
36    /// Coverage percentage (0-100)
37    pub coverage_percent: f64,
38    /// Average features per occupied tile
39    pub avg_features_per_tile: f64,
40    /// Maximum features in any single tile
41    pub max_features_in_tile: usize,
42    /// Median features per tile
43    pub median_features_per_tile: usize,
44}
45
46/// A spatial hotspot (region with high feature density)
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Hotspot {
49    /// Center longitude
50    pub lon: f64,
51    /// Center latitude
52    pub lat: f64,
53    /// Approximate radius in degrees
54    pub radius: f64,
55    /// Feature count in this hotspot
56    pub feature_count: usize,
57    /// Descriptive name (if determinable)
58    pub name: Option<String>,
59}
60
61/// Classification of spatial distribution
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub enum SpatialDistribution {
64    /// Features are spread evenly across the globe
65    Global,
66    /// Features clustered in specific regions
67    Regional,
68    /// Features concentrated in one or few small areas
69    Localized,
70    /// Very sparse data
71    Sparse,
72}
73
74impl std::fmt::Display for SpatialDistribution {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            SpatialDistribution::Global => write!(f, "Global (spread worldwide)"),
78            SpatialDistribution::Regional => write!(f, "Regional (clustered in regions)"),
79            SpatialDistribution::Localized => write!(f, "Localized (concentrated areas)"),
80            SpatialDistribution::Sparse => write!(f, "Sparse (very low density)"),
81        }
82    }
83}
84
85/// Analyze spatial characteristics of the dataset
86pub fn analyze(data: &LoadedData) -> Result<SpatialAnalysis> {
87    use indicatif::{ProgressBar, ProgressStyle};
88
89    let pb = ProgressBar::new(15); // We analyze z0-z14
90    pb.set_style(
91        ProgressStyle::default_bar()
92            .template("{msg} [{bar:30.cyan/blue}] {pos}/{len}")
93            .unwrap()
94            .progress_chars("##-"),
95    );
96    pb.set_message("Analyzing spatial coverage");
97
98    let mut zoom_coverage = Vec::new();
99
100    // Analyze each zoom level
101    for zoom in 0..=14u8 {
102        let coverage = analyze_zoom_level(data, zoom);
103        zoom_coverage.push(coverage);
104        pb.inc(1);
105    }
106
107    pb.finish_with_message("Spatial analysis complete");
108
109    // Determine recommended zoom levels. The max-zoom is derived from the
110    // typical inter-feature spacing (Tippecanoe `-zg` style), then clamped by
111    // the coarse occupancy heuristic as a sanity guard.
112    let (min_zoom, max_zoom) = recommend_zoom_levels(&zoom_coverage, data, data.features.len());
113
114    // Detect hotspots using a grid-based approach
115    let hotspots = detect_hotspots(data);
116
117    // Classify spatial distribution
118    let distribution = classify_distribution(&zoom_coverage, &hotspots, &data.bounds);
119
120    Ok(SpatialAnalysis {
121        zoom_coverage,
122        hotspots,
123        recommended_min_zoom: min_zoom,
124        recommended_max_zoom: max_zoom,
125        distribution,
126    })
127}
128
129/// Analyze coverage at a specific zoom level
130fn analyze_zoom_level(data: &LoadedData, zoom: u8) -> ZoomCoverage {
131    let mut tile_counts: HashMap<(u32, u32), usize> = HashMap::new();
132
133    for feature in &data.features {
134        if let Ok((x, y)) = projection::lonlat_to_tile(feature.lon, feature.lat, zoom) {
135            *tile_counts.entry((x, y)).or_insert(0) += 1;
136        }
137    }
138
139    let total_tiles = 1u64 << (2 * zoom as u64);
140    let occupied_tiles = tile_counts.len() as u64;
141    let coverage_percent = if total_tiles > 0 {
142        (occupied_tiles as f64 / total_tiles as f64) * 100.0
143    } else {
144        0.0
145    };
146
147    let counts: Vec<usize> = tile_counts.values().copied().collect();
148    let avg_features_per_tile = if !counts.is_empty() {
149        counts.iter().sum::<usize>() as f64 / counts.len() as f64
150    } else {
151        0.0
152    };
153
154    let max_features_in_tile = counts.iter().copied().max().unwrap_or(0);
155
156    let median_features_per_tile = if !counts.is_empty() {
157        let mut sorted = counts.clone();
158        sorted.sort();
159        sorted[sorted.len() / 2]
160    } else {
161        0
162    };
163
164    ZoomCoverage {
165        zoom,
166        total_tiles,
167        occupied_tiles,
168        coverage_percent,
169        avg_features_per_tile,
170        max_features_in_tile,
171        median_features_per_tile,
172    }
173}
174
175/// Hard cap on the recommended max zoom. The STT pipeline analyzes z0-z14, so
176/// the recommendation is bounded by the same window.
177const MAX_SUPPORTED_ZOOM: u8 = 14;
178
179/// Web Mercator world circumference in meters at the equator (EPSG:3857 extent
180/// width = 2·π·a where a = 6_378_137 m). One full map at zoom 0 spans this many
181/// meters across, so the ground size of a single tile at zoom `z` is
182/// `WORLD_CIRCUMFERENCE_M / 2^z`.
183const WORLD_CIRCUMFERENCE_M: f64 = 40_075_016.686;
184
185/// Recommend min and max zoom levels based on coverage and feature density.
186fn recommend_zoom_levels(
187    coverage: &[ZoomCoverage],
188    data: &LoadedData,
189    total_features: usize,
190) -> (u8, u8) {
191    // ---- Min zoom -------------------------------------------------------
192    // Find min zoom: the coarsest level that still aggregates meaningfully.
193    // Start from zoom 0 and go up until average features per tile drops too low.
194    let mut min_zoom = 0u8;
195    for cov in coverage.iter() {
196        // If average features per tile drops below 2, we are too zoomed in to
197        // be a useful *minimum* (overview) level; back off one.
198        if cov.avg_features_per_tile < 2.0 && cov.zoom > 0 {
199            min_zoom = cov.zoom.saturating_sub(1);
200            break;
201        }
202        min_zoom = cov.zoom;
203    }
204
205    // ---- Max zoom (density-derived, Tippecanoe `-zg` style) -------------
206    // Pick the zoom at which features sit roughly one tile apart, so detail is
207    // preserved without generating wastefully deep, near-empty tiles.
208    let density_max_zoom = density_based_max_zoom(data, total_features);
209
210    // ---- Occupancy sanity clamp ----------------------------------------
211    // The density estimate is a global average; guard it with the empirical
212    // per-zoom occupancy so we never recommend a zoom where the data has
213    // already become uselessly sparse. We walk down from the density estimate
214    // to the first zoom that still has occupied tiles averaging >= 1 feature.
215    let mut max_zoom = density_max_zoom;
216    for cov in coverage.iter().rev() {
217        if cov.zoom <= density_max_zoom
218            && cov.occupied_tiles > 0
219            && cov.avg_features_per_tile >= 1.0
220        {
221            max_zoom = cov.zoom;
222            break;
223        }
224    }
225
226    // Ensure min <= max
227    if min_zoom > max_zoom {
228        min_zoom = 0;
229    }
230
231    // Cap at the analyzed window.
232    max_zoom = max_zoom.min(MAX_SUPPORTED_ZOOM);
233
234    (min_zoom, max_zoom)
235}
236
237/// Estimate the max zoom from the typical inter-feature spacing.
238///
239/// # Derivation (Tippecanoe `-zg` analogue)
240///
241/// We want the deepest zoom `z` at which neighbouring features are about one
242/// tile apart. Two quantities drive this:
243///
244/// * **Typical inter-feature spacing** `s` (meters). As a cheap, allocation-free
245///   proxy for the median nearest-neighbour distance we use
246///   `s = sqrt(area / n)`, the side length of the square each feature would
247///   occupy if `n` features were spread uniformly over the dataset's projected
248///   area `area`. (Uniform-packing spacing; for clustered data this slightly
249///   under-estimates local spacing, which the occupancy clamp then corrects.)
250///
251/// * **Tile ground size at zoom `z`** `t(z) = WORLD_CIRCUMFERENCE_M / 2^z`
252///   meters (Web Mercator, measured at the data's mean latitude so the
253///   longitudinal shrink `cos(lat)` is accounted for).
254///
255/// Setting `t(z) == s` and solving for `z`:
256///
257/// ```text
258///   WORLD_CIRCUMFERENCE_M * cos(lat) / 2^z  ==  s
259///   2^z  ==  WORLD_CIRCUMFERENCE_M * cos(lat) / s
260///   z    ==  log2( WORLD_CIRCUMFERENCE_M * cos(lat) / s )
261/// ```
262///
263/// We round to the nearest integer zoom and clamp to `[0, MAX_SUPPORTED_ZOOM]`.
264fn density_based_max_zoom(data: &LoadedData, total_features: usize) -> u8 {
265    // Degenerate inputs: nothing to derive from.
266    if total_features < 2 {
267        return 0;
268    }
269
270    let b = &data.bounds;
271    let lon_extent = (b.max_lon - b.min_lon).abs();
272    let lat_extent = (b.max_lat - b.min_lat).abs();
273
274    // Mean latitude drives the east-west meter-per-degree shrink.
275    let mean_lat = ((b.min_lat + b.max_lat) / 2.0).clamp(-85.0511, 85.0511);
276    let cos_lat = mean_lat.to_radians().cos().max(1e-6);
277
278    // Meters per degree of latitude (constant) and longitude (shrinks with lat).
279    let m_per_deg_lat = WORLD_CIRCUMFERENCE_M / 360.0;
280    let m_per_deg_lon = m_per_deg_lat * cos_lat;
281
282    // Projected area covered by the data, in square meters. A zero-extent axis
283    // (all features share a lon or lat) would zero the area, so floor each axis
284    // at a single-tile-at-MAX_SUPPORTED_ZOOM span to keep the estimate finite
285    // and push such degenerate-but-real datasets to the deepest zoom.
286    let min_span_m = WORLD_CIRCUMFERENCE_M / (1u64 << MAX_SUPPORTED_ZOOM) as f64;
287    let width_m = (lon_extent * m_per_deg_lon).max(min_span_m);
288    let height_m = (lat_extent * m_per_deg_lat).max(min_span_m);
289    let area_m2 = width_m * height_m;
290
291    // Uniform-packing inter-feature spacing.
292    let spacing_m = (area_m2 / total_features as f64).sqrt();
293    if spacing_m <= 0.0 {
294        return MAX_SUPPORTED_ZOOM;
295    }
296
297    // z = log2( tile-meters-at-z0 / spacing ), using the latitude-adjusted
298    // world width so the tile size matches the spacing's projection.
299    let world_width_m = WORLD_CIRCUMFERENCE_M * cos_lat;
300    let z = (world_width_m / spacing_m).log2();
301
302    // Round to nearest integer zoom, clamp to the supported window.
303    let z = z.round();
304    if z.is_nan() || z < 0.0 {
305        0
306    } else if z > MAX_SUPPORTED_ZOOM as f64 {
307        MAX_SUPPORTED_ZOOM
308    } else {
309        z as u8
310    }
311}
312
313/// Detect hotspots using a grid-based density approach
314fn detect_hotspots(data: &LoadedData) -> Vec<Hotspot> {
315    // Use a coarse grid (about 10 degrees cells)
316    let grid_size = 10.0;
317    let mut grid_counts: HashMap<(i32, i32), (f64, f64, usize)> = HashMap::new();
318
319    for feature in &data.features {
320        let grid_x = (feature.lon / grid_size).floor() as i32;
321        let grid_y = (feature.lat / grid_size).floor() as i32;
322
323        let entry = grid_counts.entry((grid_x, grid_y)).or_insert((0.0, 0.0, 0));
324        entry.0 += feature.lon;
325        entry.1 += feature.lat;
326        entry.2 += 1;
327    }
328
329    // Find cells with above-average density
330    let total_features = data.features.len();
331    let avg_per_cell = if !grid_counts.is_empty() {
332        total_features as f64 / grid_counts.len() as f64
333    } else {
334        0.0
335    };
336
337    let threshold = avg_per_cell * 2.0; // Hotspot if 2x average
338
339    let mut hotspots: Vec<Hotspot> = grid_counts
340        .iter()
341        .filter(|(_, (_, _, count))| *count as f64 > threshold && *count > 100)
342        .map(|((_gx, _gy), (sum_lon, sum_lat, count))| {
343            let center_lon = sum_lon / *count as f64;
344            let center_lat = sum_lat / *count as f64;
345            Hotspot {
346                lon: center_lon,
347                lat: center_lat,
348                radius: grid_size / 2.0,
349                feature_count: *count,
350                name: get_region_name(center_lon, center_lat),
351            }
352        })
353        .collect();
354
355    // Sort by feature count (descending)
356    hotspots.sort_by(|a, b| b.feature_count.cmp(&a.feature_count));
357
358    // Keep top 10 hotspots
359    hotspots.truncate(10);
360
361    hotspots
362}
363
364/// Get a rough region name based on coordinates
365fn get_region_name(lon: f64, lat: f64) -> Option<String> {
366    // Simple heuristic based on major regions
367    let name = if lon >= -180.0 && lon <= -100.0 {
368        if lat >= 25.0 && lat <= 50.0 {
369            Some("Western North America")
370        } else if lat >= -60.0 && lat <= 15.0 {
371            Some("South America (West)")
372        } else {
373            None
374        }
375    } else if lon > -100.0 && lon <= -30.0 {
376        if lat >= 25.0 && lat <= 50.0 {
377            Some("Eastern North America")
378        } else if lat >= -60.0 && lat <= 15.0 {
379            Some("South America (East)")
380        } else {
381            None
382        }
383    } else if lon > -30.0 && lon <= 60.0 {
384        if lat >= 35.0 && lat <= 70.0 {
385            Some("Europe")
386        } else if lat >= -35.0 && lat <= 35.0 {
387            Some("Africa")
388        } else {
389            None
390        }
391    } else if lon > 60.0 && lon <= 150.0 {
392        if lat >= 20.0 && lat <= 55.0 {
393            Some("Asia (Central/East)")
394        } else if lat >= -10.0 && lat <= 30.0 {
395            Some("South/Southeast Asia")
396        } else {
397            None
398        }
399    } else if lon > 100.0 || lon <= -150.0 {
400        if lat >= -50.0 && lat <= 0.0 {
401            Some("Oceania/Australia")
402        } else if lat >= 30.0 && lat <= 45.0 {
403            Some("Pacific Ring (Japan/Korea)")
404        } else {
405            None
406        }
407    } else {
408        None
409    };
410
411    name.map(|s| s.to_string())
412}
413
414/// Classify the overall spatial distribution
415fn classify_distribution(
416    coverage: &[ZoomCoverage],
417    hotspots: &[Hotspot],
418    bounds: &stt_core::types::BoundingBox,
419) -> SpatialDistribution {
420    // Check bounds extent
421    let lon_extent = bounds.max_lon - bounds.min_lon;
422    let lat_extent = bounds.max_lat - bounds.min_lat;
423
424    // Very localized if bounds are small
425    if lon_extent < 10.0 && lat_extent < 10.0 {
426        return SpatialDistribution::Localized;
427    }
428
429    // Check coverage at mid-zoom (z6)
430    let z6_coverage = coverage.iter().find(|c| c.zoom == 6);
431
432    if let Some(cov) = z6_coverage {
433        if cov.coverage_percent < 0.5 {
434            return SpatialDistribution::Sparse;
435        }
436
437        // If many hotspots with high concentration
438        if hotspots.len() >= 3 {
439            let hotspot_features: usize = hotspots.iter().take(5).map(|h| h.feature_count).sum();
440            let z6_features: usize = coverage
441                .iter()
442                .find(|c| c.zoom == 6)
443                .map(|c| (c.avg_features_per_tile * c.occupied_tiles as f64) as usize)
444                .unwrap_or(0);
445
446            if hotspot_features > z6_features / 2 {
447                return SpatialDistribution::Regional;
448            }
449        }
450
451        // Wide coverage
452        if cov.coverage_percent > 5.0 && lon_extent > 100.0 {
453            return SpatialDistribution::Global;
454        }
455    }
456
457    SpatialDistribution::Regional
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::loader::{AnalyzableFeature, GeometryType, LoadedData};
464    use stt_core::types::{BoundingBox, TimeRange};
465
466    /// Build a synthetic `LoadedData` from (lon, lat) points. Bounds are derived
467    /// from the points so the density estimate sees a realistic extent.
468    fn make_data(points: &[(f64, f64)]) -> LoadedData {
469        let features: Vec<AnalyzableFeature> = points
470            .iter()
471            .map(|&(lon, lat)| AnalyzableFeature {
472                lon,
473                lat,
474                timestamp: 0,
475                geometry_type: GeometryType::Point,
476                vertex_count: 1,
477                estimated_size: 120,
478                property_count: 1,
479            })
480            .collect();
481
482        let mut min_lon = f64::MAX;
483        let mut max_lon = f64::MIN;
484        let mut min_lat = f64::MAX;
485        let mut max_lat = f64::MIN;
486        for &(lon, lat) in points {
487            min_lon = min_lon.min(lon);
488            max_lon = max_lon.max(lon);
489            min_lat = min_lat.min(lat);
490            max_lat = max_lat.max(lat);
491        }
492
493        LoadedData {
494            features,
495            bounds: BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
496            time_range: TimeRange::new(0, 0),
497        }
498    }
499
500    #[test]
501    fn test_region_name() {
502        assert_eq!(get_region_name(-122.0, 37.0), Some("Western North America".to_string()));
503        assert_eq!(get_region_name(2.0, 48.0), Some("Europe".to_string()));
504        // 139.0 lon, 35.0 lat is in Asia (Central/East) range
505        assert_eq!(get_region_name(139.0, 35.0), Some("Asia (Central/East)".to_string()));
506    }
507
508    #[test]
509    fn test_density_max_zoom_dense_cluster_is_high() {
510        // 2500 points packed into a ~0.01° x 0.01° box near San Francisco
511        // (~1.1 km across). Inter-feature spacing is a few tens of meters, so the
512        // formula should land near the deepest supported zoom.
513        let mut pts = Vec::new();
514        let n = 50;
515        for i in 0..n {
516            for j in 0..n {
517                let lon = -122.4 + (i as f64) * 0.0002;
518                let lat = 37.77 + (j as f64) * 0.0002;
519                pts.push((lon, lat));
520            }
521        }
522        let data = make_data(&pts);
523        let z = density_based_max_zoom(&data, data.features.len());
524        assert!(z >= 12, "dense cluster should yield a high zoom, got {}", z);
525    }
526
527    #[test]
528    fn test_density_max_zoom_sparse_global_is_low() {
529        // 200 points scattered across the whole globe. Spacing is hundreds of km,
530        // so the formula should pick a low (overview) zoom.
531        let mut pts = Vec::new();
532        let n = 200;
533        for i in 0..n {
534            // Spread roughly uniformly over lon/lat.
535            let lon = -180.0 + (i as f64) * (360.0 / n as f64);
536            let lat = -80.0 + ((i * 7) % 160) as f64; // pseudo-spread in lat
537            pts.push((lon, lat));
538        }
539        let data = make_data(&pts);
540        let z = density_based_max_zoom(&data, data.features.len());
541        assert!(z <= 6, "sparse global scatter should yield a low zoom, got {}", z);
542    }
543
544    #[test]
545    fn test_density_max_zoom_degenerate_inputs() {
546        // Fewer than 2 features cannot define spacing -> zoom 0.
547        let data = make_data(&[(0.0, 0.0)]);
548        assert_eq!(density_based_max_zoom(&data, 1), 0);
549        let empty = make_data(&[]);
550        assert_eq!(density_based_max_zoom(&empty, 0), 0);
551    }
552
553    #[test]
554    fn test_recommend_zoom_dense_cluster_high_max() {
555        // End-to-end through analyze(): a dense cluster should recommend a high
556        // max zoom that survives the occupancy clamp.
557        let mut pts = Vec::new();
558        for i in 0..40 {
559            for j in 0..40 {
560                pts.push((-122.4 + (i as f64) * 0.0003, 37.77 + (j as f64) * 0.0003));
561            }
562        }
563        let data = make_data(&pts);
564        let analysis = analyze(&data).unwrap();
565        assert!(
566            analysis.recommended_max_zoom >= 11,
567            "dense cluster recommended_max_zoom too low: {}",
568            analysis.recommended_max_zoom
569        );
570        assert!(analysis.recommended_min_zoom <= analysis.recommended_max_zoom);
571    }
572}
573