Skip to main content

rich_ext/chart/
multiples.rs

1//! Small multiples: a grid of charts on one scale, so they compare at a
2//! glance.
3
4use rich::measure::Measurement;
5use rich::{Console, ConsoleOptions, Renderable, Segment};
6
7use super::{
8    BarChart, Charset, Chart, Line, LineChart, Scale, Sparkline, ValueFormat, has_colour,
9    lines_to_segments, theme_style, truncate, wrap_entries,
10};
11
12/// The narrowest cell [`SmallMultiples`] picks its columns by, by default.
13const DEFAULT_MIN_WIDTH: usize = 24;
14/// Cells between two columns, by default.
15const DEFAULT_GAP: usize = 2;
16/// The widest [`gap`](SmallMultiples::gap), as the bar chart's gaps.
17const MAX_GAP: usize = u16::MAX as usize;
18
19/// One chart of a [`SmallMultiples`] grid: a chart whose value scale can
20/// be shared. Each chart type converts with `.into()`.
21///
22/// | Facet | Its value scale | Fixed with |
23/// |---|---|---|
24/// | [`Sparkline`] | the heights | [`Sparkline::range`] |
25/// | [`BarChart`] | the bars' lengths, from zero | [`BarChart::range`] |
26/// | [`LineChart`] | the y axis | [`LineChart::y_range`] |
27/// | [`Chart`] | the y axis | [`Axis::bounds`](super::Axis::bounds) |
28#[derive(Clone, Debug, PartialEq)]
29#[non_exhaustive]
30#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
31pub enum Facet {
32    Sparkline(Sparkline),
33    Bars(BarChart),
34    Line(LineChart),
35    Chart(Chart),
36}
37
38impl From<Sparkline> for Facet {
39    fn from(chart: Sparkline) -> Self {
40        Facet::Sparkline(chart)
41    }
42}
43
44impl From<BarChart> for Facet {
45    fn from(chart: BarChart) -> Self {
46        Facet::Bars(chart)
47    }
48}
49
50impl From<LineChart> for Facet {
51    fn from(chart: LineChart) -> Self {
52        Facet::Line(chart)
53    }
54}
55
56impl From<Chart> for Facet {
57    fn from(chart: Chart) -> Self {
58        Facet::Chart(chart)
59    }
60}
61
62impl Facet {
63    /// The range its value scale shows on its own: the data's (from zero,
64    /// for bars), with any bound it fixes. `None` without data.
65    pub fn value_range(&self) -> Option<(f64, f64)> {
66        match self {
67            Facet::Sparkline(c) => c.value_extent(),
68            Facet::Bars(c) => c.value_extent(),
69            Facet::Line(c) => c.y_extent(),
70            Facet::Chart(c) => c.y_extent(),
71        }
72    }
73
74    /// This chart with its value scale fixed to `min..max`.
75    pub fn with_range(self, min: f64, max: f64) -> Facet {
76        match self {
77            Facet::Sparkline(c) => Facet::Sparkline(c.range(min, max)),
78            Facet::Bars(c) => Facet::Bars(c.range(min, max)),
79            Facet::Line(c) => Facet::Line(c.y_range(min, max)),
80            Facet::Chart(c) => Facet::Chart(c.with_y_bounds(min, max)),
81        }
82    }
83
84    /// Whether its scale has written labels, so a shared range is best
85    /// rounded out.
86    fn has_axis(&self) -> bool {
87        matches!(self, Facet::Line(_) | Facet::Chart(_))
88    }
89
90    fn renderable(&self) -> &dyn Renderable {
91        match self {
92            Facet::Sparkline(c) => c,
93            Facet::Bars(c) => c,
94            Facet::Line(c) => c,
95            Facet::Chart(c) => c,
96        }
97    }
98}
99
100impl Renderable for Facet {
101    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
102        self.renderable().rich_render(console, options)
103    }
104
105    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
106        self.renderable().measure(console, options)
107    }
108}
109
110impl crate::a11y::AccessibleText for Facet {
111    fn accessible_text(&self, width: usize) -> String {
112        match self {
113            Facet::Sparkline(c) => c.accessible_text(width),
114            Facet::Bars(c) => c.accessible_text(width),
115            Facet::Line(c) => c.accessible_text(width),
116            Facet::Chart(c) => c.accessible_text(width),
117        }
118    }
119}
120
121/// A grid of charts, each with a title, on one value scale and fitted to
122/// the width: small multiples, as Tufte named them.
123///
124/// ```text
125/// api                       web                       worker
126/// ▁▂▂▃▄▆▅▄▇█▇▅▄▆██▇▆▅▄▃▂▂▁   ▁▁▁▂▂▃▃▂▃▄▄▃▃▃▄▄▄▃▃▂▂▂▁▁   ▁▁▁▁▁▁▁▁▂▂▂▂▁▁▂▂▂▂▂▁▁▁▁▁
127/// scale 12 to 52 in every chart
128/// ```
129///
130/// - **Columns:** [`columns(n)`](Self::columns), or as many as fit cells
131///   of at least [`min_cell_width`](Self::min_cell_width) (24 by default)
132///   with [`gap`](Self::gap) cells between them (2 by default), and never
133///   more than there are charts. Every cell is as wide; what is left over
134///   stays at the right. Charts fill the grid row by row.
135/// - **The shared scale:** every chart's value scale (a sparkline's
136///   heights, the bars' lengths, a line chart's or a `Chart`'s y axis) is
137///   fixed to the range of them all, so a taller bar is a larger value
138///   whichever chart it is in. When a chart has axis labels the range is
139///   rounded out to round numbers. [`range`](Self::range) fixes it;
140///   [`shared_scale(false)`](Self::shared_scale) leaves each chart its own.
141/// - **One note** under the grid says the scale (`scale 0 to 60 in every
142///   chart`) and holds the one legend: the series of the line charts,
143///   whose own legends are left out. [`note(false)`](Self::note) leaves it
144///   out.
145/// - **Height:** [`height(rows)`](Self::height) makes each chart that many
146///   rows; without it, a grid given a height (a `Layout` region, an
147///   intuiTUIve node) shares it among its rows of charts, less the titles,
148///   the gaps and the note; printed on its own, each chart keeps its
149///   default height.
150/// - Titles are cut to the cell (with `…`, or `.` in ASCII) in
151///   `chart.title`; a blank line separates the rows of charts.
152///
153/// ```
154/// use rich::Console;
155/// use rich_ext::chart::{SmallMultiples, Sparkline};
156///
157/// let console = Console::builder().width(30).color_system(None).build();
158/// let grid = SmallMultiples::new([
159///     ("low", Sparkline::new([1.0, 2.0, 3.0])),
160///     ("high", Sparkline::new([7.0, 8.0, 9.0])),
161/// ])
162/// .min_cell_width(10);
163/// assert_eq!(
164///     console.render_export(&grid),
165///     concat!(
166///         "low             high          \n",
167///         "▁▂▃             ▆▇█           \n",
168///         "scale 1 to 9 in every chart   \n",
169///     )
170/// );
171/// ```
172#[derive(Clone, Debug, PartialEq)]
173#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
174pub struct SmallMultiples {
175    items: Vec<(String, Facet)>,
176    columns: Option<usize>,
177    min_width: usize,
178    gap: usize,
179    shared: bool,
180    range: Option<(f64, f64)>,
181    height: Option<usize>,
182    note: bool,
183    charset: Charset,
184}
185
186impl Default for SmallMultiples {
187    fn default() -> Self {
188        Self::new(Vec::<(String, Facet)>::new())
189    }
190}
191
192impl SmallMultiples {
193    /// A grid of `(title, chart)` pairs, in order.
194    pub fn new<T: Into<String>, F: Into<Facet>>(items: impl IntoIterator<Item = (T, F)>) -> Self {
195        SmallMultiples {
196            items: items
197                .into_iter()
198                .map(|(t, f)| (t.into(), f.into()))
199                .collect(),
200            columns: None,
201            min_width: DEFAULT_MIN_WIDTH,
202            gap: DEFAULT_GAP,
203            shared: true,
204            range: None,
205            height: None,
206            note: true,
207            charset: Charset::Auto,
208        }
209    }
210
211    /// Add a chart with its title.
212    pub fn push(mut self, title: impl Into<String>, chart: impl Into<Facet>) -> Self {
213        self.items.push((title.into(), chart.into()));
214        self
215    }
216
217    /// Lay the charts out in `n` columns (at least 1), whatever the width.
218    pub fn columns(mut self, n: usize) -> Self {
219        self.columns = Some(n.max(1));
220        self
221    }
222
223    /// The narrowest cell when the columns are picked by the width (default
224    /// 24, at least 1).
225    pub fn min_cell_width(mut self, cells: usize) -> Self {
226        self.min_width = cells.max(1);
227        self
228    }
229
230    /// Cells between two columns (default 2, at most 65535).
231    pub fn gap(mut self, cells: usize) -> Self {
232        self.gap = cells.min(MAX_GAP);
233        self
234    }
235
236    /// Put every chart on one value scale (default on).
237    pub fn shared_scale(mut self, shared: bool) -> Self {
238        self.shared = shared;
239        self
240    }
241
242    /// Fix the shared scale (default: the range of every chart's values).
243    pub fn range(mut self, min: f64, max: f64) -> Self {
244        self.range = Some((min, max));
245        self
246    }
247
248    /// Each chart's height in rows (at least 1), its title not counted.
249    pub fn height(mut self, rows: usize) -> Self {
250        self.height = Some(rows.max(1));
251        self
252    }
253
254    /// Write the note under the grid: the shared scale and the legend
255    /// (default on).
256    pub fn note(mut self, show: bool) -> Self {
257        self.note = show;
258        self
259    }
260
261    /// Glyphs for the titles and the note: [`Charset::Ascii`] asks for
262    /// ASCII (an ASCII-only console always gets it). Each chart keeps its
263    /// own charset.
264    pub fn charset(mut self, charset: Charset) -> Self {
265        self.charset = charset;
266        self
267    }
268
269    /// The charts, with their titles.
270    pub fn items(&self) -> &[(String, Facet)] {
271        &self.items
272    }
273
274    /// How many columns the grid has at `width` cells.
275    pub fn columns_at(&self, width: usize) -> usize {
276        let n = self.items.len().max(1);
277        let gap = self.gap;
278        let wanted = self.columns.unwrap_or_else(|| {
279            (width.saturating_add(gap) / self.min_width.saturating_add(gap)).max(1)
280        });
281        // Never so many that a cell has no room.
282        let fits = (width.saturating_add(gap) / gap.saturating_add(1)).max(1);
283        wanted.min(n).min(fits)
284    }
285
286    /// The shared scale: the fixed range, else every chart's range (rounded
287    /// out when a chart has axis labels). `None` when not shared or there
288    /// is no data.
289    pub fn scale(&self) -> Option<(f64, f64)> {
290        if !self.shared {
291            return None;
292        }
293        if let Some((a, b)) = self.range {
294            let s = Scale::new(a, b);
295            return Some((s.min(), s.max()));
296        }
297        let ends = self
298            .items
299            .iter()
300            .filter_map(|(_, f)| f.value_range())
301            .flat_map(|(lo, hi)| [lo, hi]);
302        let (lo, hi) = super::describe::extent(ends)?;
303        let scale = Scale::new(lo, hi);
304        let scale = if self.items.iter().any(|(_, f)| f.has_axis()) {
305            scale.nice(5)
306        } else {
307            scale
308        };
309        Some((scale.min(), scale.max()))
310    }
311
312    /// The charts as drawn: on the shared scale, line charts without their
313    /// own legends.
314    fn facets(&self) -> Vec<Facet> {
315        let scale = self.scale();
316        self.items
317            .iter()
318            .map(|(_, f)| {
319                let f = match scale {
320                    Some((lo, hi)) => f.clone().with_range(lo, hi),
321                    None => f.clone(),
322                };
323                match f {
324                    Facet::Line(c) if self.note => Facet::Line(c.legend(false)),
325                    f => f,
326                }
327            })
328            .collect()
329    }
330
331    /// The legend's entries: each line chart series' marker and name, by
332    /// position, from the first chart that names it.
333    fn legend(
334        &self,
335        console: &Console,
336        options: &ConsoleOptions,
337        ascii: bool,
338    ) -> Vec<Vec<(String, Option<rich::Style>)>> {
339        let label_style = has_colour(console).then(|| theme_style(console, "chart.label"));
340        let mut names: Vec<String> = Vec::new();
341        let mut entries = Vec::new();
342        for (_, facet) in &self.items {
343            let Facet::Line(chart) = facet else {
344                continue;
345            };
346            if !chart.has_legend() {
347                continue;
348            }
349            for (index, series) in chart.all_series().iter().enumerate() {
350                if index < names.len() || series.name().is_empty() {
351                    continue;
352                }
353                while names.len() < index {
354                    names.push(String::new());
355                }
356                names.push(series.name().to_string());
357                let (marker, style) = chart.legend_entry(console, options, index);
358                entries.push(vec![
359                    (marker.to_string(), style),
360                    (" ".to_string(), None),
361                    (truncate(series.name(), 40, ascii), label_style.clone()),
362                ]);
363            }
364        }
365        entries
366    }
367
368    /// The note's lines at `width`.
369    fn note_lines(&self, console: &Console, options: &ConsoleOptions, ascii: bool) -> Vec<Line> {
370        if !self.note {
371            return Vec::new();
372        }
373        let mut entries = Vec::new();
374        if let Some((lo, hi)) = self.scale() {
375            let format = ValueFormat::Compact;
376            let style = has_colour(console).then(|| theme_style(console, "chart.axis"));
377            entries.push(vec![(
378                format!(
379                    "scale {} to {} in every chart",
380                    format.format(lo),
381                    format.format(hi)
382                ),
383                style,
384            )]);
385        }
386        entries.extend(self.legend(console, options, ascii));
387        wrap_entries(entries, options.max_width)
388    }
389
390    /// Each chart's height: as set, or a share of `given` rows.
391    fn chart_height(&self, given: Option<usize>, rows: usize, note: usize) -> Option<usize> {
392        if self.height.is_some() {
393            return self.height;
394        }
395        let given = given?;
396        // A title a row, a blank line between rows, then the note.
397        let chrome = rows + rows.saturating_sub(1) + note;
398        Some((given.saturating_sub(chrome) / rows.max(1)).max(1))
399    }
400}
401
402/// `lines` as one [`Line`] each, control segments left out.
403fn to_lines(lines: Vec<Vec<Segment>>) -> Vec<Line> {
404    lines
405        .into_iter()
406        .map(|segments| {
407            let mut line = Line::new();
408            for s in segments.into_iter().filter(|s| !s.control) {
409                line.push(&s.text, s.style);
410            }
411            line
412        })
413        .collect()
414}
415
416impl Renderable for SmallMultiples {
417    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
418        let width = options.max_width;
419        let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
420        if self.items.is_empty() {
421            let mut line = Line::new();
422            line.push(&truncate("no data", width, ascii), None);
423            return lines_to_segments(vec![line], width);
424        }
425        let columns = self.columns_at(width);
426        let gaps = self.gap.saturating_mul(columns - 1);
427        let cell = (width.saturating_sub(gaps) / columns).max(1);
428        let rows = self.items.len().div_ceil(columns);
429        let note = self.note_lines(console, options, ascii);
430        let height = self.chart_height(options.height, rows, note.len());
431        let title_style = has_colour(console).then(|| theme_style(console, "chart.title"));
432        let facets = self.facets();
433
434        let mut out: Vec<Line> = Vec::new();
435        for (row, chunk) in facets.chunks(columns).enumerate() {
436            if row > 0 {
437                out.push(Line::new());
438            }
439            let first = row * columns;
440            let mut blocks: Vec<Vec<Line>> = Vec::new();
441            for (k, facet) in chunk.iter().enumerate() {
442                let mut opts = options.update_width(cell);
443                opts.height = height;
444                // A line chart's height is its plot's: less the axis line
445                // and the x labels.
446                let facet = match (facet, height) {
447                    (Facet::Line(c), Some(h)) => Facet::Line(c.clone().height(h.saturating_sub(2))),
448                    (f, _) => f.clone(),
449                };
450                let mut block = vec![{
451                    let mut title = Line::new();
452                    let text = truncate(&self.items[first + k].0, cell, ascii);
453                    title.push(&text, title_style.clone());
454                    title
455                }];
456                block.extend(to_lines(console.render_lines(&facet, &opts, false)));
457                blocks.push(block);
458            }
459            let tall = blocks.iter().map(Vec::len).max().unwrap_or(0);
460            for i in 0..tall {
461                let mut line = Line::new();
462                for (k, block) in blocks.iter_mut().enumerate() {
463                    if k > 0 {
464                        line.pad(self.gap);
465                    }
466                    let mut part = block.get_mut(i).map(std::mem::take).unwrap_or_default();
467                    part.crop(cell);
468                    let fill = cell - part.width();
469                    line.append(part);
470                    line.pad(fill);
471                }
472                out.push(line);
473            }
474        }
475        out.extend(note);
476        lines_to_segments(out, width)
477    }
478
479    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
480        let min = self.min_width.min(options.max_width);
481        Measurement::new(min, options.max_width)
482            .with_maximum(options.max_width)
483            .normalize()
484    }
485}
486
487/// `Small multiples, 3 charts on a shared scale from 0 to 60`, then each
488/// chart's title and text, its lines joined with `; `.
489impl crate::a11y::AccessibleText for SmallMultiples {
490    fn accessible_text(&self, width: usize) -> String {
491        if self.items.is_empty() {
492            return "Small multiples, no charts".to_string();
493        }
494        let mut head = format!(
495            "Small multiples, {}",
496            super::describe::counted(self.items.len(), "chart", "charts")
497        );
498        if let Some((lo, hi)) = self.scale() {
499            head.push_str(&format!(
500                " on a shared scale {}",
501                super::describe::range(lo, hi, ValueFormat::Compact)
502            ));
503        }
504        let mut out = vec![head];
505        for ((title, _), facet) in self.items.iter().zip(self.facets()) {
506            let text = facet.accessible_text(width).replace('\n', "; ");
507            if title.is_empty() {
508                out.push(text);
509            } else {
510                out.push(format!("{title}: {text}"));
511            }
512        }
513        out.join("\n")
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::a11y::AccessibleText;
521    use crate::chart::{Series, SeriesKind};
522
523    fn rows(width: usize, r: &dyn Renderable) -> Vec<String> {
524        Console::builder()
525            .width(width)
526            .color_system(None)
527            .build()
528            .render_export(r)
529            .lines()
530            .map(str::to_string)
531            .collect()
532    }
533
534    #[test]
535    fn a_huge_gap_or_width_does_not_overflow() {
536        let grid = sparks().gap(usize::MAX);
537        assert_eq!(grid.columns_at(usize::MAX), grid.items().len());
538        assert_eq!(grid.columns_at(80), 1);
539        assert_eq!(
540            sparks().min_cell_width(usize::MAX).columns_at(usize::MAX),
541            1
542        );
543        assert!(!rows(40, &sparks().gap(usize::MAX)).is_empty());
544    }
545
546    fn sparks() -> SmallMultiples {
547        SmallMultiples::new([
548            ("api", Sparkline::new([10.0, 20.0, 30.0, 40.0])),
549            ("web", Sparkline::new([5.0, 5.0, 10.0, 15.0])),
550            ("worker", Sparkline::new([40.0, 30.0, 20.0, 50.0])),
551            ("cron", Sparkline::new([0.0, 1.0, 0.0, 2.0])),
552        ])
553    }
554
555    #[test]
556    fn columns_follow_the_width() {
557        let grid = sparks();
558        assert_eq!(grid.columns_at(40), 1);
559        assert_eq!(grid.columns_at(80), 3);
560        assert_eq!(grid.columns_at(120), 4);
561        assert_eq!(grid.clone().columns(2).columns_at(120), 2);
562        assert_eq!(grid.clone().columns(9).columns_at(120), 4);
563        assert_eq!(grid.clone().min_cell_width(10).columns_at(40), 3);
564    }
565
566    #[test]
567    fn a_grid_at_40_80_and_120() {
568        let grid = sparks();
569        assert_eq!(
570            rows(40, &grid),
571            vec![
572                "api                                     ",
573                "▂▄▅▇                                    ",
574                "                                        ",
575                "web                                     ",
576                "▂▂▂▃                                    ",
577                "                                        ",
578                "worker                                  ",
579                "▇▅▄█                                    ",
580                "                                        ",
581                "cron                                    ",
582                "▁▁▁▁                                    ",
583                "scale 0 to 50 in every chart            ",
584            ]
585        );
586        assert_eq!(rows(80, &grid).len(), 6);
587        assert_eq!(
588            rows(120, &grid),
589            vec![
590                // Four cells of 28, two apart: 118 cells.
591                format!("{:<30}{:<30}{:<30}{:<28}", "api", "web", "worker", "cron"),
592                format!("{:<30}{:<30}{:<30}{:<28}", "▂▄▅▇", "▂▂▂▃", "▇▅▄█", "▁▁▁▁"),
593                format!("{:<118}", "scale 0 to 50 in every chart"),
594            ]
595        );
596    }
597
598    #[test]
599    fn the_scale_is_shared_unless_asked_not_to() {
600        let grid = sparks();
601        assert_eq!(grid.scale(), Some((0.0, 50.0)));
602        let own = grid.clone().shared_scale(false);
603        assert_eq!(own.scale(), None);
604        // Each on its own: the low series fills its height.
605        assert_eq!(rows(120, &own)[1].split_whitespace().last(), Some("▁▅▁█"));
606        assert_eq!(grid.clone().range(0.0, 100.0).scale(), Some((0.0, 100.0)));
607        // Bars include zero; a line chart's axis rounds out.
608        let mixed = SmallMultiples::new([
609            ("bars", Facet::from(BarChart::new().bar("a", 12.0))),
610            (
611                "line",
612                Facet::from(LineChart::new().series(Series::from_values("p50", [13.0, 47.0]))),
613            ),
614        ]);
615        assert_eq!(mixed.scale(), Some((0.0, 50.0)));
616    }
617
618    #[test]
619    fn line_charts_share_one_legend() {
620        let chart = |ys: [f64; 3]| {
621            LineChart::new()
622                .series(Series::from_values("p50", ys))
623                .series(Series::from_values("p99", ys.map(|y| y * 2.0)).kind(SeriesKind::Scatter))
624                .charset(Charset::Ascii)
625                .height(3)
626        };
627        let grid =
628            SmallMultiples::new([("a", chart([1.0, 2.0, 3.0])), ("b", chart([2.0, 2.0, 2.0]))])
629                .min_cell_width(16);
630        let out = rows(48, &grid);
631        assert_eq!(
632            out.last().map(|l| l.trim_end()),
633            Some("scale 1 to 6 in every chart  * p50  + p99")
634        );
635        // No chart drew its own legend.
636        assert!(
637            !out[..out.len() - 1].iter().any(|l| l.contains("p50")),
638            "{out:#?}"
639        );
640    }
641
642    #[test]
643    fn a_height_is_shared_among_the_rows() {
644        let grid = sparks().columns(2).note(false);
645        let console = Console::builder().width(40).color_system(None).build();
646        let lines = console.render_lines(&grid, &console.options().update_height(9), false);
647        // Two rows of charts: 2 titles, a blank line, 3 rows each.
648        assert_eq!(lines.len(), 9);
649        let fixed = grid.height(2);
650        assert_eq!(rows(40, &fixed).len(), 2 * 3 + 1);
651    }
652
653    #[test]
654    fn accessible_text_reads_each_chart() {
655        let grid = SmallMultiples::new([
656            ("api", Sparkline::new([10.0, 20.0, 30.0, 40.0])),
657            ("cron", Sparkline::new([0.0, 1.0, 0.0, 2.0])),
658        ]);
659        assert_eq!(
660            grid.accessible_text(80),
661            "Small multiples, 2 charts on a shared scale from 0 to 40\n\
662             api: Sparkline, 4 values: min 10, max 40, last 40, trend up\n\
663             cron: Sparkline, 4 values: min 0, max 2, last 2, trend up"
664        );
665        assert_eq!(
666            SmallMultiples::default().accessible_text(80),
667            "Small multiples, no charts"
668        );
669    }
670}