use crate::loader::LoadedData;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use stt_core::projection;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpatialAnalysis {
pub zoom_coverage: Vec<ZoomCoverage>,
pub hotspots: Vec<Hotspot>,
pub recommended_min_zoom: u8,
pub recommended_max_zoom: u8,
pub distribution: SpatialDistribution,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoomCoverage {
pub zoom: u8,
pub total_tiles: u64,
pub occupied_tiles: u64,
pub coverage_percent: f64,
pub avg_features_per_tile: f64,
pub max_features_in_tile: usize,
pub median_features_per_tile: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hotspot {
pub lon: f64,
pub lat: f64,
pub radius: f64,
pub feature_count: usize,
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SpatialDistribution {
Global,
Regional,
Localized,
Sparse,
}
impl std::fmt::Display for SpatialDistribution {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SpatialDistribution::Global => write!(f, "Global (spread worldwide)"),
SpatialDistribution::Regional => write!(f, "Regional (clustered in regions)"),
SpatialDistribution::Localized => write!(f, "Localized (concentrated areas)"),
SpatialDistribution::Sparse => write!(f, "Sparse (very low density)"),
}
}
}
pub fn analyze(data: &LoadedData) -> Result<SpatialAnalysis> {
use indicatif::{ProgressBar, ProgressStyle};
let pb = ProgressBar::new(15); pb.set_style(
ProgressStyle::default_bar()
.template("{msg} [{bar:30.cyan/blue}] {pos}/{len}")
.unwrap()
.progress_chars("##-"),
);
pb.set_message("Analyzing spatial coverage");
let mut zoom_coverage = Vec::new();
for zoom in 0..=14u8 {
let coverage = analyze_zoom_level(data, zoom);
zoom_coverage.push(coverage);
pb.inc(1);
}
pb.finish_with_message("Spatial analysis complete");
let (min_zoom, max_zoom) = recommend_zoom_levels(&zoom_coverage, data, data.features.len());
let hotspots = detect_hotspots(data);
let distribution = classify_distribution(&zoom_coverage, &hotspots, &data.bounds);
Ok(SpatialAnalysis {
zoom_coverage,
hotspots,
recommended_min_zoom: min_zoom,
recommended_max_zoom: max_zoom,
distribution,
})
}
fn analyze_zoom_level(data: &LoadedData, zoom: u8) -> ZoomCoverage {
let mut tile_counts: HashMap<(u32, u32), usize> = HashMap::new();
for feature in &data.features {
if let Ok((x, y)) = projection::lonlat_to_tile(feature.lon, feature.lat, zoom) {
*tile_counts.entry((x, y)).or_insert(0) += 1;
}
}
let total_tiles = 1u64 << (2 * zoom as u64);
let occupied_tiles = tile_counts.len() as u64;
let coverage_percent = if total_tiles > 0 {
(occupied_tiles as f64 / total_tiles as f64) * 100.0
} else {
0.0
};
let counts: Vec<usize> = tile_counts.values().copied().collect();
let avg_features_per_tile = if !counts.is_empty() {
counts.iter().sum::<usize>() as f64 / counts.len() as f64
} else {
0.0
};
let max_features_in_tile = counts.iter().copied().max().unwrap_or(0);
let median_features_per_tile = if !counts.is_empty() {
let mut sorted = counts.clone();
sorted.sort();
sorted[sorted.len() / 2]
} else {
0
};
ZoomCoverage {
zoom,
total_tiles,
occupied_tiles,
coverage_percent,
avg_features_per_tile,
max_features_in_tile,
median_features_per_tile,
}
}
const MAX_SUPPORTED_ZOOM: u8 = 14;
const WORLD_CIRCUMFERENCE_M: f64 = 40_075_016.686;
fn recommend_zoom_levels(
coverage: &[ZoomCoverage],
data: &LoadedData,
total_features: usize,
) -> (u8, u8) {
let mut min_zoom = 0u8;
for cov in coverage.iter() {
if cov.avg_features_per_tile < 2.0 && cov.zoom > 0 {
min_zoom = cov.zoom.saturating_sub(1);
break;
}
min_zoom = cov.zoom;
}
let density_max_zoom = density_based_max_zoom(data, total_features);
let mut max_zoom = density_max_zoom;
for cov in coverage.iter().rev() {
if cov.zoom <= density_max_zoom
&& cov.occupied_tiles > 0
&& cov.avg_features_per_tile >= 1.0
{
max_zoom = cov.zoom;
break;
}
}
if min_zoom > max_zoom {
min_zoom = 0;
}
max_zoom = max_zoom.min(MAX_SUPPORTED_ZOOM);
(min_zoom, max_zoom)
}
fn density_based_max_zoom(data: &LoadedData, total_features: usize) -> u8 {
if total_features < 2 {
return 0;
}
let b = &data.bounds;
let lon_extent = (b.max_lon - b.min_lon).abs();
let lat_extent = (b.max_lat - b.min_lat).abs();
let mean_lat = ((b.min_lat + b.max_lat) / 2.0).clamp(-85.0511, 85.0511);
let cos_lat = mean_lat.to_radians().cos().max(1e-6);
let m_per_deg_lat = WORLD_CIRCUMFERENCE_M / 360.0;
let m_per_deg_lon = m_per_deg_lat * cos_lat;
let min_span_m = WORLD_CIRCUMFERENCE_M / (1u64 << MAX_SUPPORTED_ZOOM) as f64;
let width_m = (lon_extent * m_per_deg_lon).max(min_span_m);
let height_m = (lat_extent * m_per_deg_lat).max(min_span_m);
let area_m2 = width_m * height_m;
let spacing_m = (area_m2 / total_features as f64).sqrt();
if spacing_m <= 0.0 {
return MAX_SUPPORTED_ZOOM;
}
let world_width_m = WORLD_CIRCUMFERENCE_M * cos_lat;
let z = (world_width_m / spacing_m).log2();
let z = z.round();
if z.is_nan() || z < 0.0 {
0
} else if z > MAX_SUPPORTED_ZOOM as f64 {
MAX_SUPPORTED_ZOOM
} else {
z as u8
}
}
fn detect_hotspots(data: &LoadedData) -> Vec<Hotspot> {
let grid_size = 10.0;
let mut grid_counts: HashMap<(i32, i32), (f64, f64, usize)> = HashMap::new();
for feature in &data.features {
let grid_x = (feature.lon / grid_size).floor() as i32;
let grid_y = (feature.lat / grid_size).floor() as i32;
let entry = grid_counts.entry((grid_x, grid_y)).or_insert((0.0, 0.0, 0));
entry.0 += feature.lon;
entry.1 += feature.lat;
entry.2 += 1;
}
let total_features = data.features.len();
let avg_per_cell = if !grid_counts.is_empty() {
total_features as f64 / grid_counts.len() as f64
} else {
0.0
};
let threshold = avg_per_cell * 2.0;
let mut hotspots: Vec<Hotspot> = grid_counts
.iter()
.filter(|(_, (_, _, count))| *count as f64 > threshold && *count > 100)
.map(|((_gx, _gy), (sum_lon, sum_lat, count))| {
let center_lon = sum_lon / *count as f64;
let center_lat = sum_lat / *count as f64;
Hotspot {
lon: center_lon,
lat: center_lat,
radius: grid_size / 2.0,
feature_count: *count,
name: get_region_name(center_lon, center_lat),
}
})
.collect();
hotspots.sort_by(|a, b| b.feature_count.cmp(&a.feature_count));
hotspots.truncate(10);
hotspots
}
fn get_region_name(lon: f64, lat: f64) -> Option<String> {
let name = if lon >= -180.0 && lon <= -100.0 {
if lat >= 25.0 && lat <= 50.0 {
Some("Western North America")
} else if lat >= -60.0 && lat <= 15.0 {
Some("South America (West)")
} else {
None
}
} else if lon > -100.0 && lon <= -30.0 {
if lat >= 25.0 && lat <= 50.0 {
Some("Eastern North America")
} else if lat >= -60.0 && lat <= 15.0 {
Some("South America (East)")
} else {
None
}
} else if lon > -30.0 && lon <= 60.0 {
if lat >= 35.0 && lat <= 70.0 {
Some("Europe")
} else if lat >= -35.0 && lat <= 35.0 {
Some("Africa")
} else {
None
}
} else if lon > 60.0 && lon <= 150.0 {
if lat >= 20.0 && lat <= 55.0 {
Some("Asia (Central/East)")
} else if lat >= -10.0 && lat <= 30.0 {
Some("South/Southeast Asia")
} else {
None
}
} else if lon > 100.0 || lon <= -150.0 {
if lat >= -50.0 && lat <= 0.0 {
Some("Oceania/Australia")
} else if lat >= 30.0 && lat <= 45.0 {
Some("Pacific Ring (Japan/Korea)")
} else {
None
}
} else {
None
};
name.map(|s| s.to_string())
}
fn classify_distribution(
coverage: &[ZoomCoverage],
hotspots: &[Hotspot],
bounds: &stt_core::types::BoundingBox,
) -> SpatialDistribution {
let lon_extent = bounds.max_lon - bounds.min_lon;
let lat_extent = bounds.max_lat - bounds.min_lat;
if lon_extent < 10.0 && lat_extent < 10.0 {
return SpatialDistribution::Localized;
}
let z6_coverage = coverage.iter().find(|c| c.zoom == 6);
if let Some(cov) = z6_coverage {
if cov.coverage_percent < 0.5 {
return SpatialDistribution::Sparse;
}
if hotspots.len() >= 3 {
let hotspot_features: usize = hotspots.iter().take(5).map(|h| h.feature_count).sum();
let z6_features: usize = coverage
.iter()
.find(|c| c.zoom == 6)
.map(|c| (c.avg_features_per_tile * c.occupied_tiles as f64) as usize)
.unwrap_or(0);
if hotspot_features > z6_features / 2 {
return SpatialDistribution::Regional;
}
}
if cov.coverage_percent > 5.0 && lon_extent > 100.0 {
return SpatialDistribution::Global;
}
}
SpatialDistribution::Regional
}
#[cfg(test)]
mod tests {
use super::*;
use crate::loader::{AnalyzableFeature, GeometryType, LoadedData};
use stt_core::types::{BoundingBox, TimeRange};
fn make_data(points: &[(f64, f64)]) -> LoadedData {
let features: Vec<AnalyzableFeature> = points
.iter()
.map(|&(lon, lat)| AnalyzableFeature {
lon,
lat,
timestamp: 0,
geometry_type: GeometryType::Point,
vertex_count: 1,
estimated_size: 120,
property_count: 1,
})
.collect();
let mut min_lon = f64::MAX;
let mut max_lon = f64::MIN;
let mut min_lat = f64::MAX;
let mut max_lat = f64::MIN;
for &(lon, lat) in points {
min_lon = min_lon.min(lon);
max_lon = max_lon.max(lon);
min_lat = min_lat.min(lat);
max_lat = max_lat.max(lat);
}
LoadedData {
features,
bounds: BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
time_range: TimeRange::new(0, 0),
}
}
#[test]
fn test_region_name() {
assert_eq!(get_region_name(-122.0, 37.0), Some("Western North America".to_string()));
assert_eq!(get_region_name(2.0, 48.0), Some("Europe".to_string()));
assert_eq!(get_region_name(139.0, 35.0), Some("Asia (Central/East)".to_string()));
}
#[test]
fn test_density_max_zoom_dense_cluster_is_high() {
let mut pts = Vec::new();
let n = 50;
for i in 0..n {
for j in 0..n {
let lon = -122.4 + (i as f64) * 0.0002;
let lat = 37.77 + (j as f64) * 0.0002;
pts.push((lon, lat));
}
}
let data = make_data(&pts);
let z = density_based_max_zoom(&data, data.features.len());
assert!(z >= 12, "dense cluster should yield a high zoom, got {}", z);
}
#[test]
fn test_density_max_zoom_sparse_global_is_low() {
let mut pts = Vec::new();
let n = 200;
for i in 0..n {
let lon = -180.0 + (i as f64) * (360.0 / n as f64);
let lat = -80.0 + ((i * 7) % 160) as f64; pts.push((lon, lat));
}
let data = make_data(&pts);
let z = density_based_max_zoom(&data, data.features.len());
assert!(z <= 6, "sparse global scatter should yield a low zoom, got {}", z);
}
#[test]
fn test_density_max_zoom_degenerate_inputs() {
let data = make_data(&[(0.0, 0.0)]);
assert_eq!(density_based_max_zoom(&data, 1), 0);
let empty = make_data(&[]);
assert_eq!(density_based_max_zoom(&empty, 0), 0);
}
#[test]
fn test_recommend_zoom_dense_cluster_high_max() {
let mut pts = Vec::new();
for i in 0..40 {
for j in 0..40 {
pts.push((-122.4 + (i as f64) * 0.0003, 37.77 + (j as f64) * 0.0003));
}
}
let data = make_data(&pts);
let analysis = analyze(&data).unwrap();
assert!(
analysis.recommended_max_zoom >= 11,
"dense cluster recommended_max_zoom too low: {}",
analysis.recommended_max_zoom
);
assert!(analysis.recommended_min_zoom <= analysis.recommended_max_zoom);
}
}