1use crate::analysis::density::IssueSeverity;
6use crate::analysis::AnalysisResult;
7use crate::recommend::Recommendations;
8use anyhow::Result;
9
10pub fn generate_text(result: &AnalysisResult, recommendations: &Recommendations) -> String {
12 let mut output = String::new();
13
14 output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
16 output.push_str(&format!(" STT Optimization Report - {}\n", result.source));
17 output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
18
19 output.push_str("📊 Dataset Summary\n");
21 output.push_str(&format!(" Features: {:>12}\n", format_number(result.feature_count)));
22 output.push_str(&format!(" Time Range: {}\n", result.temporal.time_range_description()));
23 output.push_str(&format!(
24 " Spatial Bounds: [{:.2}, {:.2}] to [{:.2}, {:.2}]\n",
25 result.bounds.min_lon,
26 result.bounds.min_lat,
27 result.bounds.max_lon,
28 result.bounds.max_lat
29 ));
30 output.push_str(&format!(" Geometry Type: {} ({})\n",
31 result.geometry.dominant_type,
32 format_type_distribution(&result.geometry.type_distribution)
33 ));
34 output.push('\n');
35
36 output.push_str("🗺️ Spatial Analysis\n");
38 output.push_str(&format!(" Distribution: {}\n", result.spatial.distribution));
39
40 for z in [4, 6, 8, 10, 12, 14, 16].iter() {
42 if let Some(cov) = result.spatial.zoom_coverage.iter().find(|c| c.zoom == *z) {
43 output.push_str(&format!(
44 " Coverage at z{}: {:.2}% ({} tiles)\n",
45 z, cov.coverage_percent, cov.occupied_tiles
46 ));
47 }
48 }
49
50 output.push_str(&format!(
51 " Recommended Zoom: {}-{}\n",
52 result.spatial.recommended_min_zoom,
53 result.spatial.recommended_max_zoom
54 ));
55
56 if !result.spatial.hotspots.is_empty() {
57 output.push_str(" Hotspots:\n");
58 for (i, hotspot) in result.spatial.hotspots.iter().take(3).enumerate() {
59 let name = hotspot.name.as_deref().unwrap_or("Unknown region");
60 output.push_str(&format!(
61 " {}. {} ({} features)\n",
62 i + 1, name, format_number(hotspot.feature_count)
63 ));
64 }
65 }
66 output.push('\n');
67
68 output.push_str("⏰ Temporal Analysis\n");
70 output.push_str(&format!(" Duration: {}\n", result.temporal.duration_human));
71 output.push_str(&format!(" Distribution: {}\n", result.temporal.distribution));
72 output.push_str(&format!(
73 " Events/day avg: {:.1}\n",
74 result.temporal.events_per_day.avg
75 ));
76 output.push_str(&format!(
77 " Unique times: {}\n",
78 format_number(result.temporal.unique_timestamps)
79 ));
80 output.push_str(&format!(
81 " Suggested bucket: {}\n",
82 result.temporal.recommended_bucket_human
83 ));
84 output.push('\n');
85
86 output.push_str("📐 Geometry Analysis\n");
88 output.push_str(&format!(" Complexity: {}\n", result.geometry.complexity));
89 output.push_str(&format!(
90 " Vertices (avg): {:.1}\n",
91 result.geometry.vertex_stats.avg
92 ));
93 output.push_str(&format!(
94 " Vertices (p95): {}\n",
95 result.geometry.vertex_stats.p95
96 ));
97 output.push_str(&format!(
98 " Avg size/feat: {} bytes\n",
99 result.geometry.size_stats.avg as usize
100 ));
101 output.push_str(&format!(
102 " Total size: {}\n",
103 format_bytes(result.geometry.size_stats.total)
104 ));
105 output.push('\n');
106
107 output.push_str("💾 Size Estimation\n");
109 output.push_str(&format!(
110 " Est. tiles: {} (at recommended settings)\n",
111 format_number(result.density.estimated_tile_count)
112 ));
113 output.push_str(&format!(
114 " Est. archive: {} compressed\n",
115 format_bytes(result.density.estimated_archive_size)
116 ));
117 output.push('\n');
118
119 if let Some(m) = &result.measured {
122 output.push_str("🔬 Measured Encoding (sampled)\n");
123 output.push_str(&format!(
124 " Sample: {} features ({})\n",
125 format_number(m.features),
126 m.geometry_kind
127 ));
128 output.push_str(&format!(
129 " Bytes/feature: {:.1} compressed\n",
130 m.bytes_per_feature
131 ));
132 output.push_str(&format!(" Zstd ratio: {:.2}x\n", m.zstd_ratio));
133 if !m.per_column.is_empty() {
134 output.push_str(" Top columns:\n");
135 for c in m.per_column.iter().take(5) {
136 output.push_str(&format!(
137 " {:<18} {:>5.1}% ({})\n",
138 c.name,
139 c.share * 100.0,
140 format_bytes(c.compressed_bytes)
141 ));
142 }
143 }
144 output.push('\n');
145 }
146
147 if !result.density.issues.is_empty() {
149 output.push_str("⚠️ Issues\n");
150 for issue in &result.density.issues {
151 let icon = match issue.severity {
152 IssueSeverity::Error => "❌",
153 IssueSeverity::Warning => "⚠️",
154 IssueSeverity::Info => "ℹ️",
155 };
156 output.push_str(&format!(" {} {}\n", icon, issue.description));
157 output.push_str(&format!(" → {}\n", issue.suggestion));
158 }
159 output.push('\n');
160 }
161
162 output.push_str("💡 Recommendations\n");
164 output.push_str(&format!(
165 " --min-zoom {}\n",
166 recommendations.min_zoom
167 ));
168 output.push_str(&format!(
169 " --max-zoom {}\n",
170 recommendations.max_zoom
171 ));
172 output.push('\n');
173
174 output.push_str(&format!(
176 " Confidence: {}%\n",
177 recommendations.confidence
178 ));
179 output.push('\n');
180
181 if !recommendations.advice.is_empty() {
184 output.push_str("🧭 Advisor\n");
185 for advice in &recommendations.advice {
186 let mut line = format!(" {}", advice.flag);
187 if let Some(value) = &advice.value {
188 line.push(' ');
189 line.push_str(value);
190 }
191 if let Some(projected) = &advice.projected {
192 line.push_str(&format!(" [{}]", projected));
193 }
194 line.push_str(&format!(" ({} confidence)", advice.confidence));
195 if advice.lossy {
196 line.push_str(" [LOSSY - opt-in]");
197 }
198 output.push_str(&line);
199 output.push('\n');
200 output.push_str(&format!(" → {}\n", advice.why));
201 }
202 output.push('\n');
203 }
204
205 output.push_str("📋 Suggested Command:\n");
207 output.push_str(&format!(
210 " stt-build --input {} --output {} \\\n",
211 result.source,
212 result.source.trim_end_matches(".parquet").trim_end_matches(".geoparquet")
213 ));
214 output.push_str(&format!(
215 " --time-field timestamp --min-zoom {} --max-zoom {}\n",
216 recommendations.min_zoom, recommendations.max_zoom
217 ));
218 output.push('\n');
219
220 output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
222
223 output
224}
225
226pub fn generate_json(result: &AnalysisResult, recommendations: &Recommendations) -> Result<String> {
228 let report = serde_json::json!({
229 "source": result.source,
230 "feature_count": result.feature_count,
231 "bounds": {
232 "min_lon": result.bounds.min_lon,
233 "min_lat": result.bounds.min_lat,
234 "max_lon": result.bounds.max_lon,
235 "max_lat": result.bounds.max_lat,
236 },
237 "spatial": {
238 "distribution": format!("{}", result.spatial.distribution),
239 "recommended_min_zoom": result.spatial.recommended_min_zoom,
240 "recommended_max_zoom": result.spatial.recommended_max_zoom,
241 "hotspots": result.spatial.hotspots.iter().map(|h| {
242 serde_json::json!({
243 "lon": h.lon,
244 "lat": h.lat,
245 "feature_count": h.feature_count,
246 "name": h.name,
247 })
248 }).collect::<Vec<_>>(),
249 "zoom_coverage": result.spatial.zoom_coverage.iter().map(|c| {
250 serde_json::json!({
251 "zoom": c.zoom,
252 "coverage_percent": c.coverage_percent,
253 "occupied_tiles": c.occupied_tiles,
254 "avg_features_per_tile": c.avg_features_per_tile,
255 })
256 }).collect::<Vec<_>>(),
257 },
258 "temporal": {
259 "time_start": result.temporal.time_start,
260 "time_end": result.temporal.time_end,
261 "duration_ms": result.temporal.duration_ms,
262 "duration_human": result.temporal.duration_human,
263 "distribution": format!("{}", result.temporal.distribution),
264 "unique_timestamps": result.temporal.unique_timestamps,
265 "recommended_bucket_ms": result.temporal.recommended_bucket_ms,
266 "recommended_bucket_human": result.temporal.recommended_bucket_human,
267 "events_per_day": {
268 "avg": result.temporal.events_per_day.avg,
269 "min": result.temporal.events_per_day.min,
270 "max": result.temporal.events_per_day.max,
271 },
272 },
273 "geometry": {
274 "dominant_type": result.geometry.dominant_type,
275 "type_distribution": result.geometry.type_distribution,
276 "complexity": format!("{}", result.geometry.complexity),
277 "vertex_stats": {
278 "min": result.geometry.vertex_stats.min,
279 "max": result.geometry.vertex_stats.max,
280 "avg": result.geometry.vertex_stats.avg,
281 "median": result.geometry.vertex_stats.median,
282 "p95": result.geometry.vertex_stats.p95,
283 },
284 "size_stats": {
285 "min": result.geometry.size_stats.min,
286 "max": result.geometry.size_stats.max,
287 "avg": result.geometry.size_stats.avg,
288 "total": result.geometry.size_stats.total,
289 },
290 },
291 "density": {
292 "per_zoom": result.density.per_zoom.iter().map(|z| {
293 serde_json::json!({
294 "zoom": z.zoom,
295 "tile_count": z.tile_count,
296 "avg_features_per_tile": z.avg_features_per_tile,
297 "median_features_per_tile": z.median_features_per_tile,
298 "max_features_per_tile": z.max_features_per_tile,
299 "oversized_tiles": z.oversized_tiles,
300 "undersized_tiles": z.undersized_tiles,
301 "estimated_size_uncompressed": z.estimated_size_uncompressed,
302 "estimated_size_compressed": z.estimated_size_compressed,
303 })
304 }).collect::<Vec<_>>(),
305 "estimated_tile_count": result.density.estimated_tile_count,
306 "estimated_archive_size": result.density.estimated_archive_size,
307 "issues": result.density.issues.iter().map(|i| {
308 serde_json::json!({
309 "severity": format!("{}", i.severity),
310 "description": i.description,
311 "suggestion": i.suggestion,
312 })
313 }).collect::<Vec<_>>(),
314 },
315 "measured": result.measured,
317 "recommendations": {
318 "min_zoom": recommendations.min_zoom,
319 "max_zoom": recommendations.max_zoom,
320 "temporal_bucket_ms": recommendations.temporal_bucket_ms,
321 "confidence": recommendations.confidence,
322 "explanations": recommendations.explanations,
323 },
324 "advice": recommendations.advice,
327 });
328
329 Ok(serde_json::to_string_pretty(&report)?)
330}
331
332fn format_number(n: usize) -> String {
334 let s = n.to_string();
335 let mut result = String::new();
336 for (i, c) in s.chars().rev().enumerate() {
337 if i > 0 && i % 3 == 0 {
338 result.insert(0, ',');
339 }
340 result.insert(0, c);
341 }
342 result
343}
344
345fn format_bytes(bytes: usize) -> String {
347 const KB: usize = 1024;
348 const MB: usize = 1024 * KB;
349 const GB: usize = 1024 * MB;
350
351 if bytes >= GB {
352 format!("{:.2} GB", bytes as f64 / GB as f64)
353 } else if bytes >= MB {
354 format!("{:.2} MB", bytes as f64 / MB as f64)
355 } else if bytes >= KB {
356 format!("{:.2} KB", bytes as f64 / KB as f64)
357 } else {
358 format!("{} bytes", bytes)
359 }
360}
361
362fn format_type_distribution(dist: &std::collections::HashMap<String, usize>) -> String {
364 let total: usize = dist.values().sum();
365 if total == 0 {
366 return "N/A".to_string();
367 }
368
369 let mut parts: Vec<String> = dist
370 .iter()
371 .map(|(t, c)| {
372 let pct = *c as f64 / total as f64 * 100.0;
373 if pct > 99.0 {
374 format!("100% {}", t)
375 } else if pct > 1.0 {
376 format!("{:.0}% {}", pct, t)
377 } else {
378 String::new()
379 }
380 })
381 .filter(|s| !s.is_empty())
382 .collect();
383
384 parts.sort();
385 parts.reverse();
386 parts.join(", ")
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_format_number() {
395 assert_eq!(format_number(1000), "1,000");
396 assert_eq!(format_number(1000000), "1,000,000");
397 assert_eq!(format_number(123), "123");
398 }
399
400 #[test]
401 fn test_format_bytes() {
402 assert_eq!(format_bytes(500), "500 bytes");
403 assert_eq!(format_bytes(1024), "1.00 KB");
404 assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
405 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
406 }
407}
408
409