Skip to main content

chartml_core/layout/
stack.rs

1/// A single stacked data point with baseline and top values.
2#[derive(Debug, Clone)]
3pub struct StackedPoint {
4    /// Category (e.g., month name)
5    pub key: String,
6    /// Series name (e.g., product line)
7    pub series: String,
8    /// Bottom of stack (baseline)
9    pub y0: f64,
10    /// Top of stack (y0 + value)
11    pub y1: f64,
12    /// The value used for stacking (raw value, or normalized proportion when using Normalize offset)
13    pub value: f64,
14}
15
16/// Order in which series are stacked.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum StackOrder {
19    /// Use input order
20    None,
21    /// Smallest series first (bottom)
22    Ascending,
23    /// Largest series first (bottom)
24    Descending,
25}
26
27/// Offset mode for the stack baseline.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum StackOffset {
30    /// Zero baseline (standard stacking)
31    None,
32    /// Normalize to 0-1 (100% stacked)
33    Normalize,
34}
35
36/// Computes stacked layout from grouped data.
37/// Equivalent to D3's `d3.stack()`.
38pub struct StackLayout {
39    order: StackOrder,
40    offset: StackOffset,
41}
42
43impl StackLayout {
44    /// Create a new StackLayout with default settings (StackOrder::None, StackOffset::None).
45    pub fn new() -> Self {
46        Self {
47            order: StackOrder::None,
48            offset: StackOffset::None,
49        }
50    }
51
52    /// Set the stacking order.
53    pub fn order(mut self, order: StackOrder) -> Self {
54        self.order = order;
55        self
56    }
57
58    /// Set the stacking offset.
59    pub fn offset(mut self, offset: StackOffset) -> Self {
60        self.offset = offset;
61        self
62    }
63
64    /// Compute stacked layout.
65    ///
66    /// # Arguments
67    /// - `keys`: the categories (x-axis values), e.g., \["Jan", "Feb", "Mar"\]
68    /// - `series_names`: the series (stacking groups), e.g., \["Hardware", "Software", "Services"\]
69    /// - `values`: a 2D structure where `values[series_index][key_index]` = the value
70    ///   (outer = series, inner = keys)
71    ///
72    /// # Returns
73    /// A `Vec<StackedPoint>` for every (key, series) combination.
74    pub fn layout(
75        &self,
76        keys: &[String],
77        series_names: &[String],
78        values: &[Vec<f64>],
79    ) -> Vec<StackedPoint> {
80        if keys.is_empty() || series_names.is_empty() || values.is_empty() {
81            return Vec::new();
82        }
83
84        let num_series = series_names.len();
85        let num_keys = keys.len();
86
87        // Determine the order of series indices
88        let ordered_indices = self.compute_order(series_names, values, num_keys);
89
90        // Compute totals per key (needed for Normalize)
91        let totals: Vec<f64> = (0..num_keys)
92            .map(|k| {
93                (0..num_series)
94                    .map(|s| {
95                        values
96                            .get(s)
97                            .and_then(|v| v.get(k))
98                            .copied()
99                            .unwrap_or(0.0)
100                    })
101                    .sum()
102            })
103            .collect();
104
105        let mut results = Vec::with_capacity(num_series * num_keys);
106
107        for k in 0..num_keys {
108            let mut y_base = 0.0;
109
110            for &s in &ordered_indices {
111                let raw_value = values
112                    .get(s)
113                    .and_then(|v| v.get(k))
114                    .copied()
115                    .unwrap_or(0.0);
116
117                let value = match self.offset {
118                    StackOffset::None => raw_value,
119                    StackOffset::Normalize => {
120                        let total = totals[k];
121                        if total == 0.0 {
122                            0.0
123                        } else {
124                            raw_value / total
125                        }
126                    }
127                };
128
129                let y0 = y_base;
130                let y1 = y_base + value;
131                y_base = y1;
132
133                results.push(StackedPoint {
134                    key: keys[k].clone(),
135                    series: series_names[s].clone(),
136                    y0,
137                    y1,
138                    value,
139                });
140            }
141        }
142
143        results
144    }
145
146    /// Compute the order of series indices based on the configured StackOrder.
147    fn compute_order(
148        &self,
149        series_names: &[String],
150        values: &[Vec<f64>],
151        num_keys: usize,
152    ) -> Vec<usize> {
153        let num_series = series_names.len();
154        let mut indices: Vec<usize> = (0..num_series).collect();
155
156        match self.order {
157            StackOrder::None => {
158                // Keep input order
159            }
160            StackOrder::Ascending => {
161                let sums: Vec<f64> = (0..num_series)
162                    .map(|s| {
163                        (0..num_keys)
164                            .map(|k| {
165                                values
166                                    .get(s)
167                                    .and_then(|v| v.get(k))
168                                    .copied()
169                                    .unwrap_or(0.0)
170                            })
171                            .sum()
172                    })
173                    .collect();
174                indices.sort_by(|&a, &b| sums[a].partial_cmp(&sums[b]).unwrap_or(std::cmp::Ordering::Equal));
175            }
176            StackOrder::Descending => {
177                let sums: Vec<f64> = (0..num_series)
178                    .map(|s| {
179                        (0..num_keys)
180                            .map(|k| {
181                                values
182                                    .get(s)
183                                    .and_then(|v| v.get(k))
184                                    .copied()
185                                    .unwrap_or(0.0)
186                            })
187                            .sum()
188                    })
189                    .collect();
190                indices.sort_by(|&a, &b| sums[b].partial_cmp(&sums[a]).unwrap_or(std::cmp::Ordering::Equal));
191            }
192        }
193
194        indices
195    }
196}
197
198impl Default for StackLayout {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn stack_basic() {
210        let keys = vec!["Jan".to_string(), "Feb".to_string(), "Mar".to_string()];
211        let series = vec!["A".to_string(), "B".to_string()];
212        let values = vec![
213            vec![10.0, 20.0, 30.0], // Series A
214            vec![5.0, 10.0, 15.0],  // Series B
215        ];
216
217        let layout = StackLayout::new();
218        let points = layout.layout(&keys, &series, &values);
219
220        assert_eq!(points.len(), 6);
221
222        // Jan: A(0-10), B(10-15)
223        assert_eq!(points[0].key, "Jan");
224        assert_eq!(points[0].series, "A");
225        assert_eq!(points[0].y0, 0.0);
226        assert_eq!(points[0].y1, 10.0);
227        assert_eq!(points[0].value, 10.0);
228
229        assert_eq!(points[1].key, "Jan");
230        assert_eq!(points[1].series, "B");
231        assert_eq!(points[1].y0, 10.0);
232        assert_eq!(points[1].y1, 15.0);
233        assert_eq!(points[1].value, 5.0);
234
235        // Feb: A(0-20), B(20-30)
236        assert_eq!(points[2].y0, 0.0);
237        assert_eq!(points[2].y1, 20.0);
238        assert_eq!(points[3].y0, 20.0);
239        assert_eq!(points[3].y1, 30.0);
240
241        // Mar: A(0-30), B(30-45)
242        assert_eq!(points[4].y0, 0.0);
243        assert_eq!(points[4].y1, 30.0);
244        assert_eq!(points[5].y0, 30.0);
245        assert_eq!(points[5].y1, 45.0);
246    }
247
248    #[test]
249    fn stack_y0_y1_chain() {
250        let keys = vec!["X".to_string()];
251        let series = vec!["A".to_string(), "B".to_string(), "C".to_string()];
252        let values = vec![vec![10.0], vec![20.0], vec![30.0]];
253
254        let layout = StackLayout::new();
255        let points = layout.layout(&keys, &series, &values);
256
257        assert_eq!(points.len(), 3);
258        // Each series y0 equals previous series y1
259        assert_eq!(points[0].y0, 0.0);
260        assert_eq!(points[0].y1, 10.0);
261        assert_eq!(points[1].y0, points[0].y1);
262        assert_eq!(points[1].y1, 30.0);
263        assert_eq!(points[2].y0, points[1].y1);
264        assert_eq!(points[2].y1, 60.0);
265    }
266
267    #[test]
268    fn stack_normalize() {
269        let keys = vec!["Jan".to_string(), "Feb".to_string()];
270        let series = vec!["A".to_string(), "B".to_string()];
271        let values = vec![
272            vec![30.0, 40.0], // Series A
273            vec![70.0, 60.0], // Series B
274        ];
275
276        let layout = StackLayout::new().offset(StackOffset::Normalize);
277        let points = layout.layout(&keys, &series, &values);
278
279        assert_eq!(points.len(), 4);
280
281        // Jan: total=100, A=0.3, B=0.7 → top should be 1.0
282        assert!((points[0].y0 - 0.0).abs() < 1e-10);
283        assert!((points[0].y1 - 0.3).abs() < 1e-10);
284        assert!((points[1].y0 - 0.3).abs() < 1e-10);
285        assert!((points[1].y1 - 1.0).abs() < 1e-10);
286
287        // Feb: total=100, A=0.4, B=0.6 → top should be 1.0
288        assert!((points[2].y0 - 0.0).abs() < 1e-10);
289        assert!((points[2].y1 - 0.4).abs() < 1e-10);
290        assert!((points[3].y0 - 0.4).abs() < 1e-10);
291        assert!((points[3].y1 - 1.0).abs() < 1e-10);
292    }
293
294    #[test]
295    fn stack_empty() {
296        let layout = StackLayout::new();
297
298        let points = layout.layout(&[], &[], &[]);
299        assert!(points.is_empty());
300
301        let points = layout.layout(&["A".to_string()], &[], &[]);
302        assert!(points.is_empty());
303
304        let points = layout.layout(&[], &["S".to_string()], &[vec![1.0]]);
305        assert!(points.is_empty());
306    }
307
308    #[test]
309    fn stack_single_series() {
310        let keys = vec!["A".to_string(), "B".to_string(), "C".to_string()];
311        let series = vec!["Only".to_string()];
312        let values = vec![vec![10.0, 20.0, 30.0]];
313
314        let layout = StackLayout::new();
315        let points = layout.layout(&keys, &series, &values);
316
317        assert_eq!(points.len(), 3);
318        for point in &points {
319            assert_eq!(point.y0, 0.0);
320            assert_eq!(point.series, "Only");
321        }
322        assert_eq!(points[0].y1, 10.0);
323        assert_eq!(points[1].y1, 20.0);
324        assert_eq!(points[2].y1, 30.0);
325    }
326}