Skip to main content

altui_core/widgets/
chart.rs

1use std::{borrow::Cow, cmp::max};
2
3use unicode_width::UnicodeWidthStr;
4
5use crate::layout::Alignment;
6use crate::{
7    buffer::Buffer,
8    layout::{Constraint, Rect},
9    style::{Color, Style},
10    symbols,
11    text::{Span, Spans},
12    widgets::{
13        canvas::{Canvas, Line, Points},
14        Block, Borders, Widget,
15    },
16};
17
18/// An X or Y axis for the chart widget
19#[derive(Debug, Clone)]
20pub struct Axis<'a> {
21    /// Title displayed next to axis end
22    title: Option<Spans<'a>>,
23    /// Bounds for the axis (all data points outside these limits will not be represented)
24    bounds: [f64; 2],
25    /// A list of labels to put to the left or below the axis
26    labels: Option<Vec<Span<'a>>>,
27    /// The style used to draw the axis itself
28    style: Style,
29    /// The alignment of the labels of the Axis
30    labels_alignment: Alignment,
31}
32
33impl<'a> Default for Axis<'a> {
34    fn default() -> Axis<'a> {
35        Axis {
36            title: None,
37            bounds: [0.0, 0.0],
38            labels: None,
39            style: Default::default(),
40            labels_alignment: Alignment::Left,
41        }
42    }
43}
44
45impl<'a> Axis<'a> {
46    pub fn title<T>(mut self, title: T) -> Axis<'a>
47    where
48        T: Into<Spans<'a>>,
49    {
50        self.title = Some(title.into());
51        self
52    }
53
54    pub fn bounds(mut self, bounds: [f64; 2]) -> Axis<'a> {
55        self.bounds = bounds;
56        self
57    }
58
59    pub fn labels(mut self, labels: Vec<Span<'a>>) -> Axis<'a> {
60        self.labels = Some(labels);
61        self
62    }
63
64    pub fn style(mut self, style: Style) -> Axis<'a> {
65        self.style = style;
66        self
67    }
68
69    /// Defines the alignment of the labels of the axis.
70    /// The alignment behaves differently based on the axis:
71    /// - Y-Axis: The labels are aligned within the area on the left of the axis
72    /// - X-Axis: The first X-axis label is aligned relative to the Y-axis
73    pub fn labels_alignment(mut self, alignment: Alignment) -> Axis<'a> {
74        self.labels_alignment = alignment;
75        self
76    }
77}
78
79/// Used to determine which style of graphing to use
80#[derive(Debug, Clone, Copy)]
81pub enum GraphType {
82    /// Draw each point
83    Scatter,
84    /// Draw each point and lines between each point using the same marker
85    Line,
86}
87
88/// A group of data points
89#[derive(Debug, Clone)]
90pub struct Dataset<'a> {
91    /// Name of the dataset (used in the legend if shown)
92    name: Cow<'a, str>,
93    /// A reference to the actual data
94    data: &'a [(f64, f64)],
95    /// Symbol used for each points of this dataset
96    marker: symbols::Marker,
97    /// Determines graph type used for drawing points
98    graph_type: GraphType,
99    /// Style used to plot this dataset
100    style: Style,
101}
102
103impl<'a> Default for Dataset<'a> {
104    fn default() -> Dataset<'a> {
105        Dataset {
106            name: Cow::from(""),
107            data: &[],
108            marker: symbols::Marker::Dot,
109            graph_type: GraphType::Scatter,
110            style: Style::default(),
111        }
112    }
113}
114
115impl<'a> Dataset<'a> {
116    pub fn name<S>(mut self, name: S) -> Dataset<'a>
117    where
118        S: Into<Cow<'a, str>>,
119    {
120        self.name = name.into();
121        self
122    }
123
124    pub fn data(mut self, data: &'a [(f64, f64)]) -> Dataset<'a> {
125        self.data = data;
126        self
127    }
128
129    pub fn marker(mut self, marker: symbols::Marker) -> Dataset<'a> {
130        self.marker = marker;
131        self
132    }
133
134    pub fn graph_type(mut self, graph_type: GraphType) -> Dataset<'a> {
135        self.graph_type = graph_type;
136        self
137    }
138
139    pub fn style(mut self, style: Style) -> Dataset<'a> {
140        self.style = style;
141        self
142    }
143}
144
145/// A container that holds all the infos about where to display each elements of the chart (axis,
146/// labels, legend, ...).
147#[derive(Debug, Clone, PartialEq, Default)]
148struct ChartLayout {
149    /// Location of the title of the x axis
150    title_x: Option<(u16, u16)>,
151    /// Location of the title of the y axis
152    title_y: Option<(u16, u16)>,
153    /// Location of the first label of the x axis
154    label_x: Option<u16>,
155    /// Location of the first label of the y axis
156    label_y: Option<u16>,
157    /// Y coordinate of the horizontal axis
158    axis_x: Option<u16>,
159    /// X coordinate of the vertical axis
160    axis_y: Option<u16>,
161    /// Area of the legend
162    legend_area: Option<Rect>,
163    /// Area of the graph
164    graph_area: Rect,
165}
166
167/// A widget to plot one or more dataset in a cartesian coordinate system
168///
169/// # Examples
170///
171/// ```
172/// # use altui_core::symbols;
173/// # use altui_core::widgets::{Block, Borders, Chart, Axis, Dataset, GraphType};
174/// # use altui_core::style::{Style, Color};
175/// # use altui_core::text::Span;
176/// let datasets = vec![
177///     Dataset::default()
178///         .name("data1")
179///         .marker(symbols::Marker::Dot)
180///         .graph_type(GraphType::Scatter)
181///         .style(Style::default().fg(Color::Cyan))
182///         .data(&[(0.0, 5.0), (1.0, 6.0), (1.5, 6.434)]),
183///     Dataset::default()
184///         .name("data2")
185///         .marker(symbols::Marker::Braille)
186///         .graph_type(GraphType::Line)
187///         .style(Style::default().fg(Color::Magenta))
188///         .data(&[(4.0, 5.0), (5.0, 8.0), (7.66, 13.5)]),
189/// ];
190/// Chart::new(datasets)
191///     .block(Block::default().title("Chart"))
192///     .x_axis(Axis::default()
193///         .title(Span::styled("X Axis", Style::default().fg(Color::Red)))
194///         .style(Style::default().fg(Color::White))
195///         .bounds([0.0, 10.0])
196///         .labels(["0.0", "5.0", "10.0"].iter().cloned().map(Span::from).collect()))
197///     .y_axis(Axis::default()
198///         .title(Span::styled("Y Axis", Style::default().fg(Color::Red)))
199///         .style(Style::default().fg(Color::White))
200///         .bounds([0.0, 10.0])
201///         .labels(["0.0", "5.0", "10.0"].iter().cloned().map(Span::from).collect()));
202/// ```
203#[derive(Debug, Clone)]
204pub struct Chart<'a> {
205    /// A block to display around the widget eventually
206    block: Option<Block<'a>>,
207    /// The horizontal axis
208    x_axis: Axis<'a>,
209    /// The vertical axis
210    y_axis: Axis<'a>,
211    /// A reference to the datasets
212    datasets: Vec<Dataset<'a>>,
213    /// The widget base style
214    style: Style,
215    /// Constraints used to determine whether the legend should be shown or not
216    hidden_legend_constraints: (Constraint, Constraint),
217}
218
219impl<'a> Chart<'a> {
220    pub fn new(datasets: Vec<Dataset<'a>>) -> Chart<'a> {
221        Chart {
222            block: None,
223            x_axis: Axis::default(),
224            y_axis: Axis::default(),
225            style: Default::default(),
226            datasets,
227            hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
228        }
229    }
230
231    pub fn block(mut self, block: Block<'a>) -> Chart<'a> {
232        self.block = Some(block);
233        self
234    }
235
236    pub fn style(mut self, style: Style) -> Chart<'a> {
237        self.style = style;
238        self
239    }
240
241    pub fn x_axis(mut self, axis: Axis<'a>) -> Chart<'a> {
242        self.x_axis = axis;
243        self
244    }
245
246    pub fn y_axis(mut self, axis: Axis<'a>) -> Chart<'a> {
247        self.y_axis = axis;
248        self
249    }
250
251    /// Set the constraints used to determine whether the legend should be shown or not.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// # use altui_core::widgets::Chart;
257    /// # use altui_core::layout::Constraint;
258    /// let constraints = (
259    ///     Constraint::Ratio(1, 3),
260    ///     Constraint::Ratio(1, 4)
261    /// );
262    /// // Hide the legend when either its width is greater than 33% of the total widget width
263    /// // or if its height is greater than 25% of the total widget height.
264    /// let _chart: Chart = Chart::new(vec![])
265    ///     .hidden_legend_constraints(constraints);
266    /// ```
267    pub fn hidden_legend_constraints(mut self, constraints: (Constraint, Constraint)) -> Chart<'a> {
268        self.hidden_legend_constraints = constraints;
269        self
270    }
271
272    /// Compute the internal layout of the chart given the area. If the area is too small some
273    /// elements may be automatically hidden
274    fn layout(&self, area: Rect) -> ChartLayout {
275        let mut layout = ChartLayout::default();
276        if area.height == 0 || area.width == 0 {
277            return layout;
278        }
279        let mut x = area.left();
280        let mut y = area.bottom() - 1;
281
282        if self.x_axis.labels.is_some() && y > area.top() {
283            layout.label_x = Some(y);
284            y -= 1;
285        }
286
287        layout.label_y = self.y_axis.labels.as_ref().and(Some(x));
288        x += self.max_width_of_labels_left_of_y_axis(area, self.y_axis.labels.is_some());
289
290        if self.x_axis.labels.is_some() && y > area.top() {
291            layout.axis_x = Some(y);
292            y -= 1;
293        }
294
295        if self.y_axis.labels.is_some() && x + 1 < area.right() {
296            layout.axis_y = Some(x);
297            x += 1;
298        }
299
300        if x < area.right() && y > 1 {
301            layout.graph_area = Rect::new(x, area.top(), area.right() - x, y - area.top() + 1);
302        }
303
304        if let Some(ref title) = self.x_axis.title {
305            let w = title.width() as u16;
306            if w < layout.graph_area.width && layout.graph_area.height > 2 {
307                layout.title_x = Some((x + layout.graph_area.width - w, y));
308            }
309        }
310
311        if let Some(ref title) = self.y_axis.title {
312            let w = title.width() as u16;
313            if w + 1 < layout.graph_area.width && layout.graph_area.height > 2 {
314                layout.title_y = Some((x, area.top()));
315            }
316        }
317
318        if let Some(inner_width) = self.datasets.iter().map(|d| d.name.width() as u16).max() {
319            let legend_width = inner_width + 2;
320            let legend_height = self.datasets.len() as u16 + 2;
321            let max_legend_width = self
322                .hidden_legend_constraints
323                .0
324                .apply(layout.graph_area.width);
325            let max_legend_height = self
326                .hidden_legend_constraints
327                .1
328                .apply(layout.graph_area.height);
329            if inner_width > 0
330                && legend_width < max_legend_width
331                && legend_height < max_legend_height
332            {
333                layout.legend_area = Some(Rect::new(
334                    layout.graph_area.right() - legend_width,
335                    layout.graph_area.top(),
336                    legend_width,
337                    legend_height,
338                ));
339            }
340        }
341        layout
342    }
343
344    fn max_width_of_labels_left_of_y_axis(&self, area: Rect, has_y_axis: bool) -> u16 {
345        let mut max_width = self
346            .y_axis
347            .labels
348            .as_ref()
349            .map(|l| l.iter().map(Span::width).max().unwrap_or_default() as u16)
350            .unwrap_or_default();
351
352        if let Some(first_x_label) = self.x_axis.labels.as_ref().and_then(|labels| labels.get(0)) {
353            let first_label_width = first_x_label.content.width() as u16;
354            let width_left_of_y_axis = match self.x_axis.labels_alignment {
355                Alignment::Left => {
356                    // The last character of the label should be below the Y-Axis when it exists, not on its left
357                    let y_axis_offset = if has_y_axis { 1 } else { 0 };
358                    first_label_width.saturating_sub(y_axis_offset)
359                }
360                Alignment::Center => first_label_width / 2,
361                Alignment::Right => 0,
362            };
363            max_width = max(max_width, width_left_of_y_axis);
364        }
365        // labels of y axis and first label of x axis can take at most 1/3rd of the total width
366        max_width.min(area.width / 3)
367    }
368
369    fn render_x_labels(
370        &mut self,
371        buf: &mut Buffer,
372        layout: &ChartLayout,
373        chart_area: Rect,
374        graph_area: Rect,
375    ) {
376        let y = match layout.label_x {
377            Some(y) => y,
378            None => return,
379        };
380        let labels = self.x_axis.labels.as_ref().unwrap();
381        let labels_len = labels.len() as u16;
382        if labels_len < 2 {
383            return;
384        }
385
386        let width_between_ticks = graph_area.width / labels_len;
387
388        let label_area = self.first_x_label_area(
389            y,
390            labels.first().unwrap().width() as u16,
391            width_between_ticks,
392            chart_area,
393            graph_area,
394        );
395
396        let label_alignment = match self.x_axis.labels_alignment {
397            Alignment::Left => Alignment::Right,
398            Alignment::Center => Alignment::Center,
399            Alignment::Right => Alignment::Left,
400        };
401
402        Self::render_label(buf, labels.first().unwrap(), label_area, label_alignment);
403
404        for (i, label) in labels[1..labels.len() - 1].iter().enumerate() {
405            // We add 1 to x (and width-1 below) to leave at least one space before each intermediate labels
406            let x = graph_area.left() + (i + 1) as u16 * width_between_ticks + 1;
407            let label_area = Rect::new(x, y, width_between_ticks.saturating_sub(1), 1);
408
409            Self::render_label(buf, label, label_area, Alignment::Center);
410        }
411
412        let x = graph_area.right() - width_between_ticks;
413        let label_area = Rect::new(x, y, width_between_ticks, 1);
414        // The last label should be aligned Right to be at the edge of the graph area
415        Self::render_label(buf, labels.last().unwrap(), label_area, Alignment::Right);
416    }
417
418    fn first_x_label_area(
419        &self,
420        y: u16,
421        label_width: u16,
422        max_width_after_y_axis: u16,
423        chart_area: Rect,
424        graph_area: Rect,
425    ) -> Rect {
426        let (min_x, max_x) = match self.x_axis.labels_alignment {
427            Alignment::Left => (chart_area.left(), graph_area.left()),
428            Alignment::Center => (
429                chart_area.left(),
430                graph_area.left() + max_width_after_y_axis.min(label_width),
431            ),
432            Alignment::Right => (
433                graph_area.left().saturating_sub(1),
434                graph_area.left() + max_width_after_y_axis,
435            ),
436        };
437
438        Rect::new(min_x, y, max_x - min_x, 1)
439    }
440
441    fn render_label(buf: &mut Buffer, label: &Span, label_area: Rect, alignment: Alignment) {
442        let label_width = label.width() as u16;
443        let bounded_label_width = label_area.width.min(label_width);
444
445        let x = match alignment {
446            Alignment::Left => label_area.left(),
447            Alignment::Center => label_area.left() + label_area.width / 2 - bounded_label_width / 2,
448            Alignment::Right => label_area.right() - bounded_label_width,
449        };
450
451        buf.set_span(x, label_area.top(), label, bounded_label_width);
452    }
453
454    fn render_y_labels(
455        &mut self,
456        buf: &mut Buffer,
457        layout: &ChartLayout,
458        chart_area: Rect,
459        graph_area: Rect,
460    ) {
461        let x = match layout.label_y {
462            Some(x) => x,
463            None => return,
464        };
465        let labels = self.y_axis.labels.as_ref().unwrap();
466        let labels_len = labels.len() as u16;
467        for (i, label) in labels.iter().enumerate() {
468            let dy = i as u16 * (graph_area.height - 1) / (labels_len - 1);
469            if dy < graph_area.bottom() {
470                let label_area = Rect::new(
471                    x,
472                    graph_area.bottom().saturating_sub(1) - dy,
473                    (graph_area.left() - chart_area.left()).saturating_sub(1),
474                    1,
475                );
476                Self::render_label(buf, label, label_area, self.y_axis.labels_alignment);
477            }
478        }
479    }
480}
481
482impl<'a> Widget for Chart<'a> {
483    fn render(&mut self, area: Rect, buf: &mut Buffer) {
484        if area.area() == 0 {
485            return;
486        }
487        buf.set_style(area, self.style);
488        // Sample the style of the entire widget. This sample will be used to reset the style of
489        // the cells that are part of the components put on top of the grah area (i.e legend and
490        // axis names).
491        let original_style = buf.get(area.left(), area.top()).style();
492
493        let chart_area = match self.block.as_mut() {
494            Some(b) => {
495                let inner_area = b.inner(area);
496                b.render(area, buf);
497                inner_area
498            }
499            None => area,
500        };
501
502        let layout = self.layout(chart_area);
503        let graph_area = layout.graph_area;
504        if graph_area.width < 1 || graph_area.height < 1 {
505            return;
506        }
507
508        self.render_x_labels(buf, &layout, chart_area, graph_area);
509        self.render_y_labels(buf, &layout, chart_area, graph_area);
510
511        if let Some(y) = layout.axis_x {
512            for x in graph_area.left()..graph_area.right() {
513                buf.get_mut(x, y)
514                    .set_symbol(symbols::line::HORIZONTAL)
515                    .set_style(self.x_axis.style);
516            }
517        }
518
519        if let Some(x) = layout.axis_y {
520            for y in graph_area.top()..graph_area.bottom() {
521                buf.get_mut(x, y)
522                    .set_symbol(symbols::line::VERTICAL)
523                    .set_style(self.y_axis.style);
524            }
525        }
526
527        if let Some(y) = layout.axis_x {
528            if let Some(x) = layout.axis_y {
529                buf.get_mut(x, y)
530                    .set_symbol(symbols::line::BOTTOM_LEFT)
531                    .set_style(self.x_axis.style);
532            }
533        }
534
535        for dataset in &self.datasets {
536            Canvas::default()
537                .background_color(self.style.bg.unwrap_or(Color::Reset))
538                .x_bounds(self.x_axis.bounds)
539                .y_bounds(self.y_axis.bounds)
540                .marker(dataset.marker)
541                .paint(|ctx| {
542                    ctx.draw(&Points {
543                        coords: dataset.data,
544                        color: dataset.style.fg.unwrap_or(Color::Reset),
545                    });
546                    if let GraphType::Line = dataset.graph_type {
547                        for data in dataset.data.windows(2) {
548                            ctx.draw(&Line {
549                                x1: data[0].0,
550                                y1: data[0].1,
551                                x2: data[1].0,
552                                y2: data[1].1,
553                                color: dataset.style.fg.unwrap_or(Color::Reset),
554                            })
555                        }
556                    }
557                })
558                .render(graph_area, buf);
559        }
560
561        if let Some(legend_area) = layout.legend_area {
562            buf.set_style(legend_area, original_style);
563            Block::default()
564                .borders(Borders::ALL)
565                .render(legend_area, buf);
566            for (i, dataset) in self.datasets.iter().enumerate() {
567                buf.set_string(
568                    legend_area.x + 1,
569                    legend_area.y + 1 + i as u16,
570                    &dataset.name,
571                    dataset.style,
572                );
573            }
574        }
575
576        if let Some((x, y)) = layout.title_x {
577            let title = self.x_axis.title.as_mut().unwrap();
578            let width = graph_area.right().saturating_sub(x);
579            buf.set_style(
580                Rect {
581                    x,
582                    y,
583                    width,
584                    height: 1,
585                },
586                original_style,
587            );
588            buf.set_spans(x, y, &title, width);
589        }
590
591        if let Some((x, y)) = layout.title_y {
592            let title = self.y_axis.title.as_mut().unwrap();
593            let width = graph_area.right().saturating_sub(x);
594            buf.set_style(
595                Rect {
596                    x,
597                    y,
598                    width,
599                    height: 1,
600                },
601                original_style,
602            );
603            buf.set_spans(x, y, &title, width);
604        }
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    struct LegendTestCase {
613        chart_area: Rect,
614        hidden_legend_constraints: (Constraint, Constraint),
615        legend_area: Option<Rect>,
616    }
617
618    #[test]
619    fn it_should_hide_the_legend() {
620        let data = [(0.0, 5.0), (1.0, 6.0), (3.0, 7.0)];
621        let cases = [
622            LegendTestCase {
623                chart_area: Rect::new(0, 0, 100, 100),
624                hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
625                legend_area: Some(Rect::new(88, 0, 12, 12)),
626            },
627            LegendTestCase {
628                chart_area: Rect::new(0, 0, 100, 100),
629                hidden_legend_constraints: (Constraint::Ratio(1, 10), Constraint::Ratio(1, 4)),
630                legend_area: None,
631            },
632        ];
633        for case in &cases {
634            let datasets = (0..10)
635                .map(|i| {
636                    let name = format!("Dataset #{}", i);
637                    Dataset::default().name(name).data(&data)
638                })
639                .collect::<Vec<_>>();
640            let chart = Chart::new(datasets)
641                .x_axis(Axis::default().title("X axis"))
642                .y_axis(Axis::default().title("Y axis"))
643                .hidden_legend_constraints(case.hidden_legend_constraints);
644            let layout = chart.layout(case.chart_area);
645            assert_eq!(layout.legend_area, case.legend_area);
646        }
647    }
648}