Skip to main content

ggplot_rs/
summary.rs

1//! What a built plot draws, as numbers: per layer (and facet panel) and per
2//! series, how many marks, the x range, the y minimum / maximum and where,
3//! the first and last y, and a band's extent. A caller that writes about a
4//! chart (a title, an answer) can quote these instead of guessing them.
5//!
6//! ```
7//! use ggplot_rs::prelude::*;
8//! let df = vec![
9//!     ("x".to_string(), vec![Value::Str("a".into()), Value::Str("b".into())]),
10//!     ("y".to_string(), vec![Value::Float(3.0), Value::Float(5.0)]),
11//! ];
12//! let built = GGPlot::new(df).aes(Aes::new().x("x").y("y")).geom_col().build();
13//! assert_eq!(built.summary_text(), "col: 2 marks, x a … b; y first 3, last 5, min 3 at a, max 5 at b");
14//! ```
15
16use crate::build::BuiltPlot;
17use crate::data::{format_epoch_secs, DataFrame, Value};
18
19/// Columns that name a layer's series, in order of preference.
20const SERIES_COLS: &[&str] = &["colour", "color", "fill", "group"];
21/// At most this many series per layer are listed; the rest are counted.
22pub const MAX_SERIES: usize = 12;
23
24/// One layer of one panel.
25#[derive(Debug, Clone, PartialEq)]
26pub struct LayerSummary {
27    /// The geom's name (`line`, `col`, `smooth`, …).
28    pub geom: String,
29    /// The facet panel's label, when the plot is faceted.
30    pub panel: Option<String>,
31    pub series: Vec<SeriesSummary>,
32}
33
34/// One series of a layer (all its rows when the layer has no series column).
35#[derive(Debug, Clone, PartialEq)]
36pub struct SeriesSummary {
37    /// The series' value (its colour / fill / group), `None` for a single one.
38    pub key: Option<String>,
39    /// Rows with a finite y.
40    pub n: usize,
41    /// The x extent: the smallest and largest of a numeric or date x, the
42    /// first and last (in the order drawn) of a discrete one.
43    pub x_from: String,
44    pub x_to: String,
45    pub y_first: f64,
46    pub y_last: f64,
47    /// `(y, x)` of the smallest and largest y (the first, on a tie).
48    pub y_min: (f64, String),
49    pub y_max: (f64, String),
50    /// `(lowest ymin, highest ymax)` of a band (ribbon, smooth, interval).
51    pub band: Option<(f64, f64)>,
52    /// The band's width (ymax − ymin) at `x_from` and at `x_to`: whether
53    /// the uncertainty widens or narrows along x.
54    pub band_width: Option<(f64, f64)>,
55    /// Every mark's `(x label, y)`, in the order drawn.
56    pub points: Vec<(String, f64)>,
57}
58
59/// The summaries of every layer, panel by panel.
60#[derive(Debug, Clone, PartialEq, Default)]
61pub struct PlotSummary {
62    pub layers: Vec<LayerSummary>,
63    /// Two series over the same x values: their difference per x.
64    pub compare: Option<Comparison>,
65}
66
67/// `b − a` per shared x, for a plot of exactly two series (2025 against
68/// 2024, actual against plan): where the gap is largest and smallest.
69#[derive(Debug, Clone, PartialEq)]
70pub struct Comparison {
71    pub a: String,
72    pub b: String,
73    /// `(b − a, (b − a) / |a| in %, x)` of the largest and smallest difference.
74    pub largest: (f64, Option<f64>, String),
75    pub smallest: (f64, Option<f64>, String),
76    /// The same by the % change, when that ranks the x values differently.
77    pub largest_pct: Option<(f64, Option<f64>, String)>,
78    pub smallest_pct: Option<(f64, Option<f64>, String)>,
79}
80
81impl BuiltPlot {
82    /// What the plot draws, as numbers (see the [module docs](crate::summary)).
83    pub fn summary(&self) -> PlotSummary {
84        // a date x stays a date in the stat output too (a smooth's grid is
85        // plain numbers): format those as dates as well
86        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        // a layer repeating the one before it (a line's point markers) is
110        // one entry: `line+point`
111        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    /// [`summary`](Self::summary) as text, one line per layer and panel.
128    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                // a ribbon is its band: its y is only the lower edge
152                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
213/// The plot's series outside bands and ribbons; exactly two named ones over
214/// the same (at least two) x values are compared.
215fn 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    // only when % ranks them differently (else it repeats the line above)
263    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
275/// `, width 16 at a → 25.3 at b (widens)` for a band.
276fn 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    // a stacked layer keeps each mark's own value in `.y_raw`
300    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    // a band is drawn only by a layer whose ymin/ymax differ from its y
304    // (a bar's ymin = 0 is its base, not a band)
305    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            // a numeric / date x: its extent, not the row order (a scatter's
350            // rows are in no x order)
351            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
396/// A value as the summary shows it: a date as `YYYY-MM-DD` (with the time
397/// when it has one), a number trimmed, text as is.
398fn 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
415/// A number with at most 4 significant decimals, no trailing zeros.
416fn 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        // the fitted grid's x is a date too, and its band is reported
468        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        // widths: x=1 → 6−3 = 3, x=9 → 4−2 = 2
528        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}