1use crate::loader::LoadedData;
7use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use stt_core::projection;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SpatialAnalysis {
15 pub zoom_coverage: Vec<ZoomCoverage>,
17 pub hotspots: Vec<Hotspot>,
19 pub recommended_min_zoom: u8,
21 pub recommended_max_zoom: u8,
23 pub distribution: SpatialDistribution,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ZoomCoverage {
30 pub zoom: u8,
32 pub total_tiles: u64,
34 pub occupied_tiles: u64,
36 pub coverage_percent: f64,
38 pub avg_features_per_tile: f64,
40 pub max_features_in_tile: usize,
42 pub median_features_per_tile: usize,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Hotspot {
49 pub lon: f64,
51 pub lat: f64,
53 pub radius: f64,
55 pub feature_count: usize,
57 pub name: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub enum SpatialDistribution {
64 Global,
66 Regional,
68 Localized,
70 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
85pub fn analyze(data: &LoadedData) -> Result<SpatialAnalysis> {
87 use indicatif::{ProgressBar, ProgressStyle};
88
89 let pb = ProgressBar::new(MAX_SUPPORTED_ZOOM as u64 + 1); 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 for zoom in 0..=MAX_SUPPORTED_ZOOM {
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 let (min_zoom, max_zoom) = recommend_zoom_levels(&zoom_coverage, data, data.features.len());
113
114 let hotspots = detect_hotspots(data);
116
117 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
129fn 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
175const MAX_SUPPORTED_ZOOM: u8 = 18;
179
180const WORLD_CIRCUMFERENCE_M: f64 = 40_075_016.686;
185
186fn recommend_zoom_levels(
188 coverage: &[ZoomCoverage],
189 data: &LoadedData,
190 total_features: usize,
191) -> (u8, u8) {
192 let mut min_zoom = 0u8;
196 for cov in coverage.iter() {
197 if cov.avg_features_per_tile < 2.0 && cov.zoom > 0 {
200 min_zoom = cov.zoom.saturating_sub(1);
201 break;
202 }
203 min_zoom = cov.zoom;
204 }
205
206 let density_max_zoom = density_based_max_zoom(data, total_features);
210
211 let mut max_zoom = density_max_zoom;
217 for cov in coverage.iter().rev() {
218 if cov.zoom <= density_max_zoom
219 && cov.occupied_tiles > 0
220 && cov.avg_features_per_tile >= 1.0
221 {
222 max_zoom = cov.zoom;
223 break;
224 }
225 }
226
227 if min_zoom > max_zoom {
229 min_zoom = 0;
230 }
231
232 max_zoom = max_zoom.min(MAX_SUPPORTED_ZOOM);
234
235 (min_zoom, max_zoom)
236}
237
238fn density_based_max_zoom(data: &LoadedData, total_features: usize) -> u8 {
266 if total_features < 2 {
268 return 0;
269 }
270
271 let b = &data.bounds;
272 let lon_extent = (b.max_lon - b.min_lon).abs();
273 let lat_extent = (b.max_lat - b.min_lat).abs();
274
275 let mean_lat = ((b.min_lat + b.max_lat) / 2.0).clamp(-85.0511, 85.0511);
277 let cos_lat = mean_lat.to_radians().cos().max(1e-6);
278
279 let m_per_deg_lat = WORLD_CIRCUMFERENCE_M / 360.0;
281 let m_per_deg_lon = m_per_deg_lat * cos_lat;
282
283 let min_span_m = WORLD_CIRCUMFERENCE_M / (1u64 << MAX_SUPPORTED_ZOOM) as f64;
288 let width_m = (lon_extent * m_per_deg_lon).max(min_span_m);
289 let height_m = (lat_extent * m_per_deg_lat).max(min_span_m);
290 let area_m2 = width_m * height_m;
291
292 let spacing_m = (area_m2 / total_features as f64).sqrt();
294 if spacing_m <= 0.0 {
295 return MAX_SUPPORTED_ZOOM;
296 }
297
298 let world_width_m = WORLD_CIRCUMFERENCE_M * cos_lat;
301 let z = (world_width_m / spacing_m).log2();
302
303 let z = z.round();
305 if z.is_nan() || z < 0.0 {
306 0
307 } else if z > MAX_SUPPORTED_ZOOM as f64 {
308 MAX_SUPPORTED_ZOOM
309 } else {
310 z as u8
311 }
312}
313
314fn detect_hotspots(data: &LoadedData) -> Vec<Hotspot> {
316 let grid_size = 10.0;
318 let mut grid_counts: HashMap<(i32, i32), (f64, f64, usize)> = HashMap::new();
319
320 for feature in &data.features {
321 let grid_x = (feature.lon / grid_size).floor() as i32;
322 let grid_y = (feature.lat / grid_size).floor() as i32;
323
324 let entry = grid_counts.entry((grid_x, grid_y)).or_insert((0.0, 0.0, 0));
325 entry.0 += feature.lon;
326 entry.1 += feature.lat;
327 entry.2 += 1;
328 }
329
330 let total_features = data.features.len();
332 let avg_per_cell = if !grid_counts.is_empty() {
333 total_features as f64 / grid_counts.len() as f64
334 } else {
335 0.0
336 };
337
338 let threshold = avg_per_cell * 2.0; let mut hotspots: Vec<Hotspot> = grid_counts
341 .iter()
342 .filter(|(_, (_, _, count))| *count as f64 > threshold && *count > 100)
343 .map(|((_gx, _gy), (sum_lon, sum_lat, count))| {
344 let center_lon = sum_lon / *count as f64;
345 let center_lat = sum_lat / *count as f64;
346 Hotspot {
347 lon: center_lon,
348 lat: center_lat,
349 radius: grid_size / 2.0,
350 feature_count: *count,
351 name: get_region_name(center_lon, center_lat),
352 }
353 })
354 .collect();
355
356 hotspots.sort_by(|a, b| b.feature_count.cmp(&a.feature_count));
358
359 hotspots.truncate(10);
361
362 hotspots
363}
364
365fn get_region_name(lon: f64, lat: f64) -> Option<String> {
367 let name = if lon >= -180.0 && lon <= -100.0 {
369 if lat >= 25.0 && lat <= 50.0 {
370 Some("Western North America")
371 } else if lat >= -60.0 && lat <= 15.0 {
372 Some("South America (West)")
373 } else {
374 None
375 }
376 } else if lon > -100.0 && lon <= -30.0 {
377 if lat >= 25.0 && lat <= 50.0 {
378 Some("Eastern North America")
379 } else if lat >= -60.0 && lat <= 15.0 {
380 Some("South America (East)")
381 } else {
382 None
383 }
384 } else if lon > -30.0 && lon <= 60.0 {
385 if lat >= 35.0 && lat <= 70.0 {
386 Some("Europe")
387 } else if lat >= -35.0 && lat <= 35.0 {
388 Some("Africa")
389 } else {
390 None
391 }
392 } else if lon > 60.0 && lon <= 150.0 {
393 if lat >= 20.0 && lat <= 55.0 {
394 Some("Asia (Central/East)")
395 } else if lat >= -10.0 && lat <= 30.0 {
396 Some("South/Southeast Asia")
397 } else {
398 None
399 }
400 } else if lon > 100.0 || lon <= -150.0 {
401 if lat >= -50.0 && lat <= 0.0 {
402 Some("Oceania/Australia")
403 } else if lat >= 30.0 && lat <= 45.0 {
404 Some("Pacific Ring (Japan/Korea)")
405 } else {
406 None
407 }
408 } else {
409 None
410 };
411
412 name.map(|s| s.to_string())
413}
414
415fn classify_distribution(
417 coverage: &[ZoomCoverage],
418 hotspots: &[Hotspot],
419 bounds: &stt_core::types::BoundingBox,
420) -> SpatialDistribution {
421 let lon_extent = bounds.max_lon - bounds.min_lon;
423 let lat_extent = bounds.max_lat - bounds.min_lat;
424
425 if lon_extent < 10.0 && lat_extent < 10.0 {
427 return SpatialDistribution::Localized;
428 }
429
430 let z6_coverage = coverage.iter().find(|c| c.zoom == 6);
432
433 if let Some(cov) = z6_coverage {
434 if cov.coverage_percent < 0.5 {
435 return SpatialDistribution::Sparse;
436 }
437
438 if hotspots.len() >= 3 {
440 let hotspot_features: usize = hotspots.iter().take(5).map(|h| h.feature_count).sum();
441 let z6_features: usize = coverage
442 .iter()
443 .find(|c| c.zoom == 6)
444 .map(|c| (c.avg_features_per_tile * c.occupied_tiles as f64) as usize)
445 .unwrap_or(0);
446
447 if hotspot_features > z6_features / 2 {
448 return SpatialDistribution::Regional;
449 }
450 }
451
452 if cov.coverage_percent > 5.0 && lon_extent > 100.0 {
454 return SpatialDistribution::Global;
455 }
456 }
457
458 SpatialDistribution::Regional
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::loader::{AnalyzableFeature, GeometryType, LoadedData};
465 use stt_core::types::{BoundingBox, TimeRange};
466
467 fn make_data(points: &[(f64, f64)]) -> LoadedData {
470 let features: Vec<AnalyzableFeature> = points
471 .iter()
472 .map(|&(lon, lat)| AnalyzableFeature {
473 lon,
474 lat,
475 timestamp: 0,
476 geometry_type: GeometryType::Point,
477 vertex_count: 1,
478 estimated_size: 120,
479 property_count: 1,
480 })
481 .collect();
482
483 let mut min_lon = f64::MAX;
484 let mut max_lon = f64::MIN;
485 let mut min_lat = f64::MAX;
486 let mut max_lat = f64::MIN;
487 for &(lon, lat) in points {
488 min_lon = min_lon.min(lon);
489 max_lon = max_lon.max(lon);
490 min_lat = min_lat.min(lat);
491 max_lat = max_lat.max(lat);
492 }
493
494 LoadedData {
495 features,
496 bounds: BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
497 time_range: TimeRange::new(0, 0),
498 sample: Vec::new(),
499 }
500 }
501
502 #[test]
503 fn test_region_name() {
504 assert_eq!(get_region_name(-122.0, 37.0), Some("Western North America".to_string()));
505 assert_eq!(get_region_name(2.0, 48.0), Some("Europe".to_string()));
506 assert_eq!(get_region_name(139.0, 35.0), Some("Asia (Central/East)".to_string()));
508 }
509
510 #[test]
511 fn test_density_max_zoom_dense_cluster_is_high() {
512 let mut pts = Vec::new();
517 let n = 50;
518 for i in 0..n {
519 for j in 0..n {
520 let lon = -122.4 + (i as f64) * 0.0002;
521 let lat = 37.77 + (j as f64) * 0.0002;
522 pts.push((lon, lat));
523 }
524 }
525 let data = make_data(&pts);
526 let z = density_based_max_zoom(&data, data.features.len());
527 assert!(z >= 15, "dense cluster should yield a high zoom, got {}", z);
528 assert!(z <= MAX_SUPPORTED_ZOOM, "zoom must be clamped, got {}", z);
529 }
530
531 #[test]
532 fn test_density_max_zoom_sparse_global_is_low() {
533 let mut pts = Vec::new();
536 let n = 200;
537 for i in 0..n {
538 let lon = -180.0 + (i as f64) * (360.0 / n as f64);
540 let lat = -80.0 + ((i * 7) % 160) as f64; pts.push((lon, lat));
542 }
543 let data = make_data(&pts);
544 let z = density_based_max_zoom(&data, data.features.len());
545 assert!(z <= 6, "sparse global scatter should yield a low zoom, got {}", z);
546 }
547
548 #[test]
549 fn test_density_max_zoom_degenerate_inputs() {
550 let data = make_data(&[(0.0, 0.0)]);
552 assert_eq!(density_based_max_zoom(&data, 1), 0);
553 let empty = make_data(&[]);
554 assert_eq!(density_based_max_zoom(&empty, 0), 0);
555 }
556
557 #[test]
558 fn test_recommend_zoom_dense_cluster_high_max() {
559 let mut pts = Vec::new();
562 for i in 0..40 {
563 for j in 0..40 {
564 pts.push((-122.4 + (i as f64) * 0.0003, 37.77 + (j as f64) * 0.0003));
565 }
566 }
567 let data = make_data(&pts);
568 let analysis = analyze(&data).unwrap();
569 assert!(
570 analysis.recommended_max_zoom >= 11,
571 "dense cluster recommended_max_zoom too low: {}",
572 analysis.recommended_max_zoom
573 );
574 assert!(analysis.recommended_min_zoom <= analysis.recommended_max_zoom);
575 }
576}
577