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