Skip to main content

rich_ext/chart/
line.rs

1//! Line and scatter charts on a Braille dot canvas.
2
3use rich::cells::char_cell_width;
4use rich::measure::Measurement;
5use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
6
7use super::axis::{self, AxisFit, AxisRequest};
8use super::canvas::bresenham;
9use super::{
10    Charset, DotCanvas, Line, Scale, ValueFormat, cells, has_colour, lines_to_segments, series_key,
11    theme_style, truncate, user_style,
12};
13
14/// Markers per series at cell resolution, in order; they cycle.
15const UNICODE_MARKERS: [char; 5] = ['●', '◆', '▲', '■', '○'];
16const ASCII_MARKERS: [char; 5] = ['*', '+', 'o', 'x', '.'];
17
18/// The narrowest plot area drawn before the y labels give way.
19const MIN_PLOT: usize = 4;
20/// The tallest plot drawn, in rows, whatever [`LineChart::height`] asks for.
21const MAX_HEIGHT: usize = u16::MAX as usize;
22
23/// How a [`Series`] is drawn.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum SeriesKind {
26    /// Points joined by straight lines; a NaN or infinite value breaks the
27    /// line.
28    #[default]
29    Line,
30    /// Points only.
31    Scatter,
32}
33
34/// One named set of `(x, y)` points in a [`LineChart`].
35#[derive(Clone, Debug, PartialEq)]
36#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
37pub struct Series {
38    name: String,
39    points: Vec<(f64, f64)>,
40    kind: SeriesKind,
41    style: Option<String>,
42    marker: Option<char>,
43}
44
45impl Series {
46    /// A line through `points`, in the order given.
47    pub fn line(name: impl Into<String>, points: impl IntoIterator<Item = (f64, f64)>) -> Self {
48        Series {
49            name: name.into(),
50            points: points.into_iter().collect(),
51            kind: SeriesKind::Line,
52            style: None,
53            marker: None,
54        }
55    }
56
57    /// Unjoined points.
58    pub fn scatter(name: impl Into<String>, points: impl IntoIterator<Item = (f64, f64)>) -> Self {
59        Series {
60            kind: SeriesKind::Scatter,
61            ..Series::line(name, points)
62        }
63    }
64
65    /// A line through `values` at x = 0, 1, 2, …
66    pub fn from_values(name: impl Into<String>, values: impl IntoIterator<Item = f64>) -> Self {
67        Series::line(
68            name,
69            values.into_iter().enumerate().map(|(i, y)| (i as f64, y)),
70        )
71    }
72
73    /// Draw as a line or as points.
74    pub fn kind(mut self, kind: SeriesKind) -> Self {
75        self.kind = kind;
76        self
77    }
78
79    /// The series' colour: a theme key or a definition (default
80    /// `chart.series.N`, by position).
81    pub fn style(mut self, style: impl Into<String>) -> Self {
82        self.style = Some(style.into());
83        self
84    }
85
86    /// The glyph marking this series at cell resolution and in the legend
87    /// (default by position: `●◆▲■○`, or `*+ox.` in ASCII). A marker that is
88    /// not one cell wide, or not ASCII on an ASCII console, is replaced by
89    /// the default.
90    pub fn marker(mut self, marker: char) -> Self {
91        self.marker = Some(marker);
92        self
93    }
94
95    /// The name shown in the legend.
96    pub fn name(&self) -> &str {
97        &self.name
98    }
99
100    /// The points.
101    pub fn points(&self) -> &[(f64, f64)] {
102        &self.points
103    }
104
105    fn finite(&self) -> impl Iterator<Item = (f64, f64)> + '_ {
106        self.points
107            .iter()
108            .copied()
109            .filter(|(x, y)| x.is_finite() && y.is_finite())
110    }
111}
112
113/// A line or scatter chart: one or more [`Series`] with a y axis, x axis
114/// labels and a legend.
115///
116/// - [`Charset::Braille`] (the default) plots 2×4 dots per cell.
117///   [`Charset::Blocks`] and [`Charset::Ascii`] plot at cell resolution,
118///   each series with its own marker (`●◆▲■○`, or `*`, `+`, `o`, `x`, `.`),
119///   lines drawn as a run of markers.
120/// - Series are told apart by colour (`chart.series.1` to `.5`) **and** by
121///   marker: the legend shows each series' marker in its colour. Braille
122///   dots carry no shape, so when the console shows no colour and there is
123///   more than one series, the chart plots at cell resolution with markers
124///   instead.
125/// - Every row and column of the plot stands for one exact value, at its
126///   centre. Axis labels sit on evenly spaced rows and columns, and each
127///   names the value of the row or column it is on. The scales round out to
128///   a step that fits whole rows and columns (2, 5, 10, 25… a label apart),
129///   unless you fix them with [`y_range`](Self::y_range) and
130///   [`x_range`](Self::x_range); then only rows whose value the format
131///   writes exactly are labelled (at least the two bounds).
132/// - It is [`height`](Self::height) rows of plot (6 to 10 by default, the
133///   one the labels fit best) plus an axis line, a row of x labels and the
134///   legend. It fills the width it is
135///   given (or [`width`](Self::width)); when that is short it drops the y
136///   labels, then the axes, and the x labels and legend are cut to fit.
137///
138/// ```
139/// use rich::Console;
140/// use rich_ext::chart::{Charset, LineChart, Series};
141///
142/// let console = Console::builder().width(24).color_system(None).build();
143/// let chart = LineChart::new()
144///     .series(Series::from_values("load", [1.0, 3.0, 2.0, 5.0, 4.0]))
145///     .height(6)
146///     .charset(Charset::Ascii);
147/// assert_eq!(
148///     console.render_export(&chart),
149///     concat!(
150///         "6 +                     \n",
151///         "5 +               ***   \n",
152///         "4 +             **   ***\n",
153///         "3 +    ****   **        \n",
154///         "2 +  **    ***          \n",
155///         "1 +**                   \n",
156///         "  ++----+----+----+----+\n",
157///         "   0    1    2    3    4\n",
158///         "* load                  \n",
159///     )
160/// );
161/// ```
162#[derive(Clone, Debug, PartialEq)]
163#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
164pub struct LineChart {
165    series: Vec<Series>,
166    height: Option<usize>,
167    width: Option<usize>,
168    x_min: Option<f64>,
169    x_max: Option<f64>,
170    y_min: Option<f64>,
171    y_max: Option<f64>,
172    charset: Charset,
173    legend: bool,
174    y_format: ValueFormat,
175    x_format: ValueFormat,
176}
177
178impl Default for LineChart {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184impl LineChart {
185    /// An empty chart; add series with [`series`](Self::series).
186    pub fn new() -> Self {
187        LineChart {
188            series: Vec::new(),
189            height: None,
190            width: None,
191            x_min: None,
192            x_max: None,
193            y_min: None,
194            y_max: None,
195            charset: Charset::Auto,
196            legend: true,
197            y_format: ValueFormat::Compact,
198            x_format: ValueFormat::Compact,
199        }
200    }
201
202    /// Add a series.
203    pub fn series(mut self, series: Series) -> Self {
204        self.series.push(series);
205        self
206    }
207
208    /// The series, in legend order.
209    pub fn all_series(&self) -> &[Series] {
210        &self.series
211    }
212
213    /// Rows of plot, not counting the axis, x labels and legend (at least
214    /// 1, at most 65535). By default the chart takes 6 to 10 rows, near 8, picking the
215    /// height whose rows its y labels divide evenly.
216    pub fn height(mut self, rows: usize) -> Self {
217        self.height = Some(rows.clamp(1, MAX_HEIGHT));
218        self
219    }
220
221    /// Width in cells (default: the width it is given).
222    pub fn width(mut self, width: usize) -> Self {
223        self.width = Some(width.max(1));
224        self
225    }
226
227    /// Fix the x scale; points outside are clamped to its edges.
228    pub fn x_range(mut self, min: f64, max: f64) -> Self {
229        self.x_min = Some(min);
230        self.x_max = Some(max);
231        self
232    }
233
234    /// Fix the y scale; points outside are clamped to its edges.
235    pub fn y_range(mut self, min: f64, max: f64) -> Self {
236        self.y_min = Some(min);
237        self.y_max = Some(max);
238        self
239    }
240
241    /// Glyphs to draw with (default [`Charset::Auto`]: Braille).
242    pub fn charset(mut self, charset: Charset) -> Self {
243        self.charset = charset;
244        self
245    }
246
247    /// Show the legend (default on, when any series has a name).
248    pub fn legend(mut self, show: bool) -> Self {
249        self.legend = show;
250        self
251    }
252
253    /// How the y axis writes values (default [`ValueFormat::Compact`]).
254    pub fn y_format(mut self, format: ValueFormat) -> Self {
255        self.y_format = format;
256        self
257    }
258
259    /// How the x axis writes values (default [`ValueFormat::Compact`]).
260    pub fn x_format(mut self, format: ValueFormat) -> Self {
261        self.x_format = format;
262        self
263    }
264
265    /// The y axis: how many rows, what each is worth, and its labels.
266    fn y_axis(&self) -> AxisFit {
267        let ys = self.series.iter().flat_map(|s| s.finite().map(|p| p.1));
268        let fixed = self.y_min.is_some() || self.y_max.is_some();
269        let data = Scale::from_values(ys).bounds(self.y_min, self.y_max);
270        let (cells, wanted): (Vec<usize>, usize) = match self.height {
271            Some(rows) => (vec![rows - 1], (rows / 2 + 1).clamp(2, 6)),
272            None => (vec![7, 6, 8, 5, 9], 5),
273        };
274        axis::fit(&AxisRequest {
275            data,
276            fixed,
277            cells: &cells,
278            wanted,
279            label_room: false,
280            format: self.y_format,
281        })
282    }
283
284    /// The x axis across `columns` columns.
285    fn x_axis(&self, columns: usize) -> AxisFit {
286        let xs = self.series.iter().flat_map(|s| s.finite().map(|p| p.0));
287        let fixed = self.x_min.is_some() || self.x_max.is_some();
288        let data = Scale::from_values(xs).bounds(self.x_min, self.x_max);
289        let guess =
290            cells(&self.x_format.format(data.min())).max(cells(&self.x_format.format(data.max())));
291        axis::fit(&AxisRequest {
292            data,
293            fixed,
294            cells: &[columns.saturating_sub(1)],
295            wanted: (columns / (guess + 3)).max(2),
296            label_room: true,
297            format: self.x_format,
298        })
299    }
300
301    fn marker(&self, index: usize, ascii: bool) -> char {
302        let set = if ascii {
303            &ASCII_MARKERS
304        } else {
305            &UNICODE_MARKERS
306        };
307        self.series[index]
308            .marker
309            .filter(|m| char_cell_width(*m) == 1 && (!ascii || m.is_ascii()))
310            .unwrap_or(set[index % set.len()])
311    }
312
313    fn series_style(&self, console: &Console, index: usize) -> Style {
314        match &self.series[index].style {
315            Some(spec) => user_style(console, spec),
316            None => theme_style(console, &series_key(index)),
317        }
318    }
319
320    fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
321        let width = self
322            .width
323            .map_or(options.max_width, |w| w.min(options.max_width));
324        let mut charset = self.charset.resolve(console, options, Charset::Braille);
325        let colour = has_colour(console);
326        if charset == Charset::Braille && !colour && self.series.len() > 1 {
327            charset = Charset::Blocks;
328        }
329        let ascii = charset == Charset::Ascii;
330        if self.series.iter().all(|s| s.finite().next().is_none()) {
331            let mut line = Line::new();
332            line.push(&truncate("no data", width, ascii), None);
333            return vec![line];
334        }
335        let y_axis = self.y_axis();
336        let rows = y_axis.cells + 1;
337        let axis_style = colour.then(|| theme_style(console, "chart.axis"));
338        let label_style = colour.then(|| theme_style(console, "chart.label"));
339
340        // Y labels and the space they need.
341        let label_w = y_axis
342            .labels
343            .iter()
344            .map(|(_, l)| cells(l))
345            .max()
346            .unwrap_or(0);
347        let (labels, axis) = if label_w > 0 && width >= label_w + 2 + MIN_PLOT {
348            (true, true)
349        } else if width >= 2 {
350            (false, true)
351        } else {
352            (false, false)
353        };
354        let prefix = if labels { label_w + 1 } else { 0 } + usize::from(axis);
355        let plot_w = width.saturating_sub(prefix).max(1);
356        let x_axis = self.x_axis(plot_w);
357
358        // Plot. Each row and column has an exact value at its centre; in
359        // Braille that centre falls between its middle dots.
360        let braille = charset == Charset::Braille;
361        let (dot_w, dot_h) = if braille {
362            (plot_w * 2, rows * 4)
363        } else {
364            (plot_w, rows)
365        };
366        let to_dot = |x: f64, y: f64| -> (i64, i64) {
367            let col = x_axis.position(x);
368            let row = y_axis.cells as f64 - y_axis.position(y);
369            let (dx, dy) = if braille {
370                (col * 2.0 + 0.5, row * 4.0 + 1.5)
371            } else {
372                (col, row)
373            };
374            (
375                (dx.round() as i64).clamp(0, dot_w as i64 - 1),
376                (dy.round() as i64).clamp(0, dot_h as i64 - 1),
377            )
378        };
379        let mut canvas = DotCanvas::new(plot_w, rows);
380        let mut grid: Vec<Option<(char, usize)>> = vec![None; plot_w * rows];
381        for (index, series) in self.series.iter().enumerate() {
382            let marker = self.marker(index, ascii);
383            let mut plot = |(x, y): (i64, i64)| {
384                if braille {
385                    canvas.set(x, y, index);
386                } else if x >= 0 && y >= 0 && (x as usize) < plot_w && (y as usize) < rows {
387                    grid[y as usize * plot_w + x as usize] = Some((marker, index));
388                }
389            };
390            let mut previous: Option<(i64, i64)> = None;
391            for &(x, y) in &series.points {
392                if !(x.is_finite() && y.is_finite()) {
393                    previous = None;
394                    continue;
395                }
396                let point = to_dot(x, y);
397                match (series.kind, previous) {
398                    (SeriesKind::Line, Some(from)) => {
399                        for p in bresenham(from, point) {
400                            plot(p);
401                        }
402                    }
403                    _ => plot(point),
404                }
405                previous = Some(point);
406            }
407        }
408
409        // The row each y label names, counted from the top.
410        let mut tick_rows: Vec<Option<&str>> = vec![None; rows];
411        for (position, label) in &y_axis.labels {
412            tick_rows[y_axis.cells - position] = Some(label.as_str());
413        }
414
415        let styles: Vec<Option<Style>> = (0..self.series.len())
416            .map(|i| colour.then(|| self.series_style(console, i)))
417            .collect();
418        let (v_axis, v_tick, corner, h_axis, h_tick) = if ascii {
419            ('|', '+', '+', '-', '+')
420        } else {
421            ('│', '┤', '└', '─', '┬')
422        };
423        let mut out = Vec::new();
424        for (row, tick) in tick_rows.iter().enumerate() {
425            let mut line = Line::new();
426            if labels {
427                let label = tick.unwrap_or("");
428                line.pad(label_w - cells(label));
429                line.push(label, label_style.clone());
430                line.pad(1);
431            }
432            if axis {
433                let glyph = if tick.is_some() { v_tick } else { v_axis };
434                line.push(&glyph.to_string(), axis_style.clone());
435            }
436            for col in 0..plot_w {
437                let (glyph, layer) = if braille {
438                    if canvas.is_set(col, row) {
439                        let (c, layer) = canvas.cell(col, row);
440                        (c, layer)
441                    } else {
442                        (' ', None)
443                    }
444                } else {
445                    match grid[row * plot_w + col] {
446                        Some((c, layer)) => (c, Some(layer)),
447                        None => (' ', None),
448                    }
449                };
450                let style = layer.and_then(|l| styles[l].clone());
451                line.push(&glyph.to_string(), style);
452            }
453            out.push(line);
454        }
455
456        // X axis and labels.
457        if axis {
458            // Labels on evenly spaced columns, each the column's value.
459            let x_ticks: Vec<(usize, String)> = x_axis
460                .labels
461                .iter()
462                .filter(|(col, _)| *col < plot_w)
463                .cloned()
464                .collect();
465            let mut rule = Line::new();
466            rule.pad(prefix - 1);
467            let mut glyphs: Vec<char> = vec![h_axis; plot_w];
468            for (col, _) in &x_ticks {
469                glyphs[*col] = h_tick;
470            }
471            rule.push(&corner.to_string(), axis_style.clone());
472            rule.push(&glyphs.into_iter().collect::<String>(), axis_style.clone());
473            out.push(rule);
474
475            let mut row = vec![' '; plot_w];
476            let mut free_from = 0usize;
477            for (col, label) in &x_ticks {
478                let len = label.chars().count();
479                if len > plot_w {
480                    continue;
481                }
482                let start = col.saturating_sub(len / 2).min(plot_w - len);
483                if start < free_from {
484                    continue;
485                }
486                for (i, c) in label.chars().enumerate() {
487                    row[start + i] = c;
488                }
489                free_from = start + len + 1;
490            }
491            let mut labels_line = Line::new();
492            labels_line.pad(prefix);
493            labels_line.push(&row.into_iter().collect::<String>(), label_style.clone());
494            out.push(labels_line);
495        }
496
497        // Legend: marker and name, in series order, wrapped to the width.
498        if self.legend && self.series.iter().any(|s| !s.name.is_empty()) {
499            let mut line = Line::new();
500            for (index, series) in self.series.iter().enumerate() {
501                let name = truncate(&series.name, width.saturating_sub(2), ascii);
502                let entry_w = 2 + cells(&name);
503                if line.width() > 0 && line.width() + 2 + entry_w > width {
504                    out.push(std::mem::take(&mut line));
505                }
506                if line.width() > 0 {
507                    line.pad(2);
508                }
509                line.push(
510                    &self.marker(index, ascii).to_string(),
511                    styles[index].clone(),
512                );
513                line.pad(1);
514                line.push(&name, label_style.clone());
515            }
516            out.push(line);
517        }
518        out
519    }
520}
521
522/// `Line chart, 2 series, x from 0 to 23, y from 12 to 52`, then a line
523/// per series: its name and kind, then its facts (`24 points, min 12, max
524/// 52, last 14, trend up`). The ranges are the fixed ones, else the data's.
525impl crate::a11y::AccessibleText for LineChart {
526    fn accessible_text(&self, _width: usize) -> String {
527        use super::describe::{counted, extent, range, series};
528        let lines = self
529            .series
530            .iter()
531            .filter(|s| s.kind == SeriesKind::Line)
532            .count();
533        let what = match (lines, self.series.len() - lines) {
534            (_, 0) => "Line chart",
535            (0, _) => "Scatter chart",
536            _ => "Line and scatter chart",
537        };
538        if self.series.is_empty() {
539            return format!("{what}, no series");
540        }
541        let mut head = format!("{what}, {}", counted(self.series.len(), "series", "series"));
542        let finite = || self.series.iter().flat_map(|s| s.finite());
543        let axis = |fixed: (Option<f64>, Option<f64>), data: Option<(f64, f64)>, format| {
544            let lo = fixed.0.or(data.map(|d| d.0))?;
545            let hi = fixed.1.or(data.map(|d| d.1))?;
546            Some(range(lo.min(hi), lo.max(hi), format))
547        };
548        if let Some(x) = axis(
549            (self.x_min, self.x_max),
550            extent(finite().map(|p| p.0)),
551            self.x_format,
552        ) {
553            head.push_str(&format!(", x {x}"));
554        }
555        if let Some(y) = axis(
556            (self.y_min, self.y_max),
557            extent(finite().map(|p| p.1)),
558            self.y_format,
559        ) {
560            head.push_str(&format!(", y {y}"));
561        }
562        let mut out = vec![head];
563        for (index, s) in self.series.iter().enumerate() {
564            let name = if s.name.is_empty() {
565                format!("series {}", index + 1)
566            } else {
567                s.name.clone()
568            };
569            let kind = match s.kind {
570                SeriesKind::Line => "line",
571                SeriesKind::Scatter => "points",
572            };
573            let facts = series(&s.points, self.y_format, ("point", "points"));
574            out.push(format!("{name}, {kind}: {facts}"));
575        }
576        out.join("\n")
577    }
578}
579
580impl LineChart {
581    /// The finite y values' range, or the fixed one: what a shared scale
582    /// unifies.
583    pub(crate) fn y_extent(&self) -> Option<(f64, f64)> {
584        let data =
585            super::describe::extent(self.series.iter().flat_map(|s| s.finite().map(|p| p.1)));
586        let lo = self.y_min.or(data.map(|d| d.0))?;
587        let hi = self.y_max.or(data.map(|d| d.1))?;
588        Some((lo.min(hi), lo.max(hi)))
589    }
590
591    /// Whether it shows a legend.
592    pub(crate) fn has_legend(&self) -> bool {
593        self.legend && self.series.iter().any(|s| !s.name.is_empty())
594    }
595
596    /// Series `index`'s marker and style, for a legend drawn elsewhere.
597    pub(crate) fn legend_entry(
598        &self,
599        console: &Console,
600        options: &ConsoleOptions,
601        index: usize,
602    ) -> (char, Option<Style>) {
603        let ascii = self.charset.resolve(console, options, Charset::Braille) == Charset::Ascii;
604        (
605            self.marker(index, ascii),
606            has_colour(console).then(|| self.series_style(console, index)),
607        )
608    }
609}
610
611impl crate::surface::Draw for LineChart {
612    fn draw(
613        &self,
614        console: &Console,
615        options: &ConsoleOptions,
616        screen: &mut crate::surface::Screen,
617        rect: crate::surface::Rect,
618    ) {
619        super::draw_lines(
620            self.lines(console, options),
621            options.max_width,
622            options,
623            screen,
624            rect,
625        );
626    }
627}
628
629impl Renderable for LineChart {
630    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
631        lines_to_segments(self.lines(console, options), options.max_width)
632    }
633
634    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
635        let max = self.width.unwrap_or(options.max_width);
636        Measurement::new(max.min(12), max)
637            .with_maximum(options.max_width)
638            .normalize()
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use crate::a11y::AccessibleText;
646
647    #[test]
648    fn accessible_text_reads_each_series() {
649        let chart = LineChart::new()
650            .series(Series::from_values("api", [1.0, 3.0, 2.0, 5.0]))
651            .series(Series::scatter("p99", [(0.0, 9.0), (3.0, 7.0)]));
652        assert_eq!(
653            chart.accessible_text(80),
654            "Line and scatter chart, 2 series, x from 0 to 3, y from 1 to 9\n\
655             api, line: 4 points, min 1, max 5, last 5, trend up\n\
656             p99, points: 2 points, min 7, max 9, last 7, trend down"
657        );
658        // A fixed range is the axis's; a series without a name is numbered.
659        let fixed = LineChart::new()
660            .series(Series::from_values("", [4.0, 4.0, f64::NAN]))
661            .y_range(0.0, 10.0);
662        assert_eq!(
663            fixed.accessible_text(80),
664            "Line chart, 1 series, x from 0 to 1, y from 0 to 10\n\
665             series 1, line: 3 points, 1 missing, min 4, max 4, last 4, trend flat"
666        );
667        assert_eq!(
668            LineChart::new().accessible_text(80),
669            "Line chart, no series"
670        );
671    }
672}