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    /// Every mark's `(x label, y)`, in the order drawn.
53    pub points: Vec<(String, f64)>,
54}
55
56/// The summaries of every layer, panel by panel.
57#[derive(Debug, Clone, PartialEq, Default)]
58pub struct PlotSummary {
59    pub layers: Vec<LayerSummary>,
60    /// Two series over the same x values: their difference per x.
61    pub compare: Option<Comparison>,
62}
63
64/// `b − a` per shared x, for a plot of exactly two series (2025 against
65/// 2024, actual against plan): where the gap is largest and smallest.
66#[derive(Debug, Clone, PartialEq)]
67pub struct Comparison {
68    pub a: String,
69    pub b: String,
70    /// `(b − a, (b − a) / |a| in %, x)` of the largest and smallest difference.
71    pub largest: (f64, Option<f64>, String),
72    pub smallest: (f64, Option<f64>, String),
73    /// The same by the % change, when that ranks the x values differently.
74    pub largest_pct: Option<(f64, Option<f64>, String)>,
75    pub smallest_pct: Option<(f64, Option<f64>, String)>,
76}
77
78impl BuiltPlot {
79    /// What the plot draws, as numbers (see the [module docs](crate::summary)).
80    pub fn summary(&self) -> PlotSummary {
81        // a date x stays a date in the stat output too (a smooth's grid is
82        // plain numbers): format those as dates as well
83        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        // a layer repeating the one before it (a line's point markers) is
107        // one entry: `line+point`
108        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    /// [`summary`](Self::summary) as text, one line per layer and panel.
125    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                // a ribbon is its band: its y is only the lower edge
149                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
208/// The plot's series outside bands and ribbons; exactly two named ones over
209/// the same (at least two) x values are compared.
210fn 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    // only when % ranks them differently (else it repeats the line above)
258    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    // a stacked layer keeps each mark's own value in `.y_raw`
273    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    // a band is drawn only by a layer whose ymin/ymax differ from its y
277    // (a bar's ymin = 0 is its base, not a band)
278    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            // a numeric / date x: its extent, not the row order (a scatter's
323            // rows are in no x order)
324            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
357/// A value as the summary shows it: a date as `YYYY-MM-DD` (with the time
358/// when it has one), a number trimmed, text as is.
359fn 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
376/// A number with at most 4 significant decimals, no trailing zeros.
377fn 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        // the fitted grid's x is a date too, and its band is reported
429        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}