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.spatial.zoom_coverage.first().map(|_| -180.0).unwrap_or(0.0),
26 -90.0,
27 180.0,
28 90.0
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].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 !result.density.issues.is_empty() {
121 output.push_str("⚠️ Issues\n");
122 for issue in &result.density.issues {
123 let icon = match issue.severity {
124 IssueSeverity::Error => "❌",
125 IssueSeverity::Warning => "⚠️",
126 IssueSeverity::Info => "ℹ️",
127 };
128 output.push_str(&format!(" {} {}\n", icon, issue.description));
129 output.push_str(&format!(" → {}\n", issue.suggestion));
130 }
131 output.push('\n');
132 }
133
134 output.push_str("💡 Recommendations\n");
136 output.push_str(&format!(
137 " --min-zoom {}\n",
138 recommendations.min_zoom
139 ));
140 output.push_str(&format!(
141 " --max-zoom {}\n",
142 recommendations.max_zoom
143 ));
144 output.push('\n');
145
146 output.push_str(&format!(
148 " Confidence: {}%\n",
149 recommendations.confidence
150 ));
151 output.push('\n');
152
153 output.push_str("📋 Suggested Command:\n");
155 output.push_str(&format!(
158 " stt-build --input {} --output {} \\\n",
159 result.source,
160 result.source.trim_end_matches(".parquet").trim_end_matches(".geoparquet")
161 ));
162 output.push_str(&format!(
163 " --time-field timestamp --min-zoom {} --max-zoom {}\n",
164 recommendations.min_zoom, recommendations.max_zoom
165 ));
166 output.push('\n');
167
168 output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
170
171 output
172}
173
174pub fn generate_json(result: &AnalysisResult, recommendations: &Recommendations) -> Result<String> {
176 let report = serde_json::json!({
177 "source": result.source,
178 "feature_count": result.feature_count,
179 "spatial": {
180 "distribution": format!("{}", result.spatial.distribution),
181 "recommended_min_zoom": result.spatial.recommended_min_zoom,
182 "recommended_max_zoom": result.spatial.recommended_max_zoom,
183 "hotspots": result.spatial.hotspots.iter().map(|h| {
184 serde_json::json!({
185 "lon": h.lon,
186 "lat": h.lat,
187 "feature_count": h.feature_count,
188 "name": h.name,
189 })
190 }).collect::<Vec<_>>(),
191 "zoom_coverage": result.spatial.zoom_coverage.iter().map(|c| {
192 serde_json::json!({
193 "zoom": c.zoom,
194 "coverage_percent": c.coverage_percent,
195 "occupied_tiles": c.occupied_tiles,
196 "avg_features_per_tile": c.avg_features_per_tile,
197 })
198 }).collect::<Vec<_>>(),
199 },
200 "temporal": {
201 "time_start": result.temporal.time_start,
202 "time_end": result.temporal.time_end,
203 "duration_ms": result.temporal.duration_ms,
204 "duration_human": result.temporal.duration_human,
205 "distribution": format!("{}", result.temporal.distribution),
206 "unique_timestamps": result.temporal.unique_timestamps,
207 "recommended_bucket_ms": result.temporal.recommended_bucket_ms,
208 "recommended_bucket_human": result.temporal.recommended_bucket_human,
209 "events_per_day": {
210 "avg": result.temporal.events_per_day.avg,
211 "min": result.temporal.events_per_day.min,
212 "max": result.temporal.events_per_day.max,
213 },
214 },
215 "geometry": {
216 "dominant_type": result.geometry.dominant_type,
217 "type_distribution": result.geometry.type_distribution,
218 "complexity": format!("{}", result.geometry.complexity),
219 "vertex_stats": {
220 "min": result.geometry.vertex_stats.min,
221 "max": result.geometry.vertex_stats.max,
222 "avg": result.geometry.vertex_stats.avg,
223 "median": result.geometry.vertex_stats.median,
224 "p95": result.geometry.vertex_stats.p95,
225 },
226 "size_stats": {
227 "min": result.geometry.size_stats.min,
228 "max": result.geometry.size_stats.max,
229 "avg": result.geometry.size_stats.avg,
230 "total": result.geometry.size_stats.total,
231 },
232 },
233 "density": {
234 "recommended_chunk_size": result.density.recommended_chunk_size,
235 "estimated_tile_count": result.density.estimated_tile_count,
236 "estimated_archive_size": result.density.estimated_archive_size,
237 "issues": result.density.issues.iter().map(|i| {
238 serde_json::json!({
239 "severity": format!("{}", i.severity),
240 "description": i.description,
241 "suggestion": i.suggestion,
242 })
243 }).collect::<Vec<_>>(),
244 },
245 "recommendations": {
246 "min_zoom": recommendations.min_zoom,
247 "max_zoom": recommendations.max_zoom,
248 "temporal_bucket_ms": recommendations.temporal_bucket_ms,
249 "confidence": recommendations.confidence,
250 "explanations": recommendations.explanations,
251 },
252 });
253
254 Ok(serde_json::to_string_pretty(&report)?)
255}
256
257fn format_number(n: usize) -> String {
259 let s = n.to_string();
260 let mut result = String::new();
261 for (i, c) in s.chars().rev().enumerate() {
262 if i > 0 && i % 3 == 0 {
263 result.insert(0, ',');
264 }
265 result.insert(0, c);
266 }
267 result
268}
269
270fn format_bytes(bytes: usize) -> String {
272 const KB: usize = 1024;
273 const MB: usize = 1024 * KB;
274 const GB: usize = 1024 * MB;
275
276 if bytes >= GB {
277 format!("{:.2} GB", bytes as f64 / GB as f64)
278 } else if bytes >= MB {
279 format!("{:.2} MB", bytes as f64 / MB as f64)
280 } else if bytes >= KB {
281 format!("{:.2} KB", bytes as f64 / KB as f64)
282 } else {
283 format!("{} bytes", bytes)
284 }
285}
286
287fn format_type_distribution(dist: &std::collections::HashMap<String, usize>) -> String {
289 let total: usize = dist.values().sum();
290 if total == 0 {
291 return "N/A".to_string();
292 }
293
294 let mut parts: Vec<String> = dist
295 .iter()
296 .map(|(t, c)| {
297 let pct = *c as f64 / total as f64 * 100.0;
298 if pct > 99.0 {
299 format!("100% {}", t)
300 } else if pct > 1.0 {
301 format!("{:.0}% {}", pct, t)
302 } else {
303 String::new()
304 }
305 })
306 .filter(|s| !s.is_empty())
307 .collect();
308
309 parts.sort();
310 parts.reverse();
311 parts.join(", ")
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn test_format_number() {
320 assert_eq!(format_number(1000), "1,000");
321 assert_eq!(format_number(1000000), "1,000,000");
322 assert_eq!(format_number(123), "123");
323 }
324
325 #[test]
326 fn test_format_bytes() {
327 assert_eq!(format_bytes(500), "500 bytes");
328 assert_eq!(format_bytes(1024), "1.00 KB");
329 assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
330 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
331 }
332}
333
334