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