Skip to main content

orbital_charts/engine/
stacking.rs

1//! Stack offsets and orders for bar, line, and area charts.
2
3use std::collections::HashMap;
4
5use crate::engine::ProjectedSeries;
6use crate::{ChartType, SeriesDef, StackOffset, StackOrder};
7
8/// Whether any series definition declares an explicit chart type (composition mode).
9pub fn is_composition_mode(series_defs: &[SeriesDef]) -> bool {
10    series_defs.iter().any(|s| s.chart_type.is_some())
11}
12
13/// Stack group map from series definitions.
14pub fn stack_groups_from_series(series_defs: &[SeriesDef]) -> HashMap<String, String> {
15    series_defs
16        .iter()
17        .filter_map(|s| s.stack_group.as_ref().map(|g| (s.id.clone(), g.clone())))
18        .collect()
19}
20
21/// Resolve offset and order for a stack group from member series definitions.
22pub fn resolve_stack_config(
23    group: &str,
24    series_defs: &[SeriesDef],
25    default_chart_type: ChartType,
26) -> (StackOffset, StackOrder) {
27    let members: Vec<_> = series_defs
28        .iter()
29        .filter(|s| s.stack_group.as_deref() == Some(group))
30        .collect();
31    let offset = members
32        .iter()
33        .find_map(|s| s.stack_offset)
34        .unwrap_or(match default_chart_type {
35            ChartType::Bar => StackOffset::Diverging,
36            _ => StackOffset::None,
37        });
38    let order = members
39        .iter()
40        .find_map(|s| s.stack_order)
41        .unwrap_or(StackOrder::None);
42    (offset, order)
43}
44
45/// Resolve the dominant stack offset across all groups in a chart.
46pub fn resolve_chart_stack_offset(
47    series_defs: &[SeriesDef],
48    default_chart_type: ChartType,
49) -> StackOffset {
50    let mut groups: Vec<&str> = series_defs
51        .iter()
52        .filter_map(|s| s.stack_group.as_deref())
53        .collect();
54    groups.sort_unstable();
55    groups.dedup();
56    groups
57        .first()
58        .map(|group| resolve_stack_config(group, series_defs, default_chart_type).0)
59        .unwrap_or(StackOffset::None)
60}
61
62/// Reorder series indices within a stack group.
63pub fn apply_stack_order(
64    indices: &[usize],
65    series: &[ProjectedSeries],
66    order: StackOrder,
67) -> Vec<usize> {
68    match order {
69        StackOrder::None => indices.to_vec(),
70        StackOrder::Reverse => indices.iter().rev().copied().collect(),
71        StackOrder::Appearance => {
72            let mut ordered = indices.to_vec();
73            ordered.sort_by_key(|&idx| {
74                series[idx]
75                    .data
76                    .iter()
77                    .enumerate()
78                    .filter_map(|(row, &value)| value.is_finite().then_some((value, row)))
79                    .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
80                    .map(|(_, row)| row)
81                    .unwrap_or(usize::MAX)
82            });
83            ordered
84        }
85        StackOrder::Ascending => {
86            let mut ordered = indices.to_vec();
87            ordered.sort_by(|&a, &b| {
88                total_abs(&series[a].data)
89                    .partial_cmp(&total_abs(&series[b].data))
90                    .unwrap_or(std::cmp::Ordering::Equal)
91            });
92            ordered
93        }
94        StackOrder::Descending => {
95            let mut ordered = indices.to_vec();
96            ordered.sort_by(|&a, &b| {
97                total_abs(&series[b].data)
98                    .partial_cmp(&total_abs(&series[a].data))
99                    .unwrap_or(std::cmp::Ordering::Equal)
100            });
101            ordered
102        }
103    }
104}
105
106fn total_abs(data: &[f64]) -> f64 {
107    data.iter().filter(|v| v.is_finite()).map(|v| v.abs()).sum()
108}
109
110/// Stack series values within shared stack groups.
111pub fn stack_series(
112    series: &[ProjectedSeries],
113    stack_groups: &HashMap<String, String>,
114    offset: StackOffset,
115    order: StackOrder,
116) -> Vec<ProjectedSeries> {
117    if series.is_empty() {
118        return Vec::new();
119    }
120
121    let row_count = series.first().map(|s| s.data.len()).unwrap_or(0);
122    let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
123    for (idx, s) in series.iter().enumerate() {
124        if let Some(group) = stack_groups.get(&s.id) {
125            groups.entry(group.clone()).or_default().push(idx);
126        }
127    }
128
129    let mut result: Vec<ProjectedSeries> = series
130        .iter()
131        .map(|s| ProjectedSeries {
132            id: s.id.clone(),
133            label: s.label.clone(),
134            data: if stack_groups.contains_key(&s.id) {
135                vec![0.0; row_count]
136            } else {
137                s.data.clone()
138            },
139        })
140        .collect();
141
142    for indices in groups.values() {
143        let ordered = apply_stack_order(indices, series, order);
144        for row in 0..row_count {
145            match offset {
146                StackOffset::Diverging => stack_row_diverging(&ordered, series, &mut result, row),
147                StackOffset::Expand => stack_row_expand(&ordered, series, &mut result, row),
148                StackOffset::None => stack_row_none(&ordered, series, &mut result, row),
149            }
150        }
151    }
152
153    result
154}
155
156fn stack_row_none(
157    ordered: &[usize],
158    series: &[ProjectedSeries],
159    result: &mut [ProjectedSeries],
160    row: usize,
161) {
162    let mut cumulative = 0.0;
163    for &idx in ordered {
164        let raw = series[idx].data.get(row).copied().unwrap_or(0.0);
165        cumulative += raw;
166        result[idx].data[row] = cumulative;
167    }
168}
169
170fn stack_row_expand(
171    ordered: &[usize],
172    series: &[ProjectedSeries],
173    result: &mut [ProjectedSeries],
174    row: usize,
175) {
176    let row_total: f64 = ordered
177        .iter()
178        .map(|&idx| series[idx].data.get(row).copied().unwrap_or(0.0))
179        .sum();
180    if row_total.abs() <= f64::EPSILON {
181        for &idx in ordered {
182            result[idx].data[row] = 0.0;
183        }
184        return;
185    }
186    let mut cumulative = 0.0;
187    for &idx in ordered {
188        let raw = series[idx].data.get(row).copied().unwrap_or(0.0);
189        cumulative += raw;
190        result[idx].data[row] = cumulative / row_total;
191    }
192}
193
194fn stack_row_diverging(
195    ordered: &[usize],
196    series: &[ProjectedSeries],
197    result: &mut [ProjectedSeries],
198    row: usize,
199) {
200    let mut pos_top = 0.0;
201    let mut neg_ceiling = 0.0;
202    for &idx in ordered {
203        let raw = series[idx].data.get(row).copied().unwrap_or(0.0);
204        if raw >= 0.0 {
205            pos_top += raw;
206            result[idx].data[row] = pos_top;
207        } else {
208            let y1 = neg_ceiling;
209            neg_ceiling += raw;
210            result[idx].data[row] = y1;
211        }
212    }
213}
214
215/// Bottom of a stacked segment at a row (cumulative of prior series in group).
216pub fn stack_segment_bottom(
217    raw_series: &[ProjectedSeries],
218    stacked: &[ProjectedSeries],
219    stack_groups: &HashMap<String, String>,
220    _series_defs: &[SeriesDef],
221    series_id: &str,
222    row: usize,
223    offset: StackOffset,
224    order: StackOrder,
225) -> f64 {
226    let Some(group) = stack_groups.get(series_id) else {
227        return 0.0;
228    };
229
230    let indices: Vec<usize> = stacked
231        .iter()
232        .enumerate()
233        .filter_map(|(idx, s)| (stack_groups.get(&s.id) == Some(group)).then_some(idx))
234        .collect();
235    let ordered = apply_stack_order(&indices, raw_series, order);
236
237    let raw = raw_series
238        .iter()
239        .find(|s| s.id == series_id)
240        .and_then(|s| s.data.get(row))
241        .copied()
242        .unwrap_or(0.0);
243
244    match offset {
245        StackOffset::Diverging => {
246            if raw >= 0.0 {
247                let mut bottom = 0.0;
248                for &idx in &ordered {
249                    if raw_series[idx].id == series_id {
250                        break;
251                    }
252                    let prior_raw = raw_series[idx].data.get(row).copied().unwrap_or(0.0);
253                    if prior_raw >= 0.0 {
254                        bottom = stacked[idx].data.get(row).copied().unwrap_or(bottom);
255                    }
256                }
257                bottom
258            } else {
259                stacked
260                    .iter()
261                    .find(|s| s.id == series_id)
262                    .and_then(|s| s.data.get(row))
263                    .copied()
264                    .unwrap_or(0.0)
265                    + raw
266            }
267        }
268        _ => {
269            let mut bottom = 0.0;
270            for s in stacked {
271                if stack_groups.get(&s.id) != Some(group) {
272                    continue;
273                }
274                if s.id == series_id {
275                    break;
276                }
277                bottom = s.data.get(row).copied().unwrap_or(bottom);
278            }
279            bottom
280        }
281    }
282}
283
284/// Convenience wrapper using stack config resolved from series definitions.
285pub fn stack_segment_bottom_for_series(
286    raw_series: &[ProjectedSeries],
287    stacked: &[ProjectedSeries],
288    stack_groups: &HashMap<String, String>,
289    series_defs: &[SeriesDef],
290    series_id: &str,
291    row: usize,
292) -> f64 {
293    let Some(group) = stack_groups.get(series_id) else {
294        return 0.0;
295    };
296    let chart_type = series_defs
297        .iter()
298        .find(|s| s.id == series_id)
299        .and_then(|s| s.chart_type)
300        .unwrap_or(ChartType::Bar);
301    let (offset, order) = resolve_stack_config(group, series_defs, chart_type);
302    stack_segment_bottom(
303        raw_series,
304        stacked,
305        stack_groups,
306        series_defs,
307        series_id,
308        row,
309        offset,
310        order,
311    )
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn sample() -> (Vec<ProjectedSeries>, HashMap<String, String>) {
319        let series = vec![
320            ProjectedSeries {
321                id: "a".into(),
322                label: "A".into(),
323                data: vec![10.0, 20.0],
324            },
325            ProjectedSeries {
326                id: "b".into(),
327                label: "B".into(),
328                data: vec![5.0, 10.0],
329            },
330        ];
331        let groups = HashMap::from([("a".into(), "g".into()), ("b".into(), "g".into())]);
332        (series, groups)
333    }
334
335    #[test]
336    fn stack_cumulative_values() {
337        let (series, groups) = sample();
338        let stacked = stack_series(&series, &groups, StackOffset::None, StackOrder::None);
339        assert_eq!(stacked[0].data, vec![10.0, 20.0]);
340        assert_eq!(stacked[1].data, vec![15.0, 30.0]);
341    }
342
343    #[test]
344    fn expand_normalizes_to_one() {
345        let (series, groups) = sample();
346        let stacked = stack_series(&series, &groups, StackOffset::Expand, StackOrder::None);
347        assert!((stacked[1].data[0] - 1.0).abs() < f64::EPSILON);
348        let seg_a = stacked[0].data[0]
349            - stack_segment_bottom(
350                &series,
351                &stacked,
352                &groups,
353                &[],
354                "a",
355                0,
356                StackOffset::Expand,
357                StackOrder::None,
358            );
359        let seg_b = stacked[1].data[0]
360            - stack_segment_bottom(
361                &series,
362                &stacked,
363                &groups,
364                &[],
365                "b",
366                0,
367                StackOffset::Expand,
368                StackOrder::None,
369            );
370        assert!((seg_a + seg_b - 1.0).abs() < f64::EPSILON);
371    }
372
373    #[test]
374    fn diverging_splits_signs() {
375        let series = vec![
376            ProjectedSeries {
377                id: "pos".into(),
378                label: "Pos".into(),
379                data: vec![10.0],
380            },
381            ProjectedSeries {
382                id: "neg".into(),
383                label: "Neg".into(),
384                data: vec![-5.0],
385            },
386        ];
387        let groups = HashMap::from([("pos".into(), "g".into()), ("neg".into(), "g".into())]);
388        let stacked = stack_series(&series, &groups, StackOffset::Diverging, StackOrder::None);
389        assert_eq!(stacked[0].data, vec![10.0]);
390        assert_eq!(stacked[1].data, vec![0.0]);
391        let neg_bottom = stack_segment_bottom(
392            &series,
393            &stacked,
394            &groups,
395            &[],
396            "neg",
397            0,
398            StackOffset::Diverging,
399            StackOrder::None,
400        );
401        assert_eq!(neg_bottom, -5.0);
402    }
403
404    #[test]
405    fn stack_order_reverse() {
406        let (series, groups) = sample();
407        let stacked = stack_series(&series, &groups, StackOffset::None, StackOrder::Reverse);
408        assert_eq!(stacked[0].data, vec![15.0, 30.0]);
409        assert_eq!(stacked[1].data, vec![5.0, 10.0]);
410    }
411
412    #[test]
413    fn resolve_stack_config_bar_defaults_diverging() {
414        let defs = vec![SeriesDef {
415            id: "a".into(),
416            stack_group: Some("g".into()),
417            chart_type: Some(ChartType::Bar),
418            ..Default::default()
419        }];
420        let (offset, order) = resolve_stack_config("g", &defs, ChartType::Bar);
421        assert_eq!(offset, StackOffset::Diverging);
422        assert_eq!(order, StackOrder::None);
423    }
424}