1use crate::build::BuiltPlot;
17use crate::data::{format_epoch_secs, DataFrame, Value};
18
19const SERIES_COLS: &[&str] = &["colour", "color", "fill", "group"];
21pub const MAX_SERIES: usize = 12;
23
24#[derive(Debug, Clone, PartialEq)]
26pub struct LayerSummary {
27 pub geom: String,
29 pub panel: Option<String>,
31 pub series: Vec<SeriesSummary>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct SeriesSummary {
37 pub key: Option<String>,
39 pub n: usize,
41 pub x_from: String,
44 pub x_to: String,
45 pub y_first: f64,
46 pub y_last: f64,
47 pub y_min: (f64, String),
49 pub y_max: (f64, String),
50 pub band: Option<(f64, f64)>,
52 pub points: Vec<(String, f64)>,
54}
55
56#[derive(Debug, Clone, PartialEq, Default)]
58pub struct PlotSummary {
59 pub layers: Vec<LayerSummary>,
60 pub compare: Option<Comparison>,
62}
63
64#[derive(Debug, Clone, PartialEq)]
67pub struct Comparison {
68 pub a: String,
69 pub b: String,
70 pub largest: (f64, Option<f64>, String),
72 pub smallest: (f64, Option<f64>, String),
73 pub largest_pct: Option<(f64, Option<f64>, String)>,
75 pub smallest_pct: Option<(f64, Option<f64>, String)>,
76}
77
78impl BuiltPlot {
79 pub fn summary(&self) -> PlotSummary {
81 let date_x = self.layers.iter().any(|l| {
84 l.data
85 .column("x")
86 .is_some_and(|c| c.iter().any(|v| v.is_datetime()))
87 });
88 let mut layers = Vec::new();
89 let faceted = self.panels.len() > 1 && !self.panels_data.is_empty();
90 if faceted {
91 for (p, data) in self.panels.iter().zip(&self.panels_data) {
92 for (l, d) in self.layers.iter().zip(data) {
93 if let Some(s) = layer_summary(l.geom.name(), d, date_x) {
94 layers.push(LayerSummary {
95 panel: Some(p.label.clone()),
96 ..s
97 });
98 }
99 }
100 }
101 } else {
102 for l in &self.layers {
103 layers.extend(layer_summary(l.geom.name(), &l.data, date_x));
104 }
105 }
106 let mut merged: Vec<LayerSummary> = Vec::new();
109 for l in layers {
110 match merged.last_mut() {
111 Some(m) if m.panel == l.panel && m.series == l.series => {
112 m.geom = format!("{}+{}", m.geom, l.geom);
113 }
114 _ => merged.push(l),
115 }
116 }
117 let compare = compare(&merged);
118 PlotSummary {
119 layers: merged,
120 compare,
121 }
122 }
123
124 pub fn summary_text(&self) -> String {
126 self.summary().to_string()
127 }
128}
129
130impl std::fmt::Display for PlotSummary {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 for (i, l) in self.layers.iter().enumerate() {
133 if i > 0 {
134 writeln!(f)?;
135 }
136 match &l.panel {
137 Some(p) => write!(f, "{} [{p}]:", l.geom)?,
138 None => write!(f, "{}:", l.geom)?,
139 }
140 let shown = l.series.len().min(MAX_SERIES);
141 for (j, s) in l.series[..shown].iter().enumerate() {
142 if j > 0 {
143 write!(f, ";")?;
144 }
145 if let Some(k) = &s.key {
146 write!(f, " {k} —")?;
147 }
148 if let (true, Some((lo, hi))) = (l.geom == "ribbon", s.band) {
150 write!(
151 f,
152 " {} mark{}, x {} … {}, band {} … {}",
153 s.n,
154 if s.n == 1 { "" } else { "s" },
155 s.x_from,
156 s.x_to,
157 num(lo),
158 num(hi)
159 )?;
160 continue;
161 }
162 write!(
163 f,
164 " {} mark{}, x {} … {}; y first {}, last {}, min {} at {}, max {} at {}",
165 s.n,
166 if s.n == 1 { "" } else { "s" },
167 s.x_from,
168 s.x_to,
169 num(s.y_first),
170 num(s.y_last),
171 num(s.y_min.0),
172 s.y_min.1,
173 num(s.y_max.0),
174 s.y_max.1
175 )?;
176 if let Some((lo, hi)) = s.band {
177 write!(f, ", band {} … {}", num(lo), num(hi))?;
178 }
179 }
180 if l.series.len() > shown {
181 write!(f, "; +{} more series", l.series.len() - shown)?;
182 }
183 }
184 if let Some(c) = &self.compare {
185 let one = |(d, p, x): &(f64, Option<f64>, String)| {
186 let sign = if *d >= 0.0 { "+" } else { "" };
187 match p {
188 Some(p) => format!("{sign}{} ({sign}{} %) at {x}", num(*d), num(*p)),
189 None => format!("{sign}{} at {x}", num(*d)),
190 }
191 };
192 write!(
193 f,
194 "\n{} − {}: largest {}, smallest {}",
195 c.b,
196 c.a,
197 one(&c.largest),
198 one(&c.smallest)
199 )?;
200 if let (Some(l), Some(s)) = (&c.largest_pct, &c.smallest_pct) {
201 write!(f, "; by %: largest {}, smallest {}", one(l), one(s))?;
202 }
203 }
204 Ok(())
205 }
206}
207
208fn compare(layers: &[LayerSummary]) -> Option<Comparison> {
211 let series: Vec<&SeriesSummary> = layers
212 .iter()
213 .filter(|l| l.panel.is_none() && l.geom != "ribbon" && l.geom != "smooth")
214 .flat_map(|l| &l.series)
215 .collect();
216 let [a, b] = series.as_slice() else {
217 return None;
218 };
219 let (ka, kb) = (a.key.clone()?, b.key.clone()?);
220 if ka == kb || a.points.len() < 2 || a.points.len() != b.points.len() {
221 return None;
222 }
223 let diffs: Vec<(f64, Option<f64>, String)> = a
224 .points
225 .iter()
226 .map(|(x, ya)| {
227 let yb = b.points.iter().find(|(xb, _)| xb == x)?.1;
228 let d = yb - ya;
229 Some((d, (*ya != 0.0).then(|| 100.0 * d / ya.abs()), x.clone()))
230 })
231 .collect::<Option<_>>()?;
232 let pick = |better: fn(f64, f64) -> bool| {
233 diffs
234 .iter()
235 .fold(None::<&(f64, Option<f64>, String)>, |m, d| match m {
236 Some(m) if !better(d.0, m.0) => Some(m),
237 _ => Some(d),
238 })
239 .cloned()
240 };
241 let largest = pick(|x, y| x > y)?;
242 let smallest = pick(|x, y| x < y)?;
243 let by_pct = |better: fn(f64, f64) -> bool| {
244 diffs
245 .iter()
246 .filter_map(|d| d.1.map(|p| (p, d)))
247 .fold(
248 None::<(f64, &(f64, Option<f64>, String))>,
249 |m, (p, d)| match m {
250 Some(m) if !better(p, m.0) => Some(m),
251 _ => Some((p, d)),
252 },
253 )
254 .map(|(_, d)| d.clone())
255 };
256 let (lp, sp) = (by_pct(|x, y| x > y), by_pct(|x, y| x < y));
257 let differs = lp.as_ref().map(|l| &l.2) != Some(&largest.2)
259 || sp.as_ref().map(|s| &s.2) != Some(&smallest.2);
260 Some(Comparison {
261 a: ka,
262 b: kb,
263 largest,
264 smallest,
265 largest_pct: lp.filter(|_| differs),
266 smallest_pct: sp.filter(|_| differs),
267 })
268}
269
270fn layer_summary(geom: &str, d: &DataFrame, date_x: bool) -> Option<LayerSummary> {
271 let x = d.column("x")?;
272 let y = d.column(".y_raw").or_else(|| d.column("y"))?;
274 let key = SERIES_COLS.iter().find_map(|c| d.column(c));
275 let (ymin, ymax) = (d.column("ymin"), d.column("ymax"));
276 let banded = matches!(
279 geom,
280 "ribbon" | "smooth" | "errorbar" | "linerange" | "pointrange" | "crossbar"
281 );
282 let mut order: Vec<String> = Vec::new();
283 let mut rows: Vec<Vec<usize>> = Vec::new();
284 for i in 0..d.nrows() {
285 let k = key.map(|k| label(&k[i], false)).unwrap_or_default();
286 let at = match order.iter().position(|o| *o == k) {
287 Some(at) => at,
288 None => {
289 order.push(k);
290 rows.push(Vec::new());
291 order.len() - 1
292 }
293 };
294 rows[at].push(i);
295 }
296 let series: Vec<SeriesSummary> = order
297 .into_iter()
298 .zip(rows)
299 .filter_map(|(k, ix)| {
300 let pts: Vec<(usize, f64)> = ix
301 .into_iter()
302 .filter_map(|i| y[i].as_f64().filter(|v| v.is_finite()).map(|v| (i, v)))
303 .collect();
304 let (&(i0, y0), &(i1, y1)) = (pts.first()?, pts.last()?);
305 let lo = pts
306 .iter()
307 .fold(pts[0], |a, b| if b.1 < a.1 { *b } else { a });
308 let hi = pts
309 .iter()
310 .fold(pts[0], |a, b| if b.1 > a.1 { *b } else { a });
311 let xl = |i: usize| label(&x[i], date_x);
312 let band = banded.then(|| {
313 let f = |c: Option<&[Value]>, pick: fn(f64, f64) -> f64| {
314 c.and_then(|c| {
315 pts.iter()
316 .filter_map(|(i, _)| c[*i].as_f64().filter(|v| v.is_finite()))
317 .reduce(pick)
318 })
319 };
320 f(ymin, f64::min).zip(f(ymax, f64::max))
321 });
322 let xs: Vec<(usize, f64)> = pts
325 .iter()
326 .filter(|(i, _)| !matches!(x[*i], Value::Str(_)))
327 .filter_map(|(i, _)| x[*i].as_f64().map(|v| (*i, v)))
328 .collect();
329 let (x_from, x_to) = if xs.len() == pts.len() {
330 let a = xs.iter().fold(xs[0], |a, b| if b.1 < a.1 { *b } else { a });
331 let b = xs.iter().fold(xs[0], |a, b| if b.1 > a.1 { *b } else { a });
332 (xl(a.0), xl(b.0))
333 } else {
334 (xl(i0), xl(i1))
335 };
336 Some(SeriesSummary {
337 key: key.map(|_| k),
338 n: pts.len(),
339 x_from,
340 x_to,
341 y_first: y0,
342 y_last: y1,
343 y_min: (lo.1, xl(lo.0)),
344 y_max: (hi.1, xl(hi.0)),
345 band: band.flatten(),
346 points: pts.iter().map(|(i, v)| (xl(*i), *v)).collect(),
347 })
348 })
349 .collect();
350 (!series.is_empty()).then(|| LayerSummary {
351 geom: geom.to_string(),
352 panel: None,
353 series,
354 })
355}
356
357fn label(v: &Value, date_x: bool) -> String {
360 let date = |secs: f64| {
361 let s = format_epoch_secs(secs.round() as i64);
362 s.strip_suffix(" 00:00:00").map(str::to_string).unwrap_or(s)
363 };
364 match v {
365 Value::DateTime(_) => date(v.as_f64().unwrap_or(0.0)),
366 Value::Str(s) => s.clone(),
367 Value::Na => "NA".into(),
368 _ => match v.as_f64() {
369 Some(n) if date_x => date(n),
370 Some(n) => num(n),
371 None => v.key_str().into_owned(),
372 },
373 }
374}
375
376fn num(n: f64) -> String {
378 if n == n.trunc() && n.abs() < 1e15 {
379 return format!("{}", n as i64);
380 }
381 let digits = (4 - n.abs().log10().floor() as i32).clamp(0, 6) as usize;
382 let s = format!("{n:.digits$}");
383 let s = s.trim_end_matches('0').trim_end_matches('.');
384 s.to_string()
385}
386
387#[cfg(test)]
388mod tests {
389 use crate::prelude::*;
390
391 fn strs(v: &[&str]) -> Vec<Value> {
392 v.iter().map(|s| Value::Str(s.to_string())).collect()
393 }
394
395 #[test]
396 fn series_dates_and_a_smooth_band() {
397 let day = |d: i64| Value::from_timestamp(1_700_006_400 + d * 86_400);
398 let df = vec![
399 (
400 "x".to_string(),
401 vec![day(0), day(1), day(2), day(0), day(1), day(2)],
402 ),
403 (
404 "y".to_string(),
405 [3.0, 5.0, 1.0, 2.0, 2.5, 4.0].map(Value::Float).to_vec(),
406 ),
407 ("g".to_string(), strs(&["A", "A", "A", "B", "B", "B"])),
408 ];
409 let s = GGPlot::new(df)
410 .aes(Aes::new().x("x").y("y").color("g"))
411 .geom_line()
412 .geom_smooth()
413 .build()
414 .summary();
415 let line = &s.layers[0];
416 assert_eq!(line.geom, "line");
417 assert_eq!(line.series.len(), 2);
418 let a = &line.series[0];
419 assert_eq!(a.key.as_deref(), Some("A"));
420 assert_eq!(
421 (a.n, a.x_from.as_str(), a.x_to.as_str()),
422 (3, "2023-11-15", "2023-11-17")
423 );
424 assert_eq!(a.y_max, (5.0, "2023-11-16".to_string()));
425 assert_eq!(a.y_min, (1.0, "2023-11-17".to_string()));
426 let smooth = &s.layers[1];
427 assert_eq!(smooth.geom, "smooth");
428 assert!(
430 smooth.series[0].x_from.starts_with("2023-11-15"),
431 "{smooth:?}"
432 );
433 assert!(smooth.series[0].band.is_some());
434 }
435
436 #[test]
437 fn stacked_bars_report_their_own_values_and_facets_their_panel() {
438 let df = vec![
439 ("x".to_string(), strs(&["W1", "W1", "W2", "W2"])),
440 (
441 "y".to_string(),
442 [3.0, 5.0, 7.0, 2.0].map(Value::Float).to_vec(),
443 ),
444 ("c".to_string(), strs(&["a", "b", "a", "b"])),
445 ];
446 let s = GGPlot::new(df.clone())
447 .aes(Aes::new().x("x").y("y").fill("c"))
448 .geom_col()
449 .position(PositionStack)
450 .build()
451 .summary();
452 let b = &s.layers[0].series[1];
453 assert_eq!(b.key.as_deref(), Some("b"));
454 assert_eq!(
455 (b.y_first, b.y_last),
456 (5.0, 2.0),
457 "own values, not the stack tops"
458 );
459 assert_eq!(b.band, None, "a bar's base is not a band");
460
461 let s = GGPlot::new(df)
462 .aes(Aes::new().x("x").y("y"))
463 .geom_col()
464 .facet_wrap("c", None)
465 .build()
466 .summary();
467 let panels: Vec<_> = s.layers.iter().map(|l| l.panel.clone().unwrap()).collect();
468 assert_eq!(panels, ["a", "b"]);
469 assert_eq!(s.layers[1].series[0].y_max, (5.0, "W1".to_string()));
470 }
471
472 #[test]
473 fn scatter_x_extent_ribbon_band_and_markers_merged() {
474 let df = vec![
475 ("x".to_string(), [5.0, 1.0, 9.0].map(Value::Float).to_vec()),
476 ("y".to_string(), [2.0, 4.0, 3.0].map(Value::Float).to_vec()),
477 ("lo".to_string(), [1.0, 3.0, 2.0].map(Value::Float).to_vec()),
478 ("hi".to_string(), [3.0, 6.0, 4.0].map(Value::Float).to_vec()),
479 ];
480 let t = GGPlot::new(df)
481 .aes(Aes::new().x("x").y("y").ymin("lo").ymax("hi"))
482 .geom_ribbon()
483 .geom_line()
484 .geom_point()
485 .build()
486 .summary_text();
487 let lines: Vec<&str> = t.lines().collect();
488 assert_eq!(lines[0], "ribbon: 3 marks, x 1 … 9, band 1 … 6", "{t}");
489 assert!(
490 lines[1].starts_with("line+point: 3 marks, x 1 … 9; y first"),
491 "{t}"
492 );
493 assert_eq!(lines.len(), 2, "{t}");
494 }
495
496 #[test]
497 fn two_series_over_the_same_x_are_compared() {
498 let m: Vec<Value> = (1..=3).map(|i| Value::Float(i as f64)).collect();
499 let df = vec![
500 ("x".to_string(), [m.clone(), m].concat()),
501 (
502 "y".to_string(),
503 [100.0, 200.0, 50.0, 130.0, 220.0, 51.0]
504 .map(Value::Float)
505 .to_vec(),
506 ),
507 (
508 "g".to_string(),
509 strs(&["2024", "2024", "2024", "2025", "2025", "2025"]),
510 ),
511 ];
512 let t = GGPlot::new(df)
513 .aes(Aes::new().x("x").y("y").color("g"))
514 .geom_line()
515 .build()
516 .summary_text();
517 assert!(
518 t.ends_with("2025 − 2024: largest +30 (+30 %) at 1, smallest +1 (+2 %) at 3"),
519 "{t}"
520 );
521 }
522
523 #[test]
524 fn a_comparison_by_percent_when_it_ranks_differently() {
525 let df = vec![
526 (
527 "x".to_string(),
528 [1.0, 2.0, 1.0, 2.0].map(Value::Float).to_vec(),
529 ),
530 (
531 "y".to_string(),
532 [10.0, 200.0, 15.0, 260.0].map(Value::Float).to_vec(),
533 ),
534 ("g".to_string(), strs(&["a", "a", "b", "b"])),
535 ];
536 let t = GGPlot::new(df)
537 .aes(Aes::new().x("x").y("y").color("g"))
538 .geom_line()
539 .build()
540 .summary_text();
541 assert!(
542 t.ends_with("by %: largest +5 (+50 %) at 1, smallest +60 (+30 %) at 2"),
543 "{t}"
544 );
545 }
546
547 #[test]
548 fn many_series_are_counted() {
549 let n = 15;
550 let df = vec![
551 (
552 "x".to_string(),
553 (0..n).map(|i| Value::Float(i as f64)).collect(),
554 ),
555 (
556 "y".to_string(),
557 (0..n).map(|i| Value::Float(i as f64 * 1.5)).collect(),
558 ),
559 (
560 "g".to_string(),
561 (0..n).map(|i| Value::Str(format!("s{i}"))).collect(),
562 ),
563 ];
564 let t = GGPlot::new(df)
565 .aes(Aes::new().x("x").y("y").color("g"))
566 .geom_point()
567 .build()
568 .summary_text();
569 assert!(t.ends_with("; +3 more series"), "{t}");
570 assert!(t.contains("s1 — 1 mark, x 1 … 1; y first 1.5"), "{t}");
571 }
572}