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(15); 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..=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 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 = 14;
178
179const WORLD_CIRCUMFERENCE_M: f64 = 40_075_016.686;
184
185fn recommend_zoom_levels(
187 coverage: &[ZoomCoverage],
188 data: &LoadedData,
189 total_features: usize,
190) -> (u8, u8) {
191 let mut min_zoom = 0u8;
195 for cov in coverage.iter() {
196 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 let density_max_zoom = density_based_max_zoom(data, total_features);
209
210 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 if min_zoom > max_zoom {
228 min_zoom = 0;
229 }
230
231 max_zoom = max_zoom.min(MAX_SUPPORTED_ZOOM);
233
234 (min_zoom, max_zoom)
235}
236
237fn density_based_max_zoom(data: &LoadedData, total_features: usize) -> u8 {
265 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 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 let m_per_deg_lat = WORLD_CIRCUMFERENCE_M / 360.0;
280 let m_per_deg_lon = m_per_deg_lat * cos_lat;
281
282 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 let spacing_m = (area_m2 / total_features as f64).sqrt();
293 if spacing_m <= 0.0 {
294 return MAX_SUPPORTED_ZOOM;
295 }
296
297 let world_width_m = WORLD_CIRCUMFERENCE_M * cos_lat;
300 let z = (world_width_m / spacing_m).log2();
301
302 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
313fn detect_hotspots(data: &LoadedData) -> Vec<Hotspot> {
315 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 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; 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 hotspots.sort_by(|a, b| b.feature_count.cmp(&a.feature_count));
357
358 hotspots.truncate(10);
360
361 hotspots
362}
363
364fn get_region_name(lon: f64, lat: f64) -> Option<String> {
366 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
414fn classify_distribution(
416 coverage: &[ZoomCoverage],
417 hotspots: &[Hotspot],
418 bounds: &stt_core::types::BoundingBox,
419) -> SpatialDistribution {
420 let lon_extent = bounds.max_lon - bounds.min_lon;
422 let lat_extent = bounds.max_lat - bounds.min_lat;
423
424 if lon_extent < 10.0 && lat_extent < 10.0 {
426 return SpatialDistribution::Localized;
427 }
428
429 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 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 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 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 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 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 let mut pts = Vec::new();
532 let n = 200;
533 for i in 0..n {
534 let lon = -180.0 + (i as f64) * (360.0 / n as f64);
536 let lat = -80.0 + ((i * 7) % 160) as f64; 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 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 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