Skip to main content

rich_ext/chart/xy/
stacked.rs

1//! Stacked areas: [`Chart::stacked`] puts each [`GraphType::Area`] dataset
2//! on top of the ones before it.
3
4use std::borrow::Cow;
5
6use super::super::describe::extent;
7use super::{AxisScale, Chart, GraphType};
8
9impl Chart {
10    /// Stack the [`GraphType::Area`] datasets (default off): each area's
11    /// values are drawn on top of the areas before it, so the top edge is
12    /// their running total and each band is one dataset's share. The axes
13    /// fit the totals (a linear y axis without bounds runs from the fill
14    /// level), and every stacked area fills down to the first
15    /// one's [`fill_to`](super::Dataset::fill_to) level (0 by default), the top layer
16    /// drawn first, so each band shows in its own style. Datasets that
17    /// share x values stack exactly; between another area's points the
18    /// area below is interpolated along its line. Other datasets are drawn
19    /// as they are, over the areas. The legend and the accessible text
20    /// keep each dataset's own values.
21    ///
22    /// ```
23    /// use rich::Console;
24    /// use rich_ext::chart::{Axis, Chart, Dataset, GraphType, LegendPosition, Marker};
25    ///
26    /// let console = Console::builder().width(8).color_system(None).build();
27    /// let area = |points: [(f64, f64); 2], c: char| {
28    ///     Dataset::new(points).graph_type(GraphType::Area).marker(Marker::Ascii(c))
29    /// };
30    /// let chart = Chart::new(vec![
31    ///     area([(0.0, 1.0), (7.0, 1.0)], '#'),
32    ///     area([(0.0, 1.0), (7.0, 2.0)], '+'),
33    /// ])
34    /// .x_axis(Axis::default().bounds(0.0, 7.0))
35    /// .y_axis(Axis::default().bounds(0.0, 3.0))
36    /// .legend(LegendPosition::None)
37    /// .stacked(true)
38    /// .width(8)
39    /// .height(4);
40    /// assert_eq!(
41    ///     console.render_export(&chart),
42    ///     concat!(
43    ///         "    ++++\n",
44    ///         "++++++++\n",
45    ///         "########\n",
46    ///         "########\n",
47    ///     )
48    /// );
49    /// ```
50    pub fn stacked(mut self, stacked: bool) -> Self {
51        self.stacked = stacked;
52        self
53    }
54
55    /// Whether any areas are stacked.
56    pub(super) fn stacks(&self) -> bool {
57        self.stacked
58            && self
59                .datasets
60                .iter()
61                .any(|d| d.graph_type == GraphType::Area)
62    }
63
64    /// The order the datasets are drawn in: stacked areas first, the top
65    /// one first, then the others in order.
66    pub(super) fn draw_order(&self) -> Vec<usize> {
67        let all = 0..self.datasets.len();
68        if !self.stacks() {
69            return all.collect();
70        }
71        let area = |i: &usize| self.datasets[*i].graph_type == GraphType::Area;
72        let mut order: Vec<usize> = all.clone().filter(area).rev().collect();
73        order.extend(all.filter(|i| !area(i)));
74        order
75    }
76
77    /// This chart with each stacked area's y values replaced by the running
78    /// total, and every one filling to the first one's level; the chart
79    /// itself when nothing is stacked.
80    pub(super) fn stacked_view(&self) -> Cow<'_, Chart> {
81        if !self.stacks() {
82            return Cow::Borrowed(self);
83        }
84        let mut view = self.clone();
85        let mut below: Vec<(f64, f64)> = Vec::new();
86        let mut level = None;
87        for dataset in view
88            .datasets
89            .iter_mut()
90            .filter(|d| d.graph_type == GraphType::Area)
91        {
92            let base = *level.get_or_insert(dataset.fill_to);
93            dataset.fill_to = base;
94            let stacked: Vec<(f64, f64)> = dataset
95                .points
96                .iter()
97                .map(|&(x, y)| (x, y + under(&below, x)))
98                .collect();
99            below = stacked
100                .iter()
101                .copied()
102                .filter(|(x, y)| x.is_finite() && y.is_finite())
103                .collect();
104            below.sort_by(|a, b| a.0.total_cmp(&b.0));
105            dataset.points = stacked;
106        }
107        // Without bounds, a linear y axis runs from the fill level.
108        if let (None, AxisScale::Linear, Some(base)) =
109            (view.y_axis.bounds, view.y_axis.scale, level)
110        {
111            let ys = view
112                .datasets
113                .iter()
114                .flat_map(|d| d.points.iter().map(|p| p.1));
115            if let Some((lo, hi)) = extent(ys.chain([base]))
116                && lo < hi
117            {
118                view.y_axis.bounds = Some([lo, hi]);
119            }
120        }
121        Cow::Owned(view)
122    }
123}
124
125/// The height of the line through `points` (sorted by x) at `x`: linear
126/// between them, 0 outside them.
127fn under(points: &[(f64, f64)], x: f64) -> f64 {
128    let Some(at) = points.iter().position(|p| p.0 >= x) else {
129        return 0.0;
130    };
131    let (x1, y1) = points[at];
132    if x1 == x {
133        return y1;
134    }
135    let Some(&(x0, y0)) = at.checked_sub(1).map(|i| &points[i]) else {
136        return 0.0;
137    };
138    y0 + (y1 - y0) * (x - x0) / (x1 - x0)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn the_layer_below_is_interpolated_and_zero_outside() {
147        let line = [(0.0, 1.0), (2.0, 3.0)];
148        assert_eq!(under(&line, 0.0), 1.0);
149        assert_eq!(under(&line, 1.0), 2.0);
150        assert_eq!(under(&line, 2.0), 3.0);
151        assert_eq!(under(&line, 3.0), 0.0);
152        assert_eq!(under(&line, -1.0), 0.0);
153        assert_eq!(under(&[], 1.0), 0.0);
154    }
155}