Skip to main content

rich_ext/chart/
xy.rs

1//! An x–y chart laid out as ratatui's `Chart` widget: datasets drawn on a
2//! [`Canvas`](super::Canvas), two axes with titles and labels, and a boxed
3//! legend.
4
5use rich::measure::Measurement;
6use rich::{Console, ConsoleOptions, HorizontalAlign, Renderable, Segment, Style};
7
8use super::plot_canvas::{CellGrid, Marker, Op, View, is_ascii, raster_into, spec_style};
9use super::{Scale, ValueFormat, cell_units, cells, lines_to_segments, series_key};
10use crate::fidelity::ascii_text;
11
12mod stacked;
13
14/// Rows a [`Chart`] takes when it is given no height and [`Chart::height`]
15/// is not set.
16const DEFAULT_HEIGHT: usize = 15;
17/// The tallest chart drawn, in rows.
18const MAX_HEIGHT: usize = u16::MAX as usize;
19
20/// How an [`Axis`] maps values onto its length.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22pub enum AxisScale {
23    /// Evenly.
24    #[default]
25    Linear,
26    /// By their base-10 logarithm: each power of ten takes the same length.
27    /// Zero, negative and non-finite values are skipped.
28    Log,
29}
30
31/// The labels along an [`Axis`].
32#[derive(Clone, Debug, Default, PartialEq)]
33pub enum Labels {
34    /// No labels, and no axis line (as in ratatui, where an axis without
35    /// labels draws neither).
36    #[default]
37    None,
38    /// About this many round values inside the bounds, each at its place.
39    /// On a [`Log`](AxisScale::Log) axis: the powers of ten, with 2 and 5
40    /// times them when fewer than two powers fit.
41    Auto(usize),
42    /// These values, each at its place; values outside the bounds are left
43    /// out.
44    Values(Vec<f64>),
45    /// Text spread evenly along the axis, as ratatui places its labels: the
46    /// first at the start, the last at the end.
47    Text(Vec<String>),
48    /// Console markup spread evenly, as [`Text`](Self::Text) is:
49    /// `"[bold]0[/]"` is a bold label, as a ratatui label of styled spans
50    /// is. The markup's styles go over the axis style.
51    Markup(Vec<String>),
52}
53
54/// One axis of a [`Chart`]: its bounds, title, labels and scale.
55///
56/// ```
57/// use rich_ext::chart::{Axis, AxisScale, Labels};
58///
59/// let axis = Axis::default()
60///     .bounds(1.0, 1000.0)
61///     .scale(AxisScale::Log)
62///     .labels(Labels::Auto(4))
63///     .title("Hz");
64/// assert_eq!(axis.label_values(), vec![1.0, 10.0, 100.0, 1000.0]);
65/// ```
66#[derive(Clone, Debug, Default, PartialEq)]
67#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
68pub struct Axis {
69    bounds: Option<[f64; 2]>,
70    title: Option<String>,
71    title_style: Option<String>,
72    labels: Labels,
73    labels_align: Option<HorizontalAlign>,
74    scale: AxisScale,
75    style: Option<String>,
76    label_format: ValueFormat,
77    /// Behind one pointer, as a [`Facet`](super::Facet) holds a chart and
78    /// its two axes, and must stay small.
79    grid: Option<Box<GridLines>>,
80}
81
82/// An [`Axis`]'s grid lines: their values, style and marker.
83#[derive(Clone, Debug, Default, PartialEq)]
84struct GridLines {
85    values: Vec<f64>,
86    style: Option<String>,
87    marker: Option<Marker>,
88}
89
90impl Axis {
91    /// The values at either end, in either order. Without bounds, the axis
92    /// spans the chart's data.
93    pub fn bounds(mut self, min: f64, max: f64) -> Self {
94        self.bounds = Some([min, max]);
95        self
96    }
97
98    /// The axis title: the y axis's above its top left, the x axis's at the
99    /// right end of the plot's bottom row.
100    pub fn title(mut self, title: impl Into<String>) -> Self {
101        self.title = Some(title.into());
102        self
103    }
104
105    /// The title's style: a theme key or a definition (default
106    /// `chart.label`).
107    pub fn title_style(mut self, style: impl Into<String>) -> Self {
108        self.title_style = Some(style.into());
109        self
110    }
111
112    /// The labels (default [`Labels::None`]: no labels and no axis line).
113    pub fn labels(mut self, labels: Labels) -> Self {
114        self.labels = labels;
115        self
116    }
117
118    /// How the labels sit, as ratatui's `labels_alignment`.
119    ///
120    /// - **On the y axis**, in the column left of the axis line. By
121    ///   default [`Labels::Auto`] and [`Labels::Values`] are right-aligned
122    ///   and [`Labels::Text`] and [`Labels::Markup`] left-aligned, as in
123    ///   ratatui.
124    /// - **On the x axis**, the first text label against the y axis line:
125    ///   `Left` (the default) ends it under the line, `Center` centres it
126    ///   on the line, `Right` starts it there. A label placed at a value
127    ///   ([`Labels::Auto`], [`Labels::Values`]) starts at its value
128    ///   (`Left`), is centred on it (the default) or ends at it (`Right`).
129    pub fn labels_align(mut self, align: HorizontalAlign) -> Self {
130        self.labels_align = Some(align);
131        self
132    }
133
134    /// Linear (the default) or logarithmic.
135    pub fn scale(mut self, scale: AxisScale) -> Self {
136        self.scale = scale;
137        self
138    }
139
140    /// The style of the axis line and labels: a theme key or a definition
141    /// (default `chart.axis`).
142    pub fn style(mut self, style: impl Into<String>) -> Self {
143        self.style = Some(style.into());
144        self
145    }
146
147    /// How [`Labels::Auto`] and [`Labels::Values`] are written (default
148    /// [`ValueFormat::Compact`]).
149    pub fn label_format(mut self, format: ValueFormat) -> Self {
150        self.label_format = format;
151        self
152    }
153
154    /// Grid lines across the plot at these values, in the axis's
155    /// [`grid_style`](Self::grid_style) (else its [`style`](Self::style)),
156    /// drawn under the datasets with the axis's
157    /// [`grid_marker`](Self::grid_marker) (else the chart's
158    /// [`marker`](Chart::marker), else the first dataset's, else Braille):
159    /// the x axis's run up the plot, the y axis's along it. Values the axis
160    /// cannot show (outside its bounds, or not positive on a log axis) are
161    /// left out. With [`Layering::PerDataset`] each line is a layer of its
162    /// own, the y axis's first, and the datasets are drawn over them, so a
163    /// line crossing another and a trace crossing a line break what they
164    /// cross, as datasets drawn for the purpose do in ratatui (scope-tui's
165    /// reference lines).
166    ///
167    /// ```
168    /// use rich::Console;
169    /// use rich_ext::chart::{Axis, Chart, Dataset, Marker};
170    ///
171    /// let console = Console::builder().width(9).color_system(None).build();
172    /// let chart = Chart::new(vec![Dataset::new([(2.0, 1.0)]).marker(Marker::Ascii('o'))])
173    ///     .x_axis(Axis::default().bounds(0.0, 8.0).grid([4.0]))
174    ///     .y_axis(Axis::default().bounds(0.0, 2.0).grid([0.0]))
175    ///     .width(9)
176    ///     .height(3);
177    /// // The point at (2, 1); the grid at x = 4 and y = 0, in the axis style.
178    /// assert_eq!(console.render_export(&chart), "    o    \n  o o    \nooooooooo\n");
179    /// ```
180    pub fn grid(mut self, values: impl IntoIterator<Item = f64>) -> Self {
181        self.grid_lines().values = values.into_iter().collect();
182        self
183    }
184
185    /// The style of the [`grid`](Self::grid) lines: a theme key or a
186    /// definition (default: the axis's [`style`](Self::style), else
187    /// `chart.axis`), so the lines can be fainter than the axis line and
188    /// labels.
189    ///
190    /// ```
191    /// use rich::Console;
192    /// use rich_ext::chart::{Axis, Chart, Dataset, Labels, Marker};
193    ///
194    /// let console = Console::builder().width(5).force_terminal(true).build();
195    /// let chart = Chart::new(Vec::<Dataset>::new())
196    ///     .marker(Marker::Ascii('.'))
197    ///     .x_axis(Axis::default().bounds(0.0, 4.0))
198    ///     .y_axis(
199    ///         Axis::default()
200    ///             .bounds(0.0, 1.0)
201    ///             .labels(Labels::Text(vec!["0".into(), "1".into()]))
202    ///             .style("bold")
203    ///             .grid([0.0])
204    ///             .grid_style("dim"),
205    ///     )
206    ///     .width(5)
207    ///     .height(2);
208    /// // The labels and the axis line bold, the grid line dim.
209    /// assert_eq!(
210    ///     console.render_export(&chart),
211    ///     "\u{1b}[1m1│\u{1b}[0m   \n\u{1b}[1m0│\u{1b}[0m\u{1b}[2m...\u{1b}[0m\n"
212    /// );
213    /// ```
214    pub fn grid_style(mut self, style: impl Into<String>) -> Self {
215        self.grid_lines().style = Some(style.into());
216        self
217    }
218
219    /// The marker the [`grid`](Self::grid) lines are drawn with (default:
220    /// the chart's [`marker`](Chart::marker), else the first dataset's,
221    /// else [`Marker::Braille`]).
222    pub fn grid_marker(mut self, marker: Marker) -> Self {
223        self.grid_lines().marker = Some(marker);
224        self
225    }
226
227    fn grid_lines(&mut self) -> &mut GridLines {
228        self.grid.get_or_insert_with(Default::default)
229    }
230
231    /// The values labelled with [`Labels::Auto`] or [`Labels::Values`],
232    /// when the axis spans its bounds (or `0..1` without them).
233    pub fn label_values(&self) -> Vec<f64> {
234        let scale = self.span(std::iter::empty());
235        self.values(&scale)
236    }
237
238    /// `value` where the axis measures it: itself, or its logarithm.
239    fn map(&self, value: f64) -> Option<f64> {
240        match self.scale {
241            _ if !value.is_finite() => None,
242            AxisScale::Linear => Some(value),
243            AxisScale::Log if value > 0.0 => Some(value.log10()),
244            AxisScale::Log => None,
245        }
246    }
247
248    /// The mapped span, from the bounds or else from `data`.
249    fn span(&self, data: impl Iterator<Item = f64>) -> Scale {
250        let given = self.bounds.and_then(|[a, b]| {
251            let (a, b) = (self.map(a)?, self.map(b)?);
252            Some(Scale::new(a, b))
253        });
254        match given {
255            Some(scale) => scale,
256            None => Scale::from_values(data.filter_map(|v| self.map(v))),
257        }
258    }
259
260    /// The values [`Labels::Auto`] or [`Labels::Values`] put on `scale`
261    /// (mapped), in value terms.
262    fn values(&self, scale: &Scale) -> Vec<f64> {
263        let inside = |m: f64| {
264            let slack = scale.span() * 1e-9;
265            m >= scale.min() - slack && m <= scale.max() + slack
266        };
267        match (&self.labels, self.scale) {
268            (Labels::Values(values), _) => values
269                .iter()
270                .copied()
271                .filter(|v| self.map(*v).is_some_and(inside))
272                .collect(),
273            (Labels::Auto(0), _) => Vec::new(),
274            (Labels::Auto(n), AxisScale::Linear) => {
275                scale.ticks(*n).into_iter().filter(|v| inside(*v)).collect()
276            }
277            (Labels::Auto(n), AxisScale::Log) => log_ticks(scale, *n),
278            _ => Vec::new(),
279        }
280    }
281
282    fn resolve(&self, data: impl Iterator<Item = f64>, console: &Console, ascii: bool) -> Resolved {
283        let scale = self.span(data);
284        let text = |s: &str| {
285            if ascii && !s.is_ascii() {
286                ascii_text(s)
287            } else {
288                s.to_string()
289            }
290        };
291        let plain = |s: &str| Label::plain(text(s));
292        let labels = match &self.labels {
293            Labels::None => Placed::None,
294            Labels::Text(labels) => Placed::Even(labels.iter().map(|l| plain(l)).collect()),
295            Labels::Markup(labels) => Placed::Even(
296                labels
297                    .iter()
298                    .map(|l| Label::markup(console, l, ascii))
299                    .collect(),
300            ),
301            _ => Placed::At(
302                self.values(&scale)
303                    .into_iter()
304                    .filter_map(|v| Some((self.map(v)?, plain(&self.label_format.format(v)))))
305                    .collect(),
306            ),
307        };
308        Resolved {
309            scale,
310            labels,
311            title: self.title.as_deref().map(text).filter(|t| !t.is_empty()),
312        }
313    }
314}
315
316/// Labels on a log axis spanning `scale` (in log10 terms): the powers of
317/// ten inside, with 2 and 5 times them when fewer than two fit, thinned to
318/// about `count`.
319fn log_ticks(scale: &Scale, count: usize) -> Vec<f64> {
320    let (lo, hi) = (scale.min(), scale.max());
321    let slack = (hi - lo) * 1e-9;
322    let inside = |v: f64| (lo - slack..=hi + slack).contains(&v.log10());
323    // Bounds are logs of finite values, so these stay within ±400.
324    let (first, last) = (lo.floor() as i32, hi.ceil() as i32);
325    let mut values: Vec<f64> = (first..=last)
326        .map(|k| 10f64.powi(k))
327        .filter(|v| inside(*v))
328        .collect();
329    if values.len() < 2 {
330        values = (first..=last)
331            .flat_map(|k| [1.0, 2.0, 5.0].map(|m| m * 10f64.powi(k)))
332            .filter(|v| inside(*v))
333            .collect();
334    }
335    let count = count.max(2);
336    if values.len() > count {
337        let step = values.len().div_ceil(count);
338        values = values.into_iter().step_by(step).collect();
339    }
340    values
341}
342
343/// An axis ready to draw: its mapped span, labels and title.
344struct Resolved {
345    scale: Scale,
346    labels: Placed,
347    title: Option<String>,
348}
349
350enum Placed {
351    None,
352    /// Labels at mapped values.
353    At(Vec<(f64, Label)>),
354    /// Labels spread evenly.
355    Even(Vec<Label>),
356}
357
358/// An axis label: runs of text, each in its own style over the axis style
359/// (`None`: the axis style alone).
360#[derive(Clone, Debug, Default)]
361struct Label {
362    runs: Vec<(String, Option<Style>)>,
363}
364
365impl Label {
366    fn plain(text: String) -> Label {
367        Label {
368            runs: vec![(text, None)],
369        }
370    }
371
372    /// The first line of console `markup`, as styled runs; markup that does
373    /// not parse is plain text.
374    fn markup(console: &Console, markup: &str, ascii: bool) -> Label {
375        let text = rich::Text::from_markup(markup).unwrap_or_else(|_| rich::Text::new(markup));
376        let mut options = console.options().update_width(cells(text.plain()).max(1));
377        options.no_wrap = Some(true);
378        let mut runs = Vec::new();
379        for segment in console.render(&text, Some(&options)) {
380            if segment.control || segment.text.is_empty() {
381                continue;
382            }
383            if segment.text.contains('\n') {
384                break;
385            }
386            let run = if ascii && !segment.text.is_ascii() {
387                ascii_text(&segment.text)
388            } else {
389                segment.text.into_string()
390            };
391            runs.push((run, segment.style));
392        }
393        Label { runs }
394    }
395
396    fn width(&self) -> usize {
397        self.runs.iter().map(|(text, _)| cells(text)).sum()
398    }
399}
400
401/// A run's style over the axis style.
402fn over(base: &Option<Style>, own: &Option<Style>) -> Option<Style> {
403    match (base, own) {
404        (_, None) => base.clone(),
405        (None, Some(own)) => Some(own.clone()),
406        (Some(base), Some(own)) => Some(base.combine(own)),
407    }
408}
409
410impl Resolved {
411    /// `axis`'s grid values that this span shows, mapped.
412    fn grid(&self, axis: &Axis) -> Vec<f64> {
413        let (lo, hi) = (self.scale.min(), self.scale.max());
414        let slack = (hi - lo).abs() * 1e-9;
415        axis.grid
416            .iter()
417            .flat_map(|lines| lines.values.iter())
418            .filter_map(|v| axis.map(*v))
419            .filter(|m| lo.min(hi) - slack <= *m && *m <= lo.max(hi) + slack)
420            .collect()
421    }
422
423    fn has_labels(&self) -> bool {
424        !matches!(self.labels, Placed::None)
425    }
426
427    fn texts(&self) -> Vec<&Label> {
428        match &self.labels {
429            Placed::None => Vec::new(),
430            Placed::At(labels) => labels.iter().map(|(_, l)| l).collect(),
431            Placed::Even(labels) => labels.iter().collect(),
432        }
433    }
434
435    /// The cell, of `cells`, that a mapped value falls in, with `per` dots
436    /// to a cell, counted from the low end.
437    fn cell(&self, mapped: f64, cells: usize, per: usize) -> usize {
438        let t = self.scale.normalize(mapped).unwrap_or(0.0);
439        let dots = (cells * per).saturating_sub(1);
440        ((t * dots as f64) as usize / per).min(cells.saturating_sub(1))
441    }
442}
443
444/// How a [`Dataset`] joins its points.
445#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
446pub enum GraphType {
447    /// Points only (ratatui's default).
448    #[default]
449    Scatter,
450    /// Points joined by straight lines; a point the axes cannot show (NaN,
451    /// infinite, or not positive on a log axis) breaks the line.
452    Line,
453    /// A vertical line from zero (the bottom, on a log axis) to each point.
454    Bar,
455    /// Points joined by lines, as [`Line`](Self::Line), with the area
456    /// between the lines and [`Dataset::fill_to`] filled in (ratatui's
457    /// `GraphType::Area`, 0.30.1). A level the axis cannot show (not
458    /// positive on a log axis) fills to the bottom.
459    Area,
460}
461
462/// One named set of `(x, y)` points in a [`Chart`].
463#[derive(Clone, Debug, PartialEq)]
464#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
465pub struct Dataset {
466    name: String,
467    points: Vec<(f64, f64)>,
468    graph_type: GraphType,
469    marker: Marker,
470    style: Option<String>,
471    fill_to: f64,
472}
473
474impl Dataset {
475    /// Unnamed points, drawn as a scatter of [`Marker::Dot`]s (ratatui's
476    /// defaults).
477    pub fn new(points: impl IntoIterator<Item = (f64, f64)>) -> Self {
478        Dataset {
479            name: String::new(),
480            points: points.into_iter().collect(),
481            graph_type: GraphType::Scatter,
482            marker: Marker::Dot,
483            style: None,
484            fill_to: 0.0,
485        }
486    }
487
488    /// The name shown in the legend; a dataset without one is left out.
489    pub fn name(mut self, name: impl Into<String>) -> Self {
490        self.name = name.into();
491        self
492    }
493
494    /// Points, lines or bars.
495    pub fn graph_type(mut self, graph_type: GraphType) -> Self {
496        self.graph_type = graph_type;
497        self
498    }
499
500    /// The marker (default [`Marker::Dot`]).
501    pub fn marker(mut self, marker: Marker) -> Self {
502        self.marker = marker;
503        self
504    }
505
506    /// The style of the points and the legend entry: a theme key or a
507    /// definition (default `chart.series.N`, by position).
508    pub fn style(mut self, style: impl Into<String>) -> Self {
509        self.style = Some(style.into());
510        self
511    }
512
513    /// The level a [`GraphType::Area`] fills to (default 0): below the
514    /// points that are above it, above those below it. ratatui calls it
515    /// `fill_to_y`.
516    pub fn fill_to(mut self, y: f64) -> Self {
517        self.fill_to = y;
518        self
519    }
520
521    /// The points.
522    pub fn points(&self) -> &[(f64, f64)] {
523        &self.points
524    }
525}
526
527/// Where a [`Chart`] puts its legend, inside the plot.
528#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
529pub enum LegendPosition {
530    /// The top right corner (ratatui's default).
531    #[default]
532    TopRight,
533    /// Centred along the top.
534    Top,
535    /// The top left corner.
536    TopLeft,
537    /// Centred on the left.
538    Left,
539    /// Centred on the right.
540    Right,
541    /// The bottom left corner.
542    BottomLeft,
543    /// Centred along the bottom.
544    Bottom,
545    /// The bottom right corner.
546    BottomRight,
547    /// No legend.
548    None,
549}
550
551/// What a [`Chart`] does with a line, bar or area segment that has an end
552/// outside the axes' bounds.
553///
554/// ```
555/// use rich::Console;
556/// use rich_ext::chart::{Axis, Chart, Dataset, GraphType, Marker, OutOfBounds};
557///
558/// let console = Console::builder().width(5).color_system(None).build();
559/// // From (0, 0) to (8, 2): the second end is past the right edge.
560/// let line = Dataset::new([(0.0, 0.0), (8.0, 2.0)])
561///     .graph_type(GraphType::Line)
562///     .marker(Marker::Ascii('*'));
563/// let chart = Chart::new(vec![line])
564///     .x_axis(Axis::default().bounds(0.0, 4.0))
565///     .y_axis(Axis::default().bounds(0.0, 2.0))
566///     .width(5)
567///     .height(3);
568/// assert_eq!(console.render_export(&chart), "     \n  ***\n**   \n");
569/// // ratatui 0.29 drops it; only the point inside is drawn.
570/// let dropped = chart.out_of_bounds(OutOfBounds::Drop);
571/// assert_eq!(console.render_export(&dropped), "     \n     \n*    \n");
572/// ```
573#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
574pub enum OutOfBounds {
575    /// Draw the part inside the bounds (the default; ratatui's since
576    /// 0.30).
577    #[default]
578    Clip,
579    /// Leave the whole segment out, as ratatui 0.29 does. Points outside
580    /// are never drawn either way.
581    Drop,
582}
583
584/// How a [`Chart`] puts its datasets together where they share a cell.
585///
586/// ```
587/// use rich::Console;
588/// use rich_ext::chart::{Axis, Chart, Dataset, Layering, Marker};
589///
590/// let console = Console::builder().width(1).color_system(None).build();
591/// // Two Braille dots in one cell, from two datasets.
592/// let chart = Chart::new(vec![
593///     Dataset::new([(0.0, 1.0)]).marker(Marker::Braille),
594///     Dataset::new([(0.0, 0.0)]).marker(Marker::Braille),
595/// ])
596/// .x_axis(Axis::default().bounds(0.0, 1.0))
597/// .y_axis(Axis::default().bounds(0.0, 1.0))
598/// .width(1)
599/// .height(1);
600/// assert_eq!(console.render_export(&chart), "⡁\n");
601/// let layered = chart.layering(Layering::PerDataset);
602/// assert_eq!(console.render_export(&layered), "⡀\n");
603/// ```
604#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
605pub enum Layering {
606    /// Datasets with the same marker share the dots of a cell, which takes
607    /// the style of the last dataset to set one (the default).
608    #[default]
609    Merge,
610    /// Each dataset is drawn over the ones before it, as ratatui 0.29
611    /// draws a canvas per dataset: a cell a later dataset draws in shows
612    /// only its dots, in its style.
613    PerDataset,
614}
615
616/// Datasets on two axes, laid out as ratatui's `Chart`, so a ratatui app
617/// ports with the same look.
618///
619/// - The x axis's labels take the bottom row and its line (`─`) the row
620///   above; the y axis's labels take the left (at most a third of the
621///   width) and its line (`│`) the column after, meeting at `└`. An axis
622///   draws its line only when it has [`Labels`].
623/// - The datasets are drawn in order on a [`Canvas`](super::Canvas) filling
624///   the rest, each with its marker. Datasets with the same marker share
625///   the dots of a cell, which takes the later one's style;
626///   [`Layering::PerDataset`] draws each over the ones before. A line
627///   leaving the bounds is clipped at them ([`OutOfBounds`]).
628/// - The y axis's title sits on the plot's top row, from its left; the x
629///   axis's at the right end of the plot's bottom row.
630/// - The legend is a box listing the named datasets, each in its style, in
631///   a corner of the plot ([`LegendPosition::TopRight`] by default). It
632///   hides itself when it would take more than a share of the plot: a
633///   quarter of the width and a quarter of the height by default. Its box
634///   is in the chart's [`style`](Self::style), or else the x axis's, unless
635///   [`legend_style`](Self::legend_style) says otherwise, and it clears the
636///   plot under it unless [`legend_clear`](Self::legend_clear) is false.
637/// - It fills the height it is given, as ratatui's fills its area: a
638///   `Layout` region, a `Panel` with a height, an intuiTUIve node through
639///   `renderable(..)`. [`height`](Self::height) fixes it instead; given no
640///   height, it is 15 rows. It is as wide as the width it is given (or
641///   [`width`](Self::width)). As in ratatui, a short chart gives up the x
642///   axis line, then the x labels; a narrow one cuts the y labels to a
643///   third of the width; the titles and the legend show only when the
644///   plot has room for them.
645///
646/// ```
647/// use rich::Console;
648/// use rich_ext::chart::{Axis, Chart, Dataset, GraphType, Labels, Marker};
649///
650/// let console = Console::builder().width(24).color_system(None).build();
651/// let cpu = Dataset::new([(0.0, 0.0), (5.0, 4.0), (10.0, 1.0)])
652///     .name("cpu")
653///     .graph_type(GraphType::Line)
654///     .marker(Marker::Ascii('*'));
655/// let chart = Chart::new(vec![cpu])
656///     .x_axis(Axis::default().bounds(0.0, 10.0).title("t").labels(Labels::Auto(3)))
657///     .y_axis(Axis::default().bounds(0.0, 4.0).title("%").labels(Labels::Auto(3)))
658///     .hidden_legend_fraction(0.5, 0.5)
659///     .height(8);
660/// assert_eq!(
661///     console.render_export(&chart),
662///     concat!(
663///         "4│%        ***     ┌───┐\n",
664///         " │       **   **** │cpu│\n",
665///         " │     **         *└───┘\n",
666///         "2│   **               **\n",
667///         " │ **                   \n",
668///         "0│*                    t\n",
669///         " └──────────────────────\n",
670///         "  0         5         10\n",
671///     )
672/// );
673/// ```
674#[derive(Clone, Debug, PartialEq)]
675#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
676pub struct Chart {
677    datasets: Vec<Dataset>,
678    x_axis: Axis,
679    y_axis: Axis,
680    legend: LegendPosition,
681    hidden_legend: (f64, f64),
682    // Boxed strings keep a chart small enough for a `Facet`.
683    legend_style: Option<Box<str>>,
684    style: Option<Box<str>>,
685    legend_clear: bool,
686    out_of_bounds: OutOfBounds,
687    layering: Layering,
688    marker: Option<Marker>,
689    width: Option<usize>,
690    height: Option<usize>,
691    stacked: bool,
692}
693
694impl Default for Chart {
695    fn default() -> Self {
696        Self::new(Vec::new())
697    }
698}
699
700impl Chart {
701    /// A chart of `datasets`, drawn in order.
702    pub fn new(datasets: impl IntoIterator<Item = Dataset>) -> Self {
703        Chart {
704            datasets: datasets.into_iter().collect(),
705            x_axis: Axis::default(),
706            y_axis: Axis::default(),
707            legend: LegendPosition::TopRight,
708            hidden_legend: (0.25, 0.25),
709            legend_style: None,
710            style: None,
711            legend_clear: true,
712            out_of_bounds: OutOfBounds::Clip,
713            layering: Layering::Merge,
714            marker: None,
715            width: None,
716            height: None,
717            stacked: false,
718        }
719    }
720
721    /// Add a dataset, drawn after the others.
722    pub fn dataset(mut self, dataset: Dataset) -> Self {
723        self.datasets.push(dataset);
724        self
725    }
726
727    /// The datasets, in drawing order.
728    pub fn datasets(&self) -> &[Dataset] {
729        &self.datasets
730    }
731
732    /// The x axis.
733    pub fn x_axis(mut self, axis: Axis) -> Self {
734        self.x_axis = axis;
735        self
736    }
737
738    /// The y axis.
739    pub fn y_axis(mut self, axis: Axis) -> Self {
740        self.y_axis = axis;
741        self
742    }
743
744    /// Where the legend goes (default [`LegendPosition::TopRight`]).
745    pub fn legend(mut self, position: LegendPosition) -> Self {
746        self.legend = position;
747        self
748    }
749
750    /// The largest share of the plot's width and height the legend may
751    /// take before it hides (default a quarter of each). Each is clamped
752    /// to `0..=1`; NaN keeps the default.
753    pub fn hidden_legend_fraction(mut self, width: f64, height: f64) -> Self {
754        let share = |v: f64| if v.is_nan() { 0.25 } else { v.clamp(0.0, 1.0) };
755        self.hidden_legend = (share(width), share(height));
756        self
757    }
758
759    /// The chart's own style, as ratatui's `Chart::style`: a theme key or a
760    /// definition put under every cell of the chart (a background fills
761    /// it), and the style of the legend's box unless
762    /// [`legend_style`](Self::legend_style) gives one. Without it the chart
763    /// has no style of its own and the legend's box takes the x axis's.
764    /// `"none"` is the terminal's own colours, so the legend's box is
765    /// unstyled too: [`legend_unstyled`](Self::legend_unstyled) says the
766    /// same by name.
767    ///
768    /// ```
769    /// use rich::Console;
770    /// use rich_ext::chart::{Axis, Chart, Dataset, Marker};
771    ///
772    /// let console = Console::builder().width(6).force_terminal(true).build();
773    /// let chart = Chart::new(vec![Dataset::new([]).name("a")])
774    ///     .x_axis(Axis::default().bounds(0.0, 1.0))
775    ///     .y_axis(Axis::default().bounds(0.0, 1.0))
776    ///     .hidden_legend_fraction(1.0, 1.0)
777    ///     .style("on blue")
778    ///     .width(6)
779    ///     .height(3);
780    /// let out = console.render_export(&chart);
781    /// // Every cell on blue, the legend's box too.
782    /// assert!(out.starts_with("\u{1b}[44m"));
783    /// ```
784    pub fn style(mut self, style: impl Into<String>) -> Self {
785        self.style = Some(style.into().into_boxed_str());
786        self
787    }
788
789    /// The style of the legend's box: a theme key or a definition (default:
790    /// the chart's [`style`](Self::style) when it has one, else the x
791    /// axis's style). The names keep their datasets' styles.
792    ///
793    /// ```
794    /// use rich_ext::chart::{Chart, Dataset};
795    ///
796    /// let chart = Chart::new(vec![Dataset::new([(0.0, 1.0)]).name("left")])
797    ///     .legend_style("dim");
798    /// ```
799    pub fn legend_style(mut self, style: impl Into<String>) -> Self {
800        self.legend_style = Some(style.into().into_boxed_str());
801        self
802    }
803
804    /// The legend's box in the terminal's own colours, not the x axis's
805    /// style: a ratatui chart whose legend style is the default (scope-tui
806    /// sets `legend_style(Style::default())`). The same as
807    /// [`legend_style`](Self::legend_style)`("none")`, by name; the names
808    /// keep their datasets' styles.
809    ///
810    /// ```
811    /// use rich::Console;
812    /// use rich_ext::chart::{Axis, Chart, Dataset};
813    ///
814    /// let console = Console::builder().width(6).force_terminal(true).build();
815    /// let chart = Chart::new(vec![Dataset::new([]).name("a").style("none")])
816    ///     .x_axis(Axis::default().bounds(0.0, 1.0).style("red"))
817    ///     .y_axis(Axis::default().bounds(0.0, 1.0))
818    ///     .hidden_legend_fraction(1.0, 1.0)
819    ///     .width(6)
820    ///     .height(3);
821    /// // The box in the x axis's red, then in no colour at all.
822    /// assert!(console.render_export(&chart).contains("\u{1b}[31m┌"));
823    /// assert!(!console.render_export(&chart.legend_unstyled()).contains('\u{1b}'));
824    /// ```
825    pub fn legend_unstyled(self) -> Self {
826        self.legend_style("none")
827    }
828
829    /// Whether the legend clears the plot under it (default true). When
830    /// false, as in ratatui, what the datasets drew inside the box stays,
831    /// unstyled, around the names.
832    ///
833    /// ```
834    /// use rich::Console;
835    /// use rich_ext::chart::{Axis, Chart, Dataset, Marker};
836    ///
837    /// let console = Console::builder().width(8).color_system(None).build();
838    /// let wall = (0..8).flat_map(|x| (0..4).map(move |y| (f64::from(x), f64::from(y))));
839    /// let dots = Dataset::new(wall).name("a").marker(Marker::Ascii('.'));
840    /// let chart = Chart::new(vec![dots, Dataset::new([]).name("bb")])
841    ///     .x_axis(Axis::default().bounds(0.0, 7.0))
842    ///     .y_axis(Axis::default().bounds(0.0, 3.0))
843    ///     .hidden_legend_fraction(1.0, 1.0)
844    ///     .width(8)
845    ///     .height(4);
846    /// assert_eq!(
847    ///     console.render_export(&chart),
848    ///     "....┌──┐\n....│a │\n....│bb│\n....└──┘\n"
849    /// );
850    /// assert_eq!(
851    ///     console.render_export(&chart.legend_clear(false)),
852    ///     "....┌──┐\n....│a.│\n....│bb│\n....└──┘\n"
853    /// );
854    /// ```
855    pub fn legend_clear(mut self, clear: bool) -> Self {
856        self.legend_clear = clear;
857        self
858    }
859
860    /// What to do with a segment that has an end outside the bounds
861    /// (default [`OutOfBounds::Clip`]).
862    pub fn out_of_bounds(mut self, out_of_bounds: OutOfBounds) -> Self {
863        self.out_of_bounds = out_of_bounds;
864        self
865    }
866
867    /// How datasets that share a cell are put together (default
868    /// [`Layering::Merge`]).
869    pub fn layering(mut self, layering: Layering) -> Self {
870        self.layering = layering;
871        self
872    }
873
874    /// The marker the axes' [grid lines](Axis::grid) are drawn with when
875    /// an axis gives none ([`Axis::grid_marker`]). Without it, the first
876    /// dataset's marker, or [`Marker::Braille`] when there is no dataset,
877    /// so a chart's grid looks the same before its first data as after.
878    ///
879    /// ```
880    /// use rich::Console;
881    /// use rich_ext::chart::{Axis, Chart, Dataset, Marker};
882    ///
883    /// let console = Console::builder().width(4).color_system(None).build();
884    /// let empty = Chart::new(Vec::<Dataset>::new())
885    ///     .x_axis(Axis::default().bounds(0.0, 1.0))
886    ///     .y_axis(Axis::default().bounds(-1.0, 1.0).grid([0.0]))
887    ///     .width(4)
888    ///     .height(1);
889    /// // Braille with no datasets, not dots.
890    /// assert_eq!(console.render_export(&empty), "⠒⠒⠒⠒\n");
891    /// let ascii = empty.marker(Marker::Ascii('-'));
892    /// assert_eq!(console.render_export(&ascii), "----\n");
893    /// ```
894    pub fn marker(mut self, marker: Marker) -> Self {
895        self.marker = Some(marker);
896        self
897    }
898
899    /// Width in cells (default: the width it is given).
900    pub fn width(mut self, width: usize) -> Self {
901        self.width = Some(width);
902        self
903    }
904
905    /// Height in rows, axes and labels included (at most 65535), whatever
906    /// height it is given. Without it, the chart fills the height it is
907    /// given, or takes 15 rows.
908    pub fn height(mut self, rows: usize) -> Self {
909        self.height = Some(rows.min(MAX_HEIGHT));
910        self
911    }
912
913    fn grid(&self, console: &Console, options: &ConsoleOptions) -> CellGrid {
914        let w = self
915            .width
916            .map_or(options.max_width, |v| v.min(options.max_width))
917            .min(MAX_HEIGHT);
918        let h = self
919            .height
920            .or(options.height)
921            .unwrap_or(DEFAULT_HEIGHT)
922            .min(MAX_HEIGHT);
923        let mut grid = CellGrid::new(w, h);
924        if w == 0 || h == 0 {
925            return grid;
926        }
927        let ascii = is_ascii(console, options);
928        let points = || self.datasets.iter().flat_map(|d| d.points.iter());
929        let x = self.x_axis.resolve(points().map(|p| p.0), console, ascii);
930        let y = self.y_axis.resolve(points().map(|p| p.1), console, ascii);
931        let style_of = |spec: &Option<String>, default: &str| {
932            spec_style(console, spec.as_deref().unwrap_or(default))
933        };
934        let x_style = style_of(&self.x_axis.style, "chart.axis");
935        let y_style = style_of(&self.y_axis.style, "chart.axis");
936        let (h_line, v_line, corner) = if ascii {
937            ('-', '|', '+')
938        } else {
939            ('─', '│', '└')
940        };
941
942        // The layout, as ratatui's: from the bottom row up and the left
943        // column across.
944        let mut bottom = h - 1;
945        // A label row only when there are labels to put in it.
946        let label_x = (!x.texts().is_empty() && bottom > 0).then(|| {
947            bottom -= 1;
948            bottom + 1
949        });
950        let x_align = self.x_axis.labels_align;
951        let mut left = left_width(w, &x, &y, x_align);
952        let axis_x = (x.has_labels() && bottom > 0).then(|| {
953            bottom -= 1;
954            bottom + 1
955        });
956        let axis_y = (y.has_labels() && left + 1 < w).then(|| {
957            left += 1;
958            left - 1
959        });
960        let (gx, gw, gh) = (left, w - left, bottom + 1);
961        let marker = self
962            .datasets
963            .first()
964            .map_or(Marker::Dot, |d| d.marker)
965            .resolve(ascii);
966        let (per_x, per_y) = marker.per_cell();
967
968        // Labels.
969        if let Some(row) = label_x {
970            match &x.labels {
971                Placed::At(labels) => {
972                    let mut free = 0;
973                    for (value, label) in labels {
974                        let len = label.width();
975                        if len > w {
976                            continue;
977                        }
978                        let tick = gx + x.cell(*value, gw, per_x);
979                        let start = match x_align {
980                            Some(HorizontalAlign::Left) => tick,
981                            Some(HorizontalAlign::Right) => (tick + 1).saturating_sub(len),
982                            _ => tick.saturating_sub(len / 2),
983                        }
984                        .min(w - len);
985                        if start < free {
986                            continue;
987                        }
988                        put_runs(&mut grid, row, start, w, label, &x_style);
989                        free = start + len + 1;
990                    }
991                }
992                Placed::Even(labels) if labels.len() >= 2 => {
993                    let n = labels.len();
994                    let between = gw / n;
995                    let put = |grid: &mut CellGrid, col, width, label: &Label, align| {
996                        put_aligned(grid, row, col, width, label, &x_style, align);
997                    };
998                    // The first label against the y axis line, as ratatui
999                    // places it for each alignment.
1000                    let (col, width, align) = match x_align {
1001                        Some(HorizontalAlign::Center) => {
1002                            (0, gx + between.min(labels[0].width()), Align::Centre)
1003                        }
1004                        Some(HorizontalAlign::Right) => {
1005                            (gx.saturating_sub(1), between + 1, Align::Left)
1006                        }
1007                        _ => (0, gx, Align::Right),
1008                    };
1009                    put(&mut grid, col, width, &labels[0], align);
1010                    for (i, label) in labels[1..n - 1].iter().enumerate() {
1011                        let col = gx + (i + 1) * between + 1;
1012                        put(
1013                            &mut grid,
1014                            col,
1015                            between.saturating_sub(1),
1016                            label,
1017                            Align::Centre,
1018                        );
1019                    }
1020                    put(
1021                        &mut grid,
1022                        w - between,
1023                        between,
1024                        &labels[n - 1],
1025                        Align::Right,
1026                    );
1027                }
1028                _ => {}
1029            }
1030        }
1031        if y.has_labels() {
1032            let width = gx.saturating_sub(1);
1033            let align = self.y_axis.labels_align.map(Align::from);
1034            match &y.labels {
1035                Placed::At(labels) => {
1036                    let mut used = vec![false; gh];
1037                    for (value, label) in labels {
1038                        let row = gh - 1 - y.cell(*value, gh, per_y);
1039                        if !std::mem::replace(&mut used[row], true) {
1040                            let align = align.unwrap_or(Align::Right);
1041                            put_aligned(&mut grid, row, 0, width, label, &y_style, align);
1042                        }
1043                    }
1044                }
1045                Placed::Even(labels) => {
1046                    let n = labels.len();
1047                    for (i, label) in labels.iter().enumerate() {
1048                        let dy = if n > 1 { i * (gh - 1) / (n - 1) } else { 0 };
1049                        if dy < gh {
1050                            let row = gh - 1 - dy;
1051                            let align = align.unwrap_or(Align::Left);
1052                            put_aligned(&mut grid, row, 0, width, label, &y_style, align);
1053                        }
1054                    }
1055                }
1056                Placed::None => {}
1057            }
1058        }
1059
1060        // Axis lines.
1061        let (x_id, y_id) = (grid.intern(x_style.as_ref()), grid.intern(y_style.as_ref()));
1062        if let Some(row) = axis_x {
1063            for col in gx..w {
1064                grid.set_char(row, col, h_line, x_id);
1065            }
1066        }
1067        if let Some(col) = axis_y {
1068            for row in 0..gh {
1069                grid.set_char(row, col, v_line, y_id);
1070            }
1071            if let Some(row) = axis_x {
1072                grid.set_char(row, col, corner, x_id);
1073            }
1074        }
1075
1076        // The datasets.
1077        let view = View {
1078            x: [x.scale.min(), x.scale.max()],
1079            y: [y.scale.min(), y.scale.max()],
1080            width: gw,
1081            height: gh,
1082            marker,
1083            ascii,
1084        };
1085        raster_into(&self.ops(&x, &y), &view, console, &mut grid, gx);
1086
1087        // Titles.
1088        let x_title = x.title.as_ref().map(|t| (t, cells(t)));
1089        let x_title = x_title
1090            .filter(|(_, tw)| *tw < gw && gh > 2)
1091            .map(|(title, tw)| {
1092                let col = gx + gw - tw;
1093                let style = style_of(&self.x_axis.title_style, "chart.label");
1094                grid.put_text(gh - 1, col, title, style, w);
1095                (gh - 1, col, col + tw)
1096            });
1097        let y_title = y.title.as_ref().map(|t| (t, cells(t)));
1098        let y_title = y_title
1099            .filter(|(_, tw)| tw + 1 < gw && gh > 2)
1100            .map(|(title, tw)| {
1101                let style = style_of(&self.y_axis.title_style, "chart.label");
1102                grid.put_text(0, gx, title, style, w);
1103                (0, gx, gx + tw)
1104            });
1105
1106        // The legend.
1107        let named: Vec<(String, Option<Style>)> = self
1108            .datasets
1109            .iter()
1110            .enumerate()
1111            .filter(|(_, d)| !d.name.is_empty())
1112            .map(|(i, d)| {
1113                let name = if ascii && !d.name.is_ascii() {
1114                    ascii_text(&d.name)
1115                } else {
1116                    d.name.clone()
1117                };
1118                (name, self.dataset_style(console, i))
1119            })
1120            .collect();
1121        let inner = named.iter().map(|(n, _)| cells(n)).max().unwrap_or(0);
1122        let (lw, lh) = (inner + 2, named.len() + 2);
1123        let max_w = (gw as f64 * self.hidden_legend.0).round() as usize;
1124        let max_h = (gh as f64 * self.hidden_legend.1).round() as usize;
1125        if inner > 0 && lw <= max_w && lh <= max_h {
1126            let area = Area { gx, gw, gh };
1127            if let Some((top, col)) = area.legend(self.legend, lw, lh, x_title, y_title) {
1128                let border = if ascii {
1129                    ['+', '+', '+', '+', '-', '|']
1130                } else {
1131                    ['┌', '┐', '└', '┘', '─', '│']
1132                };
1133                let style = match (self.legend_style.as_deref(), self.style.as_deref()) {
1134                    (Some(spec), _) | (None, Some(spec)) => spec_style(console, spec),
1135                    (None, None) => x_style.clone(),
1136                };
1137                let box_ = LegendBox {
1138                    at: (top, col),
1139                    inner,
1140                    border,
1141                    style,
1142                    clear: self.legend_clear,
1143                };
1144                box_.draw(&mut grid, &named);
1145            }
1146        }
1147        if let Some(style) = self.style.as_deref().and_then(|s| spec_style(console, s)) {
1148            grid.underlay(&style);
1149        }
1150        grid
1151    }
1152
1153    /// The style of dataset `index`.
1154    fn dataset_style(&self, console: &Console, index: usize) -> Option<Style> {
1155        spec_style(console, &self.dataset_spec(index))
1156    }
1157
1158    fn dataset_spec(&self, index: usize) -> String {
1159        self.datasets[index]
1160            .style
1161            .clone()
1162            .unwrap_or_else(|| series_key(index))
1163    }
1164
1165    /// The datasets as canvas steps, in the axes' mapped terms.
1166    fn ops(&self, x: &Resolved, y: &Resolved) -> Vec<Op> {
1167        let within = |v: f64, s: &Scale| {
1168            let (a, b) = (s.min(), s.max());
1169            a.min(b) <= v && v <= a.max(b)
1170        };
1171        let inside = |(px, py): (f64, f64)| within(px, &x.scale) && within(py, &y.scale);
1172        // Whether a segment from `a` to `b` is drawn (clipped) at all.
1173        let keep = |a: (f64, f64), b: (f64, f64)| {
1174            self.out_of_bounds == OutOfBounds::Clip || (inside(a) && inside(b))
1175        };
1176        let mut ops = Vec::new();
1177        // The grid lines first, under the datasets: the y axis's, then the
1178        // x axis's, each its own layer when the datasets are (scope-tui
1179        // lists its reference lines in that order).
1180        let fallback = self
1181            .marker
1182            .or(self.datasets.first().map(|d| d.marker))
1183            .unwrap_or(Marker::Braille);
1184        let lines = |axis: &Axis| {
1185            let grid = axis.grid.as_deref();
1186            let spec = grid
1187                .and_then(|g| g.style.clone())
1188                .or_else(|| axis.style.clone())
1189                .unwrap_or_else(|| "chart.axis".into());
1190            let marker = grid.and_then(|g| g.marker).unwrap_or(fallback);
1191            (spec, marker)
1192        };
1193        let (left, right) = (x.scale.min(), x.scale.max());
1194        let (bottom, top) = (y.scale.min(), y.scale.max());
1195        let (y_spec, y_marker) = lines(&self.y_axis);
1196        let y_spec: super::plot_canvas::Spec = y_spec.into();
1197        let (x_spec, x_marker) = lines(&self.x_axis);
1198        let x_spec: super::plot_canvas::Spec = x_spec.into();
1199        let across = y.grid(&self.y_axis).into_iter().map(|at| {
1200            let line = Op::Line((left, at), (right, at), y_spec.clone());
1201            (line, y_marker)
1202        });
1203        let up = x.grid(&self.x_axis).into_iter().map(|at| {
1204            let line = Op::Line((at, bottom), (at, top), x_spec.clone());
1205            (line, x_marker)
1206        });
1207        let mut marker = None;
1208        for (line, line_marker) in across.chain(up) {
1209            if marker != Some(line_marker) {
1210                ops.push(Op::Marker(line_marker));
1211                marker = Some(line_marker);
1212            }
1213            ops.push(line);
1214            if self.layering == Layering::PerDataset {
1215                ops.push(Op::Layer);
1216            }
1217        }
1218        for (nth, index) in self.draw_order().into_iter().enumerate() {
1219            let dataset = &self.datasets[index];
1220            if nth > 0 && self.layering == Layering::PerDataset {
1221                ops.push(Op::Layer);
1222            }
1223            let style: super::plot_canvas::Spec = self.dataset_spec(index).into();
1224            let mapped: Vec<Option<(f64, f64)>> = dataset
1225                .points
1226                .iter()
1227                .map(|&(px, py)| Some((self.x_axis.map(px)?, self.y_axis.map(py)?)))
1228                .collect();
1229            let shown = || mapped.iter().flatten().copied();
1230            ops.push(Op::Marker(dataset.marker));
1231            ops.push(Op::Points(shown().collect(), style.clone()));
1232            match dataset.graph_type {
1233                GraphType::Scatter => {}
1234                GraphType::Line => {
1235                    for pair in mapped.windows(2) {
1236                        if let [Some(a), Some(b)] = pair
1237                            && keep(*a, *b)
1238                        {
1239                            ops.push(Op::Line(*a, *b, style.clone()));
1240                        }
1241                    }
1242                }
1243                GraphType::Bar => {
1244                    let base = match self.y_axis.scale {
1245                        AxisScale::Linear => 0.0,
1246                        AxisScale::Log => y.scale.min(),
1247                    };
1248                    for (px, py) in shown().filter(|&p| keep((p.0, base), p)) {
1249                        ops.push(Op::Line((px, base), (px, py), style.clone()));
1250                    }
1251                }
1252                GraphType::Area => {
1253                    let level = self.y_axis.map(dataset.fill_to).unwrap_or(y.scale.min());
1254                    for pair in mapped.windows(2) {
1255                        if let [Some(a), Some(b)] = pair
1256                            && keep(*a, *b)
1257                        {
1258                            ops.push(Op::Area(*a, *b, level, style.clone()));
1259                        }
1260                    }
1261                }
1262            }
1263        }
1264        ops
1265    }
1266}
1267
1268/// How wide the labels left of the y axis line are, as ratatui works it
1269/// out: the widest y label, or as much of the first x label as hangs left
1270/// of the plot, whichever is wider, and at most a third of `width`.
1271fn left_width(width: usize, x: &Resolved, y: &Resolved, x_align: Option<HorizontalAlign>) -> usize {
1272    let mut widest = y.texts().iter().map(|l| l.width()).max().unwrap_or(0);
1273    if let Some(first) = x.texts().first() {
1274        let first = first.width();
1275        let hangs = match (&x.labels, x_align) {
1276            // The label ends under the y axis line, when there is one.
1277            (Placed::Even(_), None | Some(HorizontalAlign::Left)) => {
1278                first.saturating_sub(usize::from(y.has_labels()))
1279            }
1280            (Placed::Even(_), Some(HorizontalAlign::Right)) => 0,
1281            (_, Some(HorizontalAlign::Left)) => 0,
1282            (_, Some(HorizontalAlign::Right)) => first.saturating_sub(1),
1283            _ => first / 2,
1284        };
1285        widest = widest.max(hangs);
1286    }
1287    widest.min(width / 3)
1288}
1289
1290#[derive(Clone, Copy)]
1291enum Align {
1292    Left,
1293    Centre,
1294    Right,
1295}
1296
1297impl From<HorizontalAlign> for Align {
1298    fn from(align: HorizontalAlign) -> Align {
1299        match align {
1300            HorizontalAlign::Left => Align::Left,
1301            HorizontalAlign::Center => Align::Centre,
1302            HorizontalAlign::Right => Align::Right,
1303        }
1304    }
1305}
1306
1307/// `label`'s runs from `col` of `row`, over `base`, stopping before `end`.
1308fn put_runs(
1309    grid: &mut CellGrid,
1310    row: usize,
1311    col: usize,
1312    end: usize,
1313    label: &Label,
1314    base: &Option<Style>,
1315) {
1316    let mut at = col;
1317    for (text, style) in &label.runs {
1318        if at >= end {
1319            break;
1320        }
1321        grid.put_text(row, at, text, over(base, style), end);
1322        at += cells(text);
1323    }
1324}
1325
1326/// `label` in the `width` cells from `col` of `row`, aligned; when it is
1327/// wider it is cut on the right, or on the left when right-aligned.
1328fn put_aligned(
1329    grid: &mut CellGrid,
1330    row: usize,
1331    col: usize,
1332    width: usize,
1333    label: &Label,
1334    base: &Option<Style>,
1335    align: Align,
1336) {
1337    if width == 0 {
1338        return;
1339    }
1340    let len = label.width();
1341    let end = col + width;
1342    if len <= width {
1343        let offset = match align {
1344            Align::Left => 0,
1345            Align::Centre => (width - len) / 2,
1346            Align::Right => width - len,
1347        };
1348        put_runs(grid, row, col + offset, end, label, base);
1349    } else if let Align::Right = align {
1350        // The last `width` cells, each in its run's style.
1351        let units: Vec<(String, Option<Style>)> = label
1352            .runs
1353            .iter()
1354            .flat_map(|(text, style)| {
1355                let style = over(base, style);
1356                cell_units(text)
1357                    .into_iter()
1358                    .map(move |u| (u, style.clone()))
1359            })
1360            .collect();
1361        for (i, (unit, style)) in units[len - width..].iter().enumerate() {
1362            let unit = if i == 0 && unit.is_empty() { " " } else { unit };
1363            grid.set(row, col + i, unit.to_string(), style.clone());
1364        }
1365    } else {
1366        put_runs(grid, row, col, end, label, base);
1367    }
1368}
1369
1370/// The plot's place in the chart: its left column, width and height.
1371struct Area {
1372    gx: usize,
1373    gw: usize,
1374    gh: usize,
1375}
1376
1377/// A title's row and the columns it covers.
1378type TitleSpan = (usize, usize, usize);
1379
1380impl Area {
1381    /// The top left cell of a `lw` × `lh` legend at `position`, moved a row
1382    /// off a title it would cover; `None` when there is no room.
1383    fn legend(
1384        &self,
1385        position: LegendPosition,
1386        lw: usize,
1387        lh: usize,
1388        x_title: Option<TitleSpan>,
1389        y_title: Option<TitleSpan>,
1390    ) -> Option<(usize, usize)> {
1391        // Room for the legend and a row for each title, or none.
1392        self.gh
1393            .checked_sub(lh + usize::from(x_title.is_some()) + usize::from(y_title.is_some()))?;
1394        if lw > self.gw {
1395            return None;
1396        }
1397        let (right, centre) = (self.gx + self.gw - lw, self.gx + (self.gw - lw) / 2);
1398        let (middle, bottom) = ((self.gh - lh) / 2, self.gh - lh);
1399        let (mut row, col) = match position {
1400            LegendPosition::TopLeft => (0, self.gx),
1401            LegendPosition::Top => (0, centre),
1402            LegendPosition::TopRight => (0, right),
1403            LegendPosition::Left => (middle, self.gx),
1404            LegendPosition::Right => (middle, right),
1405            LegendPosition::BottomLeft => (bottom, self.gx),
1406            LegendPosition::Bottom => (bottom, centre),
1407            LegendPosition::BottomRight => (bottom, right),
1408            LegendPosition::None => return None,
1409        };
1410        let covers = |row: usize, (r, c0, c1): TitleSpan| {
1411            row <= r && r < row + lh && col < c1 && c0 < col + lw
1412        };
1413        if y_title.is_some_and(|t| covers(row, t)) {
1414            row += 1;
1415        }
1416        if x_title.is_some_and(|t| covers(row, t)) {
1417            row = row.saturating_sub(1);
1418        }
1419        Some((row, col))
1420    }
1421}
1422
1423/// The legend's box: its top left cell, the width inside it, its border
1424/// glyphs and style, and whether it clears what is under it.
1425struct LegendBox {
1426    at: (usize, usize),
1427    inner: usize,
1428    border: [char; 6],
1429    style: Option<Style>,
1430    clear: bool,
1431}
1432
1433impl LegendBox {
1434    /// The box, listing `named`, each name in its style.
1435    fn draw(&self, grid: &mut CellGrid, named: &[(String, Option<Style>)]) {
1436        let ((top, left), inner, border) = (self.at, self.inner, &self.style);
1437        let [tl, tr, bl, br, h, v] = self.border;
1438        let edge = |a: char, b: char| format!("{a}{}{b}", h.to_string().repeat(inner));
1439        let right = left + inner + 2;
1440        grid.put_text(top, left, &edge(tl, tr), border.clone(), right);
1441        for (i, (name, style)) in named.iter().enumerate() {
1442            let row = top + 1 + i;
1443            grid.set(row, left, v.to_string(), border.clone());
1444            for col in left + 1..right - 1 {
1445                if self.clear {
1446                    grid.set(row, col, " ".to_string(), None);
1447                } else {
1448                    grid.restyle(row, col, None);
1449                }
1450            }
1451            grid.put_text(row, left + 1, name, style.clone(), right - 1);
1452            grid.set(row, right - 1, v.to_string(), border.clone());
1453        }
1454        let bottom = top + named.len() + 1;
1455        grid.put_text(bottom, left, &edge(bl, br), border.clone(), right);
1456    }
1457}
1458
1459impl Axis {
1460    /// `x (t) from 0 to 10, log scale`: the axis's title and range (its
1461    /// bounds, else the data's), for accessible text.
1462    fn describe(&self, name: &str, data: impl Iterator<Item = f64>) -> String {
1463        let mut out = name.to_string();
1464        if let Some(title) = self.title.as_deref().filter(|t| !t.is_empty()) {
1465            out.push_str(&format!(" ({title})"));
1466        }
1467        let shown = data.filter(|v| self.map(*v).is_some());
1468        let span = match self.bounds {
1469            Some([a, b]) => Some((a.min(b), a.max(b))),
1470            None => super::describe::extent(shown),
1471        };
1472        if let Some((lo, hi)) = span {
1473            out.push(' ');
1474            out.push_str(&super::describe::range(lo, hi, self.label_format));
1475        }
1476        if self.scale == AxisScale::Log {
1477            out.push_str(", log scale");
1478        }
1479        out
1480    }
1481}
1482
1483/// `Chart, 2 datasets, x (t) from 0 to 10, y (%) from 0 to 4`, then a line
1484/// per dataset: its name (`dataset 2` without one), how it is drawn, and
1485/// its facts (`3 points, min 0, max 4, last 1, trend up`).
1486impl crate::a11y::AccessibleText for Chart {
1487    fn accessible_text(&self, _width: usize) -> String {
1488        use super::describe::{counted, series};
1489        if self.datasets.is_empty() {
1490            return "Chart, no datasets".to_string();
1491        }
1492        let points = || self.datasets.iter().flat_map(|d| d.points.iter());
1493        let view = self.stacked_view();
1494        let stacked_points = || view.datasets.iter().flat_map(|d| d.points.iter());
1495        let mut out = vec![format!(
1496            "Chart, {}, {}, {}{}",
1497            counted(self.datasets.len(), "dataset", "datasets"),
1498            self.x_axis.describe("x", points().map(|p| p.0)),
1499            view.y_axis.describe("y", stacked_points().map(|p| p.1)),
1500            if self.stacks() { ", areas stacked" } else { "" },
1501        )];
1502        for (index, d) in self.datasets.iter().enumerate() {
1503            let name = if d.name.is_empty() {
1504                format!("dataset {}", index + 1)
1505            } else {
1506                d.name.clone()
1507            };
1508            let kind = match d.graph_type {
1509                GraphType::Scatter => "points",
1510                GraphType::Line => "line",
1511                GraphType::Bar => "bars",
1512                GraphType::Area => "area",
1513            };
1514            let facts = series(&d.points, self.y_axis.label_format, ("point", "points"));
1515            out.push(format!("{name}, {kind}: {facts}"));
1516        }
1517        out.join("\n")
1518    }
1519}
1520
1521impl Chart {
1522    /// The y axis's bounds, else the finite y values' range: what a shared
1523    /// scale unifies.
1524    pub(crate) fn y_extent(&self) -> Option<(f64, f64)> {
1525        if let Some([a, b]) = self.y_axis.bounds {
1526            return Some((a.min(b), a.max(b)));
1527        }
1528        let view = self.stacked_view();
1529        let ys = view
1530            .datasets
1531            .iter()
1532            .flat_map(|d| d.points.iter().map(|p| p.1));
1533        super::describe::extent(ys.filter(|v| self.y_axis.map(*v).is_some()))
1534    }
1535
1536    /// This chart with its y axis's bounds set.
1537    pub(crate) fn with_y_bounds(mut self, min: f64, max: f64) -> Self {
1538        self.y_axis.bounds = Some([min, max]);
1539        self
1540    }
1541}
1542
1543impl crate::surface::Draw for Chart {
1544    fn draw(
1545        &self,
1546        console: &Console,
1547        options: &ConsoleOptions,
1548        screen: &mut crate::surface::Screen,
1549        rect: crate::surface::Rect,
1550    ) {
1551        let view = self.stacked_view();
1552        super::draw_grid(view.grid(console, options), options, screen, rect);
1553    }
1554}
1555
1556impl Renderable for Chart {
1557    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1558        let view = self.stacked_view();
1559        lines_to_segments(view.grid(console, options).into_lines(), options.max_width)
1560    }
1561
1562    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
1563        let max = self.width.unwrap_or(options.max_width);
1564        Measurement::new(max.min(12), max)
1565            .with_maximum(options.max_width)
1566            .normalize()
1567    }
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572    use super::*;
1573
1574    #[test]
1575    fn accessible_text_names_the_axes_and_each_dataset() {
1576        use crate::a11y::AccessibleText;
1577        let cpu = Dataset::new([(0.0, 0.0), (5.0, 4.0), (10.0, 1.0)])
1578            .name("cpu")
1579            .graph_type(GraphType::Line);
1580        let chart = Chart::new(vec![cpu, Dataset::new([(1.0, 2.0)])])
1581            .x_axis(Axis::default().bounds(0.0, 10.0).title("t"))
1582            .y_axis(Axis::default().bounds(0.0, 4.0).title("%"));
1583        assert_eq!(
1584            chart.accessible_text(80),
1585            "Chart, 2 datasets, x (t) from 0 to 10, y (%) from 0 to 4\n\
1586             cpu, line: 3 points, min 0, max 4, last 1, trend up\n\
1587             dataset 2, points: 1 point, min 2, max 2, last 2"
1588        );
1589        // Without bounds, the data's range; a log axis says so.
1590        let log = Chart::new(vec![
1591            Dataset::new([(1.0, 1.0), (2.0, 100.0)])
1592                .name("hz")
1593                .graph_type(GraphType::Area),
1594        ])
1595        .y_axis(Axis::default().scale(AxisScale::Log));
1596        assert_eq!(
1597            log.accessible_text(80),
1598            "Chart, 1 dataset, x from 1 to 2, y from 1 to 100, log scale\n\
1599             hz, area: 2 points, min 1, max 100, last 100, trend up"
1600        );
1601        assert_eq!(Chart::default().accessible_text(80), "Chart, no datasets");
1602    }
1603
1604    fn rows(chart: &Chart, width: usize) -> Vec<String> {
1605        let console = Console::builder().width(width).color_system(None).build();
1606        console
1607            .render_export(chart)
1608            .lines()
1609            .map(str::to_string)
1610            .collect()
1611    }
1612
1613    fn dot(points: &[(f64, f64)]) -> Dataset {
1614        Dataset::new(points.to_vec()).marker(Marker::Ascii('o'))
1615    }
1616
1617    #[test]
1618    fn log_axes_map_by_powers_of_ten() {
1619        let axis = Axis::default().bounds(1.0, 1000.0).scale(AxisScale::Log);
1620        assert_eq!(axis.map(100.0), Some(2.0));
1621        assert_eq!(axis.map(0.0), None);
1622        assert_eq!(axis.map(-5.0), None);
1623        assert_eq!(axis.map(f64::NAN), None);
1624        let labels = |axis: Axis| axis.label_values();
1625        assert_eq!(
1626            labels(axis.clone().labels(Labels::Auto(4))),
1627            vec![1.0, 10.0, 100.0, 1000.0]
1628        );
1629        // Thinned to about the count asked for.
1630        assert_eq!(
1631            labels(axis.clone().labels(Labels::Auto(2))),
1632            vec![1.0, 100.0]
1633        );
1634        // Fewer than two powers of ten: 2 and 5 times them too.
1635        let narrow = Axis::default()
1636            .bounds(2.0, 60.0)
1637            .scale(AxisScale::Log)
1638            .labels(Labels::Auto(6));
1639        assert_eq!(labels(narrow), vec![2.0, 5.0, 10.0, 20.0, 50.0]);
1640        // Values outside the bounds, or not positive, are left out.
1641        let given = axis.labels(Labels::Values(vec![-1.0, 0.0, 5.0, 5000.0]));
1642        assert_eq!(labels(given), vec![5.0]);
1643    }
1644
1645    #[test]
1646    fn a_log_axis_places_points_by_decade() {
1647        let chart = Chart::new(vec![dot(&[
1648            (1.0, 0.0),
1649            (10.0, 0.0),
1650            (100.0, 0.0),
1651            (0.0, 0.0),
1652        ])])
1653        .x_axis(
1654            Axis::default()
1655                .bounds(1.0, 100.0)
1656                .scale(AxisScale::Log)
1657                .labels(Labels::Auto(3)),
1658        )
1659        .y_axis(Axis::default().bounds(0.0, 1.0))
1660        .height(3);
1661        assert_eq!(
1662            rows(&chart, 11),
1663            vec!["           ", "o    o    o", "───────────", "1   10  100"][1..].to_vec()
1664        );
1665    }
1666
1667    #[test]
1668    fn text_labels_are_spread_as_ratatui_spreads_them() {
1669        let chart = Chart::new(Vec::new())
1670            .x_axis(Axis::default().labels(Labels::Text(vec!["a".into(), "b".into(), "c".into()])))
1671            .y_axis(Axis::default().labels(Labels::Text(vec!["lo".into(), "hi".into()])))
1672            .height(5);
1673        assert_eq!(
1674            rows(&chart, 14),
1675            vec![
1676                "hi│           ",
1677                "  │           ",
1678                "lo│           ",
1679                "  └───────────",
1680                "  a    b     c",
1681            ]
1682        );
1683    }
1684
1685    #[test]
1686    fn bars_rise_from_zero() {
1687        let chart = Chart::new(vec![
1688            dot(&[(0.0, 2.0), (2.0, 4.0), (4.0, -1.0)]).graph_type(GraphType::Bar),
1689        ])
1690        .x_axis(Axis::default().bounds(0.0, 4.0))
1691        .y_axis(Axis::default().bounds(-1.0, 4.0))
1692        .height(6)
1693        .width(5);
1694        assert_eq!(
1695            rows(&chart, 5),
1696            vec!["  o  ", "  o  ", "o o  ", "o o  ", "o o o", "    o"]
1697        );
1698    }
1699
1700    #[test]
1701    fn bars_on_a_log_axis_rise_from_the_bottom() {
1702        let chart = Chart::new(vec![dot(&[(1.0, 100.0)]).graph_type(GraphType::Bar)])
1703            .x_axis(Axis::default().bounds(0.0, 2.0))
1704            .y_axis(Axis::default().bounds(1.0, 1000.0).scale(AxisScale::Log))
1705            .height(4)
1706            .width(3);
1707        assert_eq!(rows(&chart, 3), vec!["   ", " o ", " o ", " o "]);
1708    }
1709
1710    #[test]
1711    fn lines_break_at_points_the_axes_cannot_show() {
1712        let chart = Chart::new(vec![
1713            dot(&[
1714                (0.0, 0.0),
1715                (1.0, 0.0),
1716                (f64::NAN, 0.0),
1717                (3.0, f64::INFINITY),
1718                (4.0, 0.0),
1719                (5.0, 0.0),
1720            ])
1721            .graph_type(GraphType::Line),
1722        ])
1723        .x_axis(Axis::default().bounds(0.0, 5.0))
1724        .y_axis(Axis::default().bounds(0.0, 1.0))
1725        .height(1)
1726        .width(6);
1727        assert_eq!(rows(&chart, 6), vec!["oo  oo"]);
1728    }
1729
1730    fn legend_chart(position: LegendPosition) -> Chart {
1731        Chart::new(vec![
1732            dot(&[]).name("one").style("red"),
1733            dot(&[]).name("two").style("blue"),
1734            dot(&[]),
1735        ])
1736        .legend(position)
1737        .hidden_legend_fraction(1.0, 1.0)
1738        .width(9)
1739        .height(6)
1740    }
1741
1742    #[test]
1743    fn legends_go_in_the_corner_asked_for() {
1744        assert_eq!(
1745            rows(&legend_chart(LegendPosition::TopRight), 9),
1746            vec![
1747                "    ┌───┐",
1748                "    │one│",
1749                "    │two│",
1750                "    └───┘",
1751                "         ",
1752                "         ",
1753            ]
1754        );
1755        assert_eq!(
1756            rows(&legend_chart(LegendPosition::BottomLeft), 9)[2..],
1757            ["┌───┐    ", "│one│    ", "│two│    ", "└───┘    "]
1758        );
1759        assert_eq!(
1760            rows(&legend_chart(LegendPosition::Right), 9)[1],
1761            "    ┌───┐"
1762        );
1763        assert!(
1764            rows(&legend_chart(LegendPosition::None), 9)
1765                .iter()
1766                .all(|r| r.trim().is_empty())
1767        );
1768    }
1769
1770    #[test]
1771    fn legends_keep_clear_of_titles() {
1772        let chart = legend_chart(LegendPosition::TopLeft)
1773            .y_axis(Axis::default().title("y"))
1774            .x_axis(Axis::default().title("x"));
1775        assert_eq!(
1776            rows(&chart, 9),
1777            vec![
1778                "y        ",
1779                "┌───┐    ",
1780                "│one│    ",
1781                "│two│    ",
1782                "└───┘    ",
1783                "        x",
1784            ]
1785        );
1786        // No room for both titles and the box: no legend.
1787        assert!(rows(&chart.height(5), 9).iter().all(|r| !r.contains('┌')));
1788    }
1789
1790    #[test]
1791    fn legends_hide_when_they_would_take_too_much() {
1792        let shown = |w: f64, h: f64| {
1793            let chart = legend_chart(LegendPosition::TopRight).hidden_legend_fraction(w, h);
1794            rows(&chart, 9).concat().contains("one")
1795        };
1796        assert!(shown(1.0, 1.0));
1797        // Five of nine columns, four of six rows.
1798        assert!(shown(0.56, 0.67));
1799        assert!(!shown(0.4, 1.0));
1800        assert!(!shown(1.0, 0.5));
1801        // The default, a quarter each way, hides it here.
1802        let default = legend_chart(LegendPosition::TopRight);
1803        let default = Chart {
1804            hidden_legend: (0.25, 0.25),
1805            ..default
1806        };
1807        assert!(!rows(&default, 9).concat().contains("one"));
1808    }
1809
1810    #[test]
1811    fn the_legend_names_take_their_dataset_styles() {
1812        let console = Console::builder().width(9).build();
1813        let options = console.options();
1814        let grid = legend_chart(LegendPosition::TopRight).grid(&console, &options);
1815        assert_eq!(grid.style_at(1, 5), Style::parse("red").ok());
1816        assert_eq!(grid.style_at(2, 5), Style::parse("blue").ok());
1817    }
1818
1819    #[test]
1820    fn later_datasets_win_a_cell() {
1821        let chart = Chart::new(vec![
1822            Dataset::new([(0.0, 0.0)]).style("red"),
1823            Dataset::new([(0.0, 0.0)]).style("blue"),
1824        ])
1825        .x_axis(Axis::default().bounds(0.0, 1.0))
1826        .y_axis(Axis::default().bounds(0.0, 1.0))
1827        .height(2)
1828        .width(2);
1829        let console = Console::builder().width(2).build();
1830        let grid = chart.grid(&console, &console.options());
1831        assert_eq!(grid.row_string(1), "• ");
1832        assert_eq!(grid.style_at(1, 0), Style::parse("blue").ok());
1833    }
1834
1835    /// A 5 × 3 chart on 0..=4 × 0..=2, one glyph a cell.
1836    fn unit_chart(datasets: Vec<Dataset>) -> Chart {
1837        Chart::new(datasets)
1838            .x_axis(Axis::default().bounds(0.0, 4.0))
1839            .y_axis(Axis::default().bounds(0.0, 2.0))
1840            .width(5)
1841            .height(3)
1842    }
1843
1844    #[test]
1845    fn segments_leaving_the_bounds_are_clipped_by_default() {
1846        let line = dot(&[(0.0, 0.0), (8.0, 2.0)]).graph_type(GraphType::Line);
1847        let chart = unit_chart(vec![line]);
1848        assert_eq!(chart.out_of_bounds, OutOfBounds::Clip);
1849        assert_eq!(rows(&chart, 5), vec!["     ", "  ooo", "oo   "]);
1850        let explicit = chart.clone().out_of_bounds(OutOfBounds::Clip);
1851        assert_eq!(rows(&explicit, 5), rows(&chart, 5));
1852    }
1853
1854    #[test]
1855    fn dropped_segments_leave_out_any_with_an_end_outside() {
1856        let drop = |d: Dataset| rows(&unit_chart(vec![d]).out_of_bounds(OutOfBounds::Drop), 5);
1857        // One end outside: only the point inside is drawn.
1858        let line = dot(&[(0.0, 0.0), (8.0, 2.0)]).graph_type(GraphType::Line);
1859        assert_eq!(drop(line), vec!["     ", "     ", "o    "]);
1860        // Both ends inside: drawn whole; the next segment, leaving, is not.
1861        let line = dot(&[(0.0, 0.0), (4.0, 2.0), (4.0, 9.0)]).graph_type(GraphType::Line);
1862        assert_eq!(drop(line), vec!["   oo", " oo  ", "o    "]);
1863        // A segment that crosses the plot with both ends outside.
1864        let across = dot(&[(-1.0, 1.0), (5.0, 1.0)]).graph_type(GraphType::Line);
1865        assert_eq!(drop(across.clone()), vec!["     "; 3]);
1866        assert_eq!(
1867            rows(&unit_chart(vec![across]), 5),
1868            vec!["     ", "ooooo", "     "]
1869        );
1870        // A bar from zero, when zero is below the bounds.
1871        let bar = |d: Dataset| {
1872            let chart = unit_chart(vec![d.graph_type(GraphType::Bar)])
1873                .y_axis(Axis::default().bounds(1.0, 3.0));
1874            (
1875                rows(&chart.clone(), 5),
1876                rows(&chart.out_of_bounds(OutOfBounds::Drop), 5),
1877            )
1878        };
1879        let (clipped, dropped) = bar(dot(&[(2.0, 3.0)]));
1880        assert_eq!(clipped, vec!["  o  "; 3]);
1881        assert_eq!(dropped, vec!["  o  ", "     ", "     "]);
1882        // An area segment, as a line.
1883        let area = dot(&[(0.0, 2.0), (8.0, 2.0)]).graph_type(GraphType::Area);
1884        assert_eq!(drop(area), vec!["o    ", "     ", "     "]);
1885    }
1886
1887    /// A Braille reference line along y = 1 and a trace crossing it.
1888    fn crossing() -> Chart {
1889        Chart::new(vec![
1890            Dataset::new([(0.0, 1.0), (4.0, 1.0)])
1891                .graph_type(GraphType::Line)
1892                .marker(Marker::Braille)
1893                .style("red"),
1894            Dataset::new([(2.0, 0.0), (2.0, 2.0)])
1895                .graph_type(GraphType::Line)
1896                .marker(Marker::Braille)
1897                .style("blue"),
1898        ])
1899        .x_axis(Axis::default().bounds(0.0, 4.0))
1900        .y_axis(Axis::default().bounds(0.0, 2.0))
1901        .width(3)
1902        .height(1)
1903    }
1904
1905    #[test]
1906    fn datasets_merge_their_dots_by_default() {
1907        let console = Console::builder().width(3).build();
1908        let chart = crossing();
1909        assert_eq!(chart.layering, Layering::Merge);
1910        let grid = chart.grid(&console, &console.options());
1911        // The trace's column in the middle cell, over the line's row.
1912        assert_eq!(grid.row_string(0), "⠒⡗⠒");
1913        assert_eq!(grid.style_at(0, 1), Style::parse("blue").ok());
1914        assert_eq!(grid.style_at(0, 0), Style::parse("red").ok());
1915    }
1916
1917    #[test]
1918    fn per_dataset_layering_draws_each_over_the_ones_before() {
1919        let console = Console::builder().width(3).build();
1920        let chart = crossing().layering(Layering::PerDataset);
1921        let grid = chart.grid(&console, &console.options());
1922        // The trace's cell shows only its dots: the line breaks there.
1923        assert_eq!(grid.row_string(0), "⠒⡇⠒");
1924        assert_eq!(grid.style_at(0, 1), Style::parse("blue").ok());
1925        assert_eq!(grid.style_at(0, 2), Style::parse("red").ok());
1926        // Cells only one dataset draws in are as merged.
1927        let merged = crossing().grid(&console, &console.options());
1928        assert_eq!(grid.row_string(0)[..3], merged.row_string(0)[..3]);
1929    }
1930
1931    #[test]
1932    fn the_legend_box_takes_the_x_axis_style_unless_given_its_own() {
1933        let console = Console::builder().width(9).build();
1934        let options = console.options();
1935        let chart = legend_chart(LegendPosition::TopRight).x_axis(Axis::default().style("magenta"));
1936        let grid = chart.grid(&console, &options);
1937        assert_eq!(grid.style_at(0, 4), Style::parse("magenta").ok());
1938        assert_eq!(grid.style_at(1, 4), Style::parse("magenta").ok());
1939        let own = chart.legend_style("green").grid(&console, &options);
1940        for (row, col) in [(0, 4), (0, 8), (1, 4), (1, 8), (3, 6)] {
1941            assert_eq!(own.style_at(row, col), Style::parse("green").ok());
1942        }
1943        // The names keep their datasets' styles.
1944        assert_eq!(own.style_at(1, 5), Style::parse("red").ok());
1945        assert_eq!(own.style_at(2, 5), Style::parse("blue").ok());
1946        // The text is the same either way.
1947        let text = |g: &CellGrid| (0..6).map(|r| g.row_string(r)).collect::<Vec<_>>();
1948        assert_eq!(text(&own), text(&grid));
1949    }
1950
1951    /// An 8 × 4 wall of red dots with a legend of "a" and "bb" over it.
1952    fn walled() -> Chart {
1953        let wall = (0..8).flat_map(|x| (0..4).map(move |y| (f64::from(x), f64::from(y))));
1954        let dots = Dataset::new(wall)
1955            .name("a")
1956            .marker(Marker::Ascii('.'))
1957            .style("red");
1958        Chart::new(vec![dots, Dataset::new([]).name("bb").style("blue")])
1959            .x_axis(Axis::default().bounds(0.0, 7.0))
1960            .y_axis(Axis::default().bounds(0.0, 3.0))
1961            .hidden_legend_fraction(1.0, 1.0)
1962            .width(8)
1963            .height(4)
1964    }
1965
1966    #[test]
1967    fn the_legend_clears_the_plot_under_it_by_default() {
1968        let chart = walled();
1969        assert!(chart.legend_clear);
1970        assert_eq!(
1971            rows(&chart, 8),
1972            vec!["....┌──┐", "....│a │", "....│bb│", "....└──┘"]
1973        );
1974        assert_eq!(rows(&chart.clone().legend_clear(true), 8), rows(&chart, 8));
1975    }
1976
1977    #[test]
1978    fn an_uncleared_legend_leaves_the_plot_unstyled_around_the_names() {
1979        let chart = walled().legend_clear(false);
1980        assert_eq!(
1981            rows(&chart, 8),
1982            vec!["....┌──┐", "....│a.│", "....│bb│", "....└──┘"]
1983        );
1984        let console = Console::builder().width(8).build();
1985        let grid = chart.grid(&console, &console.options());
1986        // The dot after "a" keeps its glyph, not its style.
1987        assert_eq!(grid.style_at(1, 6), None);
1988        assert_eq!(grid.style_at(1, 5), Style::parse("red").ok());
1989        assert_eq!(grid.style_at(1, 2), Style::parse("red").ok());
1990    }
1991
1992    #[test]
1993    fn bounds_default_to_the_data() {
1994        let chart = Chart::new(vec![dot(&[(10.0, 5.0), (20.0, 7.0)])])
1995            .height(2)
1996            .width(3);
1997        assert_eq!(rows(&chart, 3), vec!["  o", "o  "]);
1998    }
1999
2000    #[test]
2001    fn tiny_charts_drop_labels_then_titles() {
2002        let chart = Chart::new(vec![
2003            dot(&[(0.0, 0.0), (1.0, 1.0)])
2004                .name("series")
2005                .graph_type(GraphType::Line),
2006        ])
2007        .x_axis(
2008            Axis::default()
2009                .bounds(0.0, 1.0)
2010                .title("time")
2011                .labels(Labels::Auto(5)),
2012        )
2013        .y_axis(
2014            Axis::default()
2015                .bounds(0.0, 1.0)
2016                .title("value")
2017                .labels(Labels::Auto(5)),
2018        );
2019        for width in 0..12 {
2020            for height in 0..6 {
2021                let rendered = rows(&chart.clone().width(width).height(height), 12);
2022                assert!(rendered.len() <= height);
2023                assert!(rendered.iter().all(|r| rich::cells::cell_len(r) <= width));
2024            }
2025        }
2026        // Two rows: as in ratatui, the x labels keep their row and the x
2027        // axis line goes; the y labels are cut to a third of the width.
2028        assert_eq!(
2029            rows(&chart.clone().width(8).height(2), 8),
2030            vec![" 0│ooooo", "   0 0.8"]
2031        );
2032        // Two columns: no room for the y labels.
2033        assert_eq!(
2034            rows(&chart.clone().width(2).height(3), 2),
2035            vec!["│o", "└─", " 0"]
2036        );
2037        // Three rows of plot fit the titles; two do not.
2038        assert_eq!(
2039            rows(&chart.clone().width(12).height(5), 12),
2040            vec![
2041                "  1│value oo",
2042                "0.6│  oooo  ",
2043                "  0│oo  time",
2044                "   └────────",
2045                "    0  0.6 1",
2046            ]
2047        );
2048        let short = rows(&chart.width(12).height(4), 12).concat();
2049        assert!(!short.contains("time") && !short.contains("value"));
2050    }
2051
2052    #[test]
2053    fn ascii_consoles_draw_ascii_axes_and_legends() {
2054        let console = Console::builder()
2055            .width(10)
2056            .color_system(None)
2057            .ascii_only(true)
2058            .build();
2059        let chart = Chart::new(vec![
2060            Dataset::new([(0.0, 0.0), (1.0, 1.0)])
2061                .name("é")
2062                .graph_type(GraphType::Line),
2063        ])
2064        .x_axis(
2065            Axis::default()
2066                .bounds(0.0, 1.0)
2067                .labels(Labels::Values(vec![])),
2068        )
2069        .y_axis(
2070            Axis::default()
2071                .bounds(0.0, 1.0)
2072                .labels(Labels::Values(vec![])),
2073        )
2074        .hidden_legend_fraction(1.0, 1.0)
2075        .height(6);
2076        assert_eq!(
2077            console.render_export(&chart),
2078            concat!(
2079                "|      +-+\n",
2080                "|     *|?|\n",
2081                "|   ** +-+\n",
2082                "| **      \n",
2083                "|*        \n",
2084                "+---------\n",
2085            )
2086        );
2087    }
2088
2089    #[test]
2090    fn area_charts_fill_to_their_level() {
2091        let points = [(0.0, 1.0), (2.0, 3.0), (4.0, 2.0), (6.0, 4.0)];
2092        let area = |fill: f64| {
2093            Chart::new(vec![
2094                Dataset::new(points)
2095                    .graph_type(GraphType::Area)
2096                    .marker(Marker::Ascii('#'))
2097                    .fill_to(fill),
2098            ])
2099            .x_axis(Axis::default().bounds(0.0, 6.0))
2100            .y_axis(Axis::default().bounds(0.0, 4.0))
2101            .height(5)
2102            .width(7)
2103        };
2104        assert_eq!(
2105            rows(&area(0.0), 7),
2106            vec!["      #", "  #  ##", " ######", "#######", "#######"]
2107        );
2108        // Filled to a level above some points: the strips between the line
2109        // and the level, on either side of it.
2110        assert_eq!(
2111            rows(&area(2.0), 7),
2112            vec!["      #", "  #  ##", "#######", "#      ", "       "]
2113        );
2114        // On a log axis, a level it cannot show fills to the bottom.
2115        let log = Chart::new(vec![
2116            Dataset::new([(0.0, 10.0), (2.0, 10.0)])
2117                .graph_type(GraphType::Area)
2118                .marker(Marker::Ascii('#')),
2119        ])
2120        .x_axis(Axis::default().bounds(0.0, 2.0))
2121        .y_axis(Axis::default().bounds(1.0, 100.0).scale(AxisScale::Log))
2122        .height(3)
2123        .width(3);
2124        assert_eq!(rows(&log, 3), vec!["   ", "###", "###"]);
2125    }
2126
2127    #[test]
2128    fn markup_labels_keep_their_styles() {
2129        let chart = Chart::new(Vec::new())
2130            .x_axis(Axis::default().style("blue").labels(Labels::Markup(vec![
2131                "[bold]0[/]".into(),
2132                "5".into(),
2133                "[bold]10[/]".into(),
2134            ])))
2135            .height(3)
2136            .width(12);
2137        assert_eq!(
2138            rows(&chart, 12),
2139            vec!["            ", " ───────────", "0    5    10"]
2140        );
2141        let console = Console::builder().width(12).build();
2142        let grid = chart.grid(&console, &console.options());
2143        let bold_blue = Style::parse("bold blue").ok();
2144        assert_eq!(grid.style_at(2, 0), bold_blue);
2145        assert_eq!(grid.style_at(2, 5), Style::parse("blue").ok());
2146        assert_eq!(grid.style_at(2, 10), bold_blue);
2147        assert_eq!(grid.style_at(2, 11), bold_blue);
2148        // Markup that does not parse is written as it is.
2149        let broken = Chart::new(Vec::new())
2150            .y_axis(Axis::default().labels(Labels::Markup(vec!["[/x]".into(), "b".into()])))
2151            .height(2)
2152            .width(8);
2153        assert_eq!(rows(&broken, 8), vec!["b │     ", "[/│     "]);
2154    }
2155
2156    #[test]
2157    fn labels_align_as_asked() {
2158        let chart = |align: Option<HorizontalAlign>| {
2159            let mut y = Axis::default()
2160                .bounds(0.0, 100.0)
2161                .labels(Labels::Values(vec![0.0, 50.0, 100.0]));
2162            if let Some(align) = align {
2163                y = y.labels_align(align);
2164            }
2165            Chart::new(Vec::new())
2166                .y_axis(y.labels(Labels::Text(vec!["0".into(), "fifty".into(), "100".into()])))
2167                .height(3)
2168                .width(15)
2169        };
2170        // Text labels are left-aligned by default, as in ratatui.
2171        assert_eq!(
2172            rows(&chart(None), 15),
2173            vec!["100  │         ", "fifty│         ", "0    │         "]
2174        );
2175        assert_eq!(
2176            rows(&chart(Some(HorizontalAlign::Right)), 15),
2177            vec!["  100│         ", "fifty│         ", "    0│         "]
2178        );
2179        assert_eq!(
2180            rows(&chart(Some(HorizontalAlign::Center)), 15),
2181            vec![" 100 │         ", "fifty│         ", "  0  │         "]
2182        );
2183        // Values are right-aligned by default; left on asking.
2184        let values = |align: Option<HorizontalAlign>| {
2185            let mut y = Axis::default()
2186                .bounds(0.0, 100.0)
2187                .labels(Labels::Values(vec![0.0, 100.0]));
2188            if let Some(align) = align {
2189                y = y.labels_align(align);
2190            }
2191            rows(&Chart::new(Vec::new()).y_axis(y).height(2).width(12), 12)
2192        };
2193        assert_eq!(values(None), vec!["100│        ", "  0│        "]);
2194        assert_eq!(
2195            values(Some(HorizontalAlign::Left)),
2196            vec!["100│        ", "0  │        "]
2197        );
2198    }
2199
2200    #[test]
2201    fn the_first_x_label_sits_against_the_y_axis_as_asked() {
2202        let chart = |align: Option<HorizontalAlign>| {
2203            let mut x = Axis::default().labels(Labels::Text(vec!["start".into(), "end".into()]));
2204            if let Some(align) = align {
2205                x = x.labels_align(align);
2206            }
2207            let y = Axis::default().labels(Labels::Text(vec!["abc".into(), "d".into()]));
2208            rows(
2209                &Chart::new(Vec::new())
2210                    .x_axis(x)
2211                    .y_axis(y)
2212                    .height(4)
2213                    .width(16),
2214                16,
2215            )
2216        };
2217        // Left (the default): it ends under the y axis line.
2218        assert_eq!(chart(None)[2], "    └───────────");
2219        assert_eq!(chart(None)[3], "start        end");
2220        // Centre: about the line; right: it starts there.
2221        assert_eq!(chart(Some(HorizontalAlign::Center))[3], "  start      end");
2222        assert_eq!(chart(Some(HorizontalAlign::Right))[2], "   └────────────");
2223        assert_eq!(chart(Some(HorizontalAlign::Right))[3], "   start     end");
2224    }
2225
2226    #[test]
2227    fn a_chart_fills_the_height_it_is_given() {
2228        let chart = Chart::new(vec![
2229            dot(&[(0.0, 0.0), (1.0, 1.0)]).graph_type(GraphType::Line),
2230        ])
2231        .x_axis(Axis::default().bounds(0.0, 1.0).labels(Labels::Auto(2)))
2232        .y_axis(Axis::default().bounds(0.0, 1.0).labels(Labels::Auto(2)));
2233        let console = Console::builder().width(20).color_system(None).build();
2234        let lines = console.render_lines(&chart, &console.options().update_height(7), false);
2235        assert_eq!(lines.len(), 7);
2236        let text: Vec<String> = lines
2237            .iter()
2238            .map(|l| l.iter().map(|s| s.text.as_str()).collect())
2239            .collect();
2240        // The axes take the last two rows, the plot the five above.
2241        assert!(
2242            text[5].starts_with(" └") || text[5].contains('└'),
2243            "{text:?}"
2244        );
2245        assert!(text[0].contains('1') && text[4].contains('0'), "{text:?}");
2246        // An explicit height wins; no height at all is 15 rows.
2247        let fixed = chart.clone().height(4);
2248        let lines = console.render_lines(&fixed, &console.options().update_height(7), false);
2249        assert_eq!(lines.iter().filter(|l| !l.is_empty()).count(), 4);
2250        assert_eq!(console.render_export(&chart).lines().count(), 15);
2251    }
2252
2253    #[test]
2254    fn axes_without_labels_reserve_no_room_for_them() {
2255        let chart = Chart::new(vec![dot(&[(0.0, 0.0)])])
2256            .x_axis(
2257                Axis::default()
2258                    .bounds(0.0, 1.0)
2259                    .labels(Labels::Values(vec![])),
2260            )
2261            .y_axis(
2262                Axis::default()
2263                    .bounds(0.0, 1.0)
2264                    .labels(Labels::Text(vec![])),
2265            )
2266            .width(10)
2267            .height(4);
2268        assert_eq!(
2269            rows(&chart, 10),
2270            vec!["│         ", "│         ", "│o        ", "└─────────"]
2271        );
2272    }
2273
2274    #[test]
2275    fn grid_lines_go_under_the_datasets_and_within_the_bounds() {
2276        let trace = Dataset::new([(0.0, 1.0), (4.0, 1.0)])
2277            .graph_type(GraphType::Line)
2278            .marker(Marker::Ascii('o'));
2279        let chart = Chart::new(vec![trace])
2280            .x_axis(Axis::default().bounds(0.0, 4.0).grid([2.0, 9.0]))
2281            .y_axis(Axis::default().bounds(0.0, 2.0).grid([-1.0]))
2282            .layering(Layering::PerDataset)
2283            .width(5)
2284            .height(3);
2285        assert_eq!(rows(&chart, 5), ["  o  ", "ooooo", "  o  "]);
2286        // On a log axis, only positive values.
2287        let log = Chart::new(vec![Dataset::new([]).marker(Marker::Ascii('+'))])
2288            .x_axis(
2289                Axis::default()
2290                    .bounds(1.0, 100.0)
2291                    .scale(AxisScale::Log)
2292                    .grid([0.0, 10.0]),
2293            )
2294            .y_axis(Axis::default().bounds(0.0, 1.0))
2295            .width(5)
2296            .height(2);
2297        assert_eq!(rows(&log, 5), ["  +  ", "  +  "]);
2298    }
2299
2300    #[test]
2301    fn grid_lines_take_the_axis_style() {
2302        let console = Console::builder().width(3).build();
2303        let chart = Chart::new(vec![Dataset::new([]).marker(Marker::Ascii('.'))])
2304            .x_axis(Axis::default().bounds(0.0, 2.0).grid([1.0]).style("red"))
2305            .y_axis(Axis::default().bounds(0.0, 1.0))
2306            .width(3)
2307            .height(1);
2308        let grid = chart.grid(&console, &console.options());
2309        assert_eq!(grid.row_string(0), " . ");
2310        assert_eq!(grid.style_at(0, 1), Style::parse("red").ok());
2311    }
2312
2313    #[test]
2314    fn the_chart_style_is_under_everything_and_the_legends() {
2315        let console = Console::builder().width(8).build();
2316        let chart = Chart::new(vec![Dataset::new([]).name("a")])
2317            .x_axis(Axis::default().bounds(0.0, 1.0).style("red"))
2318            .y_axis(Axis::default().bounds(0.0, 1.0))
2319            .hidden_legend_fraction(1.0, 1.0)
2320            .width(8)
2321            .height(3);
2322        // No chart style: the legend's box in the x axis's.
2323        let grid = chart.grid(&console, &console.options());
2324        assert_eq!(grid.style_at(0, 5), Style::parse("red").ok());
2325        assert_eq!(grid.style_at(2, 0), None);
2326        let styled = chart.clone().style("on blue");
2327        let grid = styled.grid(&console, &console.options());
2328        assert_eq!(grid.style_at(0, 5), Style::parse("on blue").ok());
2329        assert_eq!(grid.style_at(2, 0), Style::parse("on blue").ok());
2330        // legend_style still wins.
2331        let grid = styled
2332            .legend_style("green")
2333            .grid(&console, &console.options());
2334        assert_eq!(grid.style_at(0, 5), Style::parse("on blue green").ok());
2335    }
2336    #[test]
2337    fn crossing_grid_lines_are_layers_of_their_own_the_y_axis_first() {
2338        // A cross at the centre of a 3 × 1 Braille plot: the x line (drawn
2339        // last) over the y line in the cell they share, as two datasets.
2340        let cross = |layering| {
2341            Chart::new(Vec::<Dataset>::new())
2342                .x_axis(Axis::default().bounds(-1.0, 1.0).grid([0.0]))
2343                .y_axis(Axis::default().bounds(-1.0, 1.0).grid([0.0]))
2344                .layering(layering)
2345                .width(3)
2346                .height(1)
2347        };
2348        let line = |points: [(f64, f64); 2]| {
2349            Dataset::new(points)
2350                .graph_type(GraphType::Line)
2351                .marker(Marker::Braille)
2352        };
2353        let datasets = Chart::new(vec![
2354            line([(-1.0, 0.0), (1.0, 0.0)]),
2355            line([(0.0, -1.0), (0.0, 1.0)]),
2356        ])
2357        .x_axis(Axis::default().bounds(-1.0, 1.0))
2358        .y_axis(Axis::default().bounds(-1.0, 1.0))
2359        .layering(Layering::PerDataset)
2360        .width(3)
2361        .height(1);
2362        let layered = rows(&cross(Layering::PerDataset), 3);
2363        assert_eq!(layered, rows(&datasets, 3));
2364        assert_eq!(layered, ["⠒⡇⠒"]);
2365        // Merged, the two lines share the cell's dots.
2366        assert_eq!(rows(&cross(Layering::Merge), 3), ["⠒⡗⠒"]);
2367    }
2368
2369    #[test]
2370    fn a_grid_with_no_datasets_is_braille_unless_told() {
2371        let empty = Chart::new(Vec::<Dataset>::new())
2372            .x_axis(Axis::default().bounds(0.0, 1.0))
2373            .y_axis(Axis::default().bounds(-1.0, 1.0).grid([0.0]))
2374            .width(3)
2375            .height(1);
2376        assert_eq!(rows(&empty, 3), ["⠒⠒⠒"]);
2377        // The axis's marker wins over the chart's, the chart's over the
2378        // first dataset's.
2379        let dotted = Chart::new(vec![Dataset::new([]).marker(Marker::Dot)])
2380            .y_axis(Axis::default().bounds(-1.0, 1.0).grid([0.0]))
2381            .x_axis(Axis::default().bounds(0.0, 1.0))
2382            .width(3)
2383            .height(1);
2384        assert_eq!(rows(&dotted, 3), ["•••"]);
2385        assert_eq!(rows(&dotted.clone().marker(Marker::Ascii('=')), 3), ["==="]);
2386        let own = dotted.marker(Marker::Ascii('=')).y_axis(
2387            Axis::default()
2388                .bounds(-1.0, 1.0)
2389                .grid([0.0])
2390                .grid_marker(Marker::Ascii('~')),
2391        );
2392        assert_eq!(rows(&own, 3), ["~~~"]);
2393    }
2394
2395    #[test]
2396    fn grid_lines_take_their_own_style_over_the_axis_style() {
2397        let console = Console::builder().width(3).build();
2398        let chart = Chart::new(vec![Dataset::new([]).marker(Marker::Ascii('.'))])
2399            .x_axis(
2400                Axis::default()
2401                    .bounds(0.0, 2.0)
2402                    .grid([1.0])
2403                    .style("red")
2404                    .grid_style("blue"),
2405            )
2406            .y_axis(Axis::default().bounds(0.0, 1.0))
2407            .width(3)
2408            .height(1);
2409        let grid = chart.grid(&console, &console.options());
2410        assert_eq!(grid.row_string(0), " . ");
2411        assert_eq!(grid.style_at(0, 1), Style::parse("blue").ok());
2412        // A style or a marker alone draws no line.
2413        let none = Chart::new(Vec::<Dataset>::new())
2414            .x_axis(Axis::default().bounds(0.0, 2.0).grid_style("blue"))
2415            .y_axis(Axis::default().bounds(0.0, 1.0))
2416            .width(3)
2417            .height(1);
2418        assert_eq!(rows(&none, 3), ["   "]);
2419    }
2420
2421    #[test]
2422    fn an_unstyled_legend_is_the_none_style_by_name() {
2423        let console = Console::builder().width(9).build();
2424        let options = console.options();
2425        let chart = legend_chart(LegendPosition::TopRight).x_axis(Axis::default().style("magenta"));
2426        let plain = chart.clone().legend_unstyled().grid(&console, &options);
2427        let none = chart.style("none").grid(&console, &options);
2428        for (row, col) in [(0, 4), (1, 4), (3, 6)] {
2429            assert_eq!(
2430                plain.style_at(row, col).unwrap_or_default(),
2431                Style::default()
2432            );
2433            assert_eq!(plain.style_at(row, col), none.style_at(row, col));
2434        }
2435    }
2436}