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}
53
54/// The summaries of every layer, panel by panel.
55#[derive(Debug, Clone, PartialEq, Default)]
56pub struct PlotSummary {
57    pub layers: Vec<LayerSummary>,
58}
59
60impl BuiltPlot {
61    /// What the plot draws, as numbers (see the [module docs](crate::summary)).
62    pub fn summary(&self) -> PlotSummary {
63        // a date x stays a date in the stat output too (a smooth's grid is
64        // plain numbers): format those as dates as well
65        let date_x = self.layers.iter().any(|l| {
66            l.data
67                .column("x")
68                .is_some_and(|c| c.iter().any(|v| v.is_datetime()))
69        });
70        let mut layers = Vec::new();
71        let faceted = self.panels.len() > 1 && !self.panels_data.is_empty();
72        if faceted {
73            for (p, data) in self.panels.iter().zip(&self.panels_data) {
74                for (l, d) in self.layers.iter().zip(data) {
75                    if let Some(s) = layer_summary(l.geom.name(), d, date_x) {
76                        layers.push(LayerSummary {
77                            panel: Some(p.label.clone()),
78                            ..s
79                        });
80                    }
81                }
82            }
83        } else {
84            for l in &self.layers {
85                layers.extend(layer_summary(l.geom.name(), &l.data, date_x));
86            }
87        }
88        // a layer repeating the one before it (a line's point markers) is
89        // one entry: `line+point`
90        let mut merged: Vec<LayerSummary> = Vec::new();
91        for l in layers {
92            match merged.last_mut() {
93                Some(m) if m.panel == l.panel && m.series == l.series => {
94                    m.geom = format!("{}+{}", m.geom, l.geom);
95                }
96                _ => merged.push(l),
97            }
98        }
99        PlotSummary { layers: merged }
100    }
101
102    /// [`summary`](Self::summary) as text, one line per layer and panel.
103    pub fn summary_text(&self) -> String {
104        self.summary().to_string()
105    }
106}
107
108impl std::fmt::Display for PlotSummary {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        for (i, l) in self.layers.iter().enumerate() {
111            if i > 0 {
112                writeln!(f)?;
113            }
114            match &l.panel {
115                Some(p) => write!(f, "{} [{p}]:", l.geom)?,
116                None => write!(f, "{}:", l.geom)?,
117            }
118            let shown = l.series.len().min(MAX_SERIES);
119            for (j, s) in l.series[..shown].iter().enumerate() {
120                if j > 0 {
121                    write!(f, ";")?;
122                }
123                if let Some(k) = &s.key {
124                    write!(f, " {k} —")?;
125                }
126                // a ribbon is its band: its y is only the lower edge
127                if let (true, Some((lo, hi))) = (l.geom == "ribbon", s.band) {
128                    write!(
129                        f,
130                        " {} mark{}, x {} … {}, band {} … {}",
131                        s.n,
132                        if s.n == 1 { "" } else { "s" },
133                        s.x_from,
134                        s.x_to,
135                        num(lo),
136                        num(hi)
137                    )?;
138                    continue;
139                }
140                write!(
141                    f,
142                    " {} mark{}, x {} … {}; y first {}, last {}, min {} at {}, max {} at {}",
143                    s.n,
144                    if s.n == 1 { "" } else { "s" },
145                    s.x_from,
146                    s.x_to,
147                    num(s.y_first),
148                    num(s.y_last),
149                    num(s.y_min.0),
150                    s.y_min.1,
151                    num(s.y_max.0),
152                    s.y_max.1
153                )?;
154                if let Some((lo, hi)) = s.band {
155                    write!(f, ", band {} … {}", num(lo), num(hi))?;
156                }
157            }
158            if l.series.len() > shown {
159                write!(f, "; +{} more series", l.series.len() - shown)?;
160            }
161        }
162        Ok(())
163    }
164}
165
166fn layer_summary(geom: &str, d: &DataFrame, date_x: bool) -> Option<LayerSummary> {
167    let x = d.column("x")?;
168    // a stacked layer keeps each mark's own value in `.y_raw`
169    let y = d.column(".y_raw").or_else(|| d.column("y"))?;
170    let key = SERIES_COLS.iter().find_map(|c| d.column(c));
171    let (ymin, ymax) = (d.column("ymin"), d.column("ymax"));
172    // a band is drawn only by a layer whose ymin/ymax differ from its y
173    // (a bar's ymin = 0 is its base, not a band)
174    let banded = matches!(
175        geom,
176        "ribbon" | "smooth" | "errorbar" | "linerange" | "pointrange" | "crossbar"
177    );
178    let mut order: Vec<String> = Vec::new();
179    let mut rows: Vec<Vec<usize>> = Vec::new();
180    for i in 0..d.nrows() {
181        let k = key.map(|k| label(&k[i], false)).unwrap_or_default();
182        let at = match order.iter().position(|o| *o == k) {
183            Some(at) => at,
184            None => {
185                order.push(k);
186                rows.push(Vec::new());
187                order.len() - 1
188            }
189        };
190        rows[at].push(i);
191    }
192    let series: Vec<SeriesSummary> = order
193        .into_iter()
194        .zip(rows)
195        .filter_map(|(k, ix)| {
196            let pts: Vec<(usize, f64)> = ix
197                .into_iter()
198                .filter_map(|i| y[i].as_f64().filter(|v| v.is_finite()).map(|v| (i, v)))
199                .collect();
200            let (&(i0, y0), &(i1, y1)) = (pts.first()?, pts.last()?);
201            let lo = pts
202                .iter()
203                .fold(pts[0], |a, b| if b.1 < a.1 { *b } else { a });
204            let hi = pts
205                .iter()
206                .fold(pts[0], |a, b| if b.1 > a.1 { *b } else { a });
207            let xl = |i: usize| label(&x[i], date_x);
208            let band = banded.then(|| {
209                let f = |c: Option<&[Value]>, pick: fn(f64, f64) -> f64| {
210                    c.and_then(|c| {
211                        pts.iter()
212                            .filter_map(|(i, _)| c[*i].as_f64().filter(|v| v.is_finite()))
213                            .reduce(pick)
214                    })
215                };
216                f(ymin, f64::min).zip(f(ymax, f64::max))
217            });
218            // a numeric / date x: its extent, not the row order (a scatter's
219            // rows are in no x order)
220            let xs: Vec<(usize, f64)> = pts
221                .iter()
222                .filter(|(i, _)| !matches!(x[*i], Value::Str(_)))
223                .filter_map(|(i, _)| x[*i].as_f64().map(|v| (*i, v)))
224                .collect();
225            let (x_from, x_to) = if xs.len() == pts.len() {
226                let a = xs.iter().fold(xs[0], |a, b| if b.1 < a.1 { *b } else { a });
227                let b = xs.iter().fold(xs[0], |a, b| if b.1 > a.1 { *b } else { a });
228                (xl(a.0), xl(b.0))
229            } else {
230                (xl(i0), xl(i1))
231            };
232            Some(SeriesSummary {
233                key: key.map(|_| k),
234                n: pts.len(),
235                x_from,
236                x_to,
237                y_first: y0,
238                y_last: y1,
239                y_min: (lo.1, xl(lo.0)),
240                y_max: (hi.1, xl(hi.0)),
241                band: band.flatten(),
242            })
243        })
244        .collect();
245    (!series.is_empty()).then(|| LayerSummary {
246        geom: geom.to_string(),
247        panel: None,
248        series,
249    })
250}
251
252/// A value as the summary shows it: a date as `YYYY-MM-DD` (with the time
253/// when it has one), a number trimmed, text as is.
254fn label(v: &Value, date_x: bool) -> String {
255    let date = |secs: f64| {
256        let s = format_epoch_secs(secs.round() as i64);
257        s.strip_suffix(" 00:00:00").map(str::to_string).unwrap_or(s)
258    };
259    match v {
260        Value::DateTime(_) => date(v.as_f64().unwrap_or(0.0)),
261        Value::Str(s) => s.clone(),
262        Value::Na => "NA".into(),
263        _ => match v.as_f64() {
264            Some(n) if date_x => date(n),
265            Some(n) => num(n),
266            None => v.key_str().into_owned(),
267        },
268    }
269}
270
271/// A number with at most 4 significant decimals, no trailing zeros.
272fn num(n: f64) -> String {
273    if n == n.trunc() && n.abs() < 1e15 {
274        return format!("{}", n as i64);
275    }
276    let digits = (4 - n.abs().log10().floor() as i32).clamp(0, 6) as usize;
277    let s = format!("{n:.digits$}");
278    let s = s.trim_end_matches('0').trim_end_matches('.');
279    s.to_string()
280}
281
282#[cfg(test)]
283mod tests {
284    use crate::prelude::*;
285
286    fn strs(v: &[&str]) -> Vec<Value> {
287        v.iter().map(|s| Value::Str(s.to_string())).collect()
288    }
289
290    #[test]
291    fn series_dates_and_a_smooth_band() {
292        let day = |d: i64| Value::from_timestamp(1_700_006_400 + d * 86_400);
293        let df = vec![
294            (
295                "x".to_string(),
296                vec![day(0), day(1), day(2), day(0), day(1), day(2)],
297            ),
298            (
299                "y".to_string(),
300                [3.0, 5.0, 1.0, 2.0, 2.5, 4.0].map(Value::Float).to_vec(),
301            ),
302            ("g".to_string(), strs(&["A", "A", "A", "B", "B", "B"])),
303        ];
304        let s = GGPlot::new(df)
305            .aes(Aes::new().x("x").y("y").color("g"))
306            .geom_line()
307            .geom_smooth()
308            .build()
309            .summary();
310        let line = &s.layers[0];
311        assert_eq!(line.geom, "line");
312        assert_eq!(line.series.len(), 2);
313        let a = &line.series[0];
314        assert_eq!(a.key.as_deref(), Some("A"));
315        assert_eq!(
316            (a.n, a.x_from.as_str(), a.x_to.as_str()),
317            (3, "2023-11-15", "2023-11-17")
318        );
319        assert_eq!(a.y_max, (5.0, "2023-11-16".to_string()));
320        assert_eq!(a.y_min, (1.0, "2023-11-17".to_string()));
321        let smooth = &s.layers[1];
322        assert_eq!(smooth.geom, "smooth");
323        // the fitted grid's x is a date too, and its band is reported
324        assert!(
325            smooth.series[0].x_from.starts_with("2023-11-15"),
326            "{smooth:?}"
327        );
328        assert!(smooth.series[0].band.is_some());
329    }
330
331    #[test]
332    fn stacked_bars_report_their_own_values_and_facets_their_panel() {
333        let df = vec![
334            ("x".to_string(), strs(&["W1", "W1", "W2", "W2"])),
335            (
336                "y".to_string(),
337                [3.0, 5.0, 7.0, 2.0].map(Value::Float).to_vec(),
338            ),
339            ("c".to_string(), strs(&["a", "b", "a", "b"])),
340        ];
341        let s = GGPlot::new(df.clone())
342            .aes(Aes::new().x("x").y("y").fill("c"))
343            .geom_col()
344            .position(PositionStack)
345            .build()
346            .summary();
347        let b = &s.layers[0].series[1];
348        assert_eq!(b.key.as_deref(), Some("b"));
349        assert_eq!(
350            (b.y_first, b.y_last),
351            (5.0, 2.0),
352            "own values, not the stack tops"
353        );
354        assert_eq!(b.band, None, "a bar's base is not a band");
355
356        let s = GGPlot::new(df)
357            .aes(Aes::new().x("x").y("y"))
358            .geom_col()
359            .facet_wrap("c", None)
360            .build()
361            .summary();
362        let panels: Vec<_> = s.layers.iter().map(|l| l.panel.clone().unwrap()).collect();
363        assert_eq!(panels, ["a", "b"]);
364        assert_eq!(s.layers[1].series[0].y_max, (5.0, "W1".to_string()));
365    }
366
367    #[test]
368    fn scatter_x_extent_ribbon_band_and_markers_merged() {
369        let df = vec![
370            ("x".to_string(), [5.0, 1.0, 9.0].map(Value::Float).to_vec()),
371            ("y".to_string(), [2.0, 4.0, 3.0].map(Value::Float).to_vec()),
372            ("lo".to_string(), [1.0, 3.0, 2.0].map(Value::Float).to_vec()),
373            ("hi".to_string(), [3.0, 6.0, 4.0].map(Value::Float).to_vec()),
374        ];
375        let t = GGPlot::new(df)
376            .aes(Aes::new().x("x").y("y").ymin("lo").ymax("hi"))
377            .geom_ribbon()
378            .geom_line()
379            .geom_point()
380            .build()
381            .summary_text();
382        let lines: Vec<&str> = t.lines().collect();
383        assert_eq!(lines[0], "ribbon: 3 marks, x 1 … 9, band 1 … 6", "{t}");
384        assert!(
385            lines[1].starts_with("line+point: 3 marks, x 1 … 9; y first"),
386            "{t}"
387        );
388        assert_eq!(lines.len(), 2, "{t}");
389    }
390
391    #[test]
392    fn many_series_are_counted() {
393        let n = 15;
394        let df = vec![
395            (
396                "x".to_string(),
397                (0..n).map(|i| Value::Float(i as f64)).collect(),
398            ),
399            (
400                "y".to_string(),
401                (0..n).map(|i| Value::Float(i as f64 * 1.5)).collect(),
402            ),
403            (
404                "g".to_string(),
405                (0..n).map(|i| Value::Str(format!("s{i}"))).collect(),
406            ),
407        ];
408        let t = GGPlot::new(df)
409            .aes(Aes::new().x("x").y("y").color("g"))
410            .geom_point()
411            .build()
412            .summary_text();
413        assert!(t.ends_with("; +3 more series"), "{t}");
414        assert!(t.contains("s1 — 1 mark, x 1 … 1; y first 1.5"), "{t}");
415    }
416}