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}