Skip to main content

ggplot_rs/position/
stack.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3
4use crate::data::{DataFrame, Value};
5
6use super::{Position, PositionParams};
7
8/// Stack bars/areas on top of each other (ggplot2's `position_stack(vjust
9/// = )`).
10///
11/// `vjust` places the row's `y` within its stacked segment: `1` (the
12/// default) puts it at the segment's top — the pre-B3-4 behaviour, and
13/// byte-identical (no `ymax` column is written). Anything else writes
14/// `y = (1 − vjust)·ymin + vjust·ymax` and an `ymax` column holding the
15/// segment's top, so e.g. `vjust(0.5)` centres a label in its segment.
16///
17/// `PositionStack` is also usable as a bare value (`vjust` 1, as before
18/// the struct gained fields) via the [`PositionStack`](const@PositionStack)
19/// const.
20#[derive(Clone, Debug, PartialEq)]
21pub struct PositionStack {
22    pub vjust: f64,
23    /// Baseline of each x's stack ([`StackOffset`]); `Zero` by default.
24    pub offset: StackOffset,
25}
26
27/// Where each x's stack starts (d3-shape's stack offsets). Non-`Zero`
28/// offsets are meant for non-negative series (streamgraphs).
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
30pub enum StackOffset {
31    /// Stacks rise from 0 (ggplot2's `position_stack`).
32    #[default]
33    Zero,
34    /// Stacks are centred on 0 (ThemeRiver).
35    Silhouette,
36    /// Byron & Wattenberg's wiggle-minimising baseline (d3's
37    /// `stackOffsetWiggle`), re-centred on 0 — the classic streamgraph.
38    Wiggle,
39}
40
41impl Default for PositionStack {
42    fn default() -> Self {
43        PositionStack {
44            vjust: 1.0,
45            offset: StackOffset::Zero,
46        }
47    }
48}
49
50/// The default stack (`vjust` 1): lets `.position(PositionStack)` keep
51/// compiling now that the type has fields.
52#[allow(non_upper_case_globals)]
53pub const PositionStack: PositionStack = PositionStack {
54    vjust: 1.0,
55    offset: StackOffset::Zero,
56};
57
58impl PositionStack {
59    /// Place `y` within each stacked segment (`0` = bottom, `0.5` =
60    /// middle, `1` = top, the default).
61    pub fn with_vjust(mut self, vjust: f64) -> Self {
62        self.vjust = vjust;
63        self
64    }
65
66    /// Shift each x's stack to the given baseline ([`StackOffset`]).
67    pub fn with_offset(mut self, offset: StackOffset) -> Self {
68        self.offset = offset;
69        self
70    }
71}
72
73/// Per-row baseline shift for a non-`Zero` [`StackOffset`]: every x's stack
74/// (rows in data order, the first row on top) moves by the same amount.
75fn baseline_shift(
76    offset: StackOffset,
77    data: &DataFrame,
78    x_col: &[Value],
79    y_col: &[Value],
80) -> Vec<f64> {
81    // Distinct x in increasing order (numeric x by value, otherwise by
82    // first appearance), and each row's x index.
83    let mut xs: Vec<(String, Option<f64>)> = Vec::new();
84    let mut x_index: HashMap<String, usize> = HashMap::new();
85    for x in x_col {
86        let k = x.key_str().into_owned();
87        if !x_index.contains_key(&k) {
88            x_index.insert(k.clone(), xs.len());
89            xs.push((k, x.as_f64()));
90        }
91    }
92    let mut order: Vec<usize> = (0..xs.len()).collect();
93    if xs.iter().all(|x| x.1.is_some()) {
94        order.sort_by(|&a, &b| xs[a].1.unwrap().total_cmp(&xs[b].1.unwrap()));
95    }
96    let mut slot = vec![0; xs.len()];
97    for (j, &k) in order.iter().enumerate() {
98        slot[k] = j;
99    }
100    let row_slot: Vec<usize> = x_col
101        .iter()
102        .map(|x| slot[x_index[x.key_str().as_ref()]])
103        .collect();
104    let m = xs.len();
105
106    // Series (bottom → top): the stack puts the first-seen series on top.
107    let series_col = ["group", "fill", "color"]
108        .iter()
109        .find_map(|c| data.column(c));
110    let mut series: Vec<String> = Vec::new();
111    let mut s_index: HashMap<String, usize> = HashMap::new();
112    let row_series: Vec<usize> = (0..x_col.len())
113        .map(|i| {
114            let k = series_col
115                .map(|c| c[i].key_str().into_owned())
116                .unwrap_or_default();
117            *s_index.entry(k.clone()).or_insert_with(|| {
118                series.push(k);
119                series.len() - 1
120            })
121        })
122        .collect();
123    let n = series.len();
124    let mut v = vec![vec![0.0; m]; n];
125    for i in 0..x_col.len() {
126        if let Some(y) = super::stack_row(&y_col[i]).value() {
127            v[n - 1 - row_series[i]][row_slot[i]] += y.max(0.0);
128        }
129    }
130    let totals: Vec<f64> = (0..m).map(|j| (0..n).map(|s| v[s][j]).sum()).collect();
131    let base: Vec<f64> = match offset {
132        StackOffset::Zero => vec![0.0; m],
133        StackOffset::Silhouette => totals.iter().map(|t| -t / 2.0).collect(),
134        StackOffset::Wiggle => {
135            let mut g = vec![0.0; m];
136            let mut y = 0.0;
137            for j in 1..m {
138                let (mut s1, mut s2) = (0.0, 0.0);
139                for i in 0..n {
140                    let mut s3 = (v[i][j] - v[i][j - 1]) / 2.0;
141                    for row in v.iter().take(i) {
142                        s3 += row[j] - row[j - 1];
143                    }
144                    s1 += v[i][j];
145                    s2 += s3 * v[i][j];
146                }
147                if s1 != 0.0 {
148                    y -= s2 / s1;
149                }
150                g[j] = y;
151            }
152            // Re-centre: the stream's mid-line averages 0.
153            let mid =
154                g.iter().zip(&totals).map(|(g, t)| g + t / 2.0).sum::<f64>() / m.max(1) as f64;
155            g.iter().map(|g| g - mid).collect()
156        }
157    };
158    row_slot.iter().map(|&j| base[j]).collect()
159}
160
161/// `position_stack()` / `position_stack().with_vjust(v)` — see
162/// [`PositionStack`](struct@PositionStack).
163pub fn position_stack() -> PositionStack {
164    PositionStack::default()
165}
166
167impl Position for PositionStack {
168    fn compute(&self, data: &mut DataFrame, _params: &PositionParams) {
169        // Group by x, accumulate y values
170        let x_col = match data.column("x") {
171            Some(c) => c.to_vec(),
172            None => return,
173        };
174        let y_col = match data.column("y") {
175            Some(c) => c.to_vec(),
176            None => return,
177        };
178        super::preserve_raw_y(data, &y_col);
179
180        let n = y_col.len();
181        let mut tops = vec![0.0_f64; n];
182        let mut bottoms = vec![0.0_f64; n];
183
184        // ggplot2 stacks a group's positive and negative values
185        // independently within each x (#154, ggplot2's internal
186        // `pos`/`neg` split in `collide`/`pos_stack`): run the same top-
187        // down, legend-order accumulation twice, once over the rows
188        // with y >= 0 and once over y < 0, so e.g. a bar chart mixing a
189        // group's positive and negative segments at the same x gets two
190        // independent stacks (one above zero, one below) instead of a
191        // single pooled running total that lets a negative row cancel
192        // into a positive one's segment. ggplot2 stacks the first group
193        // at the top of its bucket (so top-to-bottom matches the
194        // legend), so each bucket still accumulates downward from its
195        // own total rather than upward from 0 — "top"/"bottom" here are
196        // in that accumulation order, not value order (a negative
197        // bucket's "top", nearer 0, has the smaller `tops[i]`), so
198        // `ymin`/`ymax`/`y` below always take the min/max of the pair
199        // rather than assume `tops[i] >= bottoms[i]`.
200        // Per-x accumulators keyed by the borrowed x key: O(1) per row
201        // instead of a linear scan over the distinct x values.
202        //
203        // A missing row (`super::stack_row`, review #167 finding 2 on
204        // #171) joins the positive bucket but contributes 0 to its
205        // total, and its own top/bottom are left NaN below — so it
206        // doesn't poison any other row's stack, matching ggplot2's
207        // `pos_stack` (`y <- ifelse(is.na(df$y), 0, df$y)`).
208        for positive in [true, false] {
209            let mut totals: HashMap<Cow<'_, str>, f64> = HashMap::new();
210            for (x, y) in x_col.iter().zip(y_col.iter()) {
211                let row = super::stack_row(y);
212                if row.is_positive_bucket() != positive {
213                    continue;
214                }
215                *totals.entry(x.key_str()).or_insert(0.0) += row.contribution();
216            }
217
218            let mut consumed: HashMap<Cow<'_, str>, f64> = HashMap::new();
219            for (i, (x, y)) in x_col.iter().zip(y_col.iter()).enumerate() {
220                let row = super::stack_row(y);
221                if row.is_positive_bucket() != positive {
222                    continue;
223                }
224                let x_key = x.key_str();
225                let total = totals.get(&x_key).copied().unwrap_or(0.0);
226                let run = consumed.get(&x_key).copied().unwrap_or(0.0);
227
228                let Some(y_val) = row.value() else {
229                    // Missing: draw nothing for this row (NA in
230                    // ggplot2), but don't advance `consumed` — it
231                    // contributed 0, so the next row in this bucket
232                    // still starts from the same `run`.
233                    tops[i] = f64::NAN;
234                    bottoms[i] = f64::NAN;
235                    continue;
236                };
237
238                // This group occupies [total - run - y, total - run]
239                // within its own (same-sign) bucket.
240                tops[i] = total - run;
241                bottoms[i] = total - run - y_val;
242
243                *consumed.entry(x_key).or_insert(0.0) += y_val;
244            }
245        }
246
247        if self.offset != StackOffset::Zero {
248            let shift = baseline_shift(self.offset, data, &x_col, &y_col);
249            for i in 0..n {
250                tops[i] += shift[i];
251                bottoms[i] += shift[i];
252            }
253        }
254
255        // A non-finite vjust is validated (and warned on) separately; here
256        // it falls back to 1 (the byte-identical top-anchored path).
257        let vjust = if self.vjust.is_finite() {
258            self.vjust
259        } else {
260            1.0
261        };
262
263        let write_ymax = (vjust - 1.0).abs() > f64::EPSILON;
264        let lo_hi: Vec<(f64, f64)> = tops
265            .iter()
266            .zip(bottoms.iter())
267            .map(|(&t, &b)| (t.min(b), t.max(b)))
268            .collect();
269        let new_y: Vec<Value> = if write_ymax {
270            lo_hi
271                .iter()
272                .map(|&(lo, hi)| Value::Float((1.0 - vjust) * lo + vjust * hi))
273                .collect()
274        } else {
275            lo_hi.iter().map(|&(_, hi)| Value::Float(hi)).collect()
276        };
277        let ymin_vals: Vec<Value> = lo_hi.iter().map(|&(lo, _)| Value::Float(lo)).collect();
278
279        if let Some(col) = data.column_mut("y") {
280            *col = new_y;
281        }
282        if !data.has_column("ymin") {
283            data.add_column("ymin".to_string(), ymin_vals);
284        } else if let Some(col) = data.column_mut("ymin") {
285            *col = ymin_vals;
286        }
287        if write_ymax {
288            let ymax_vals: Vec<Value> = lo_hi.iter().map(|&(_, hi)| Value::Float(hi)).collect();
289            if !data.has_column("ymax") {
290                data.add_column("ymax".to_string(), ymax_vals);
291            } else if let Some(col) = data.column_mut("ymax") {
292                *col = ymax_vals;
293            }
294        }
295    }
296
297    fn name(&self) -> &str {
298        "stack"
299    }
300
301    fn per_panel(&self) -> bool {
302        true
303    }
304
305    fn validate(&self, warnings: &mut Vec<String>) {
306        if !self.vjust.is_finite() {
307            warnings.push(format!(
308                "position_stack: vjust({}) must be finite; falling back to 1",
309                self.vjust
310            ));
311        }
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn frame(x: &[f64], g: &[&str], y: &[f64]) -> DataFrame {
320        let mut df = DataFrame::new();
321        df.add_column("x".into(), x.iter().map(|v| Value::Float(*v)).collect());
322        df.add_column(
323            "fill".into(),
324            g.iter().map(|s| Value::Str(s.to_string())).collect(),
325        );
326        df.add_column("y".into(), y.iter().map(|v| Value::Float(*v)).collect());
327        df
328    }
329
330    fn col(df: &DataFrame, c: &str) -> Vec<f64> {
331        df.column(c)
332            .unwrap()
333            .iter()
334            .map(|v| v.as_f64().unwrap())
335            .collect()
336    }
337
338    /// R parity (ggplot2 4.0.3, `layer_data()`): x=1, fill groups a/b/c/d
339    /// with y = 3, -2, 4, -1 and `position_stack(vjust = 0.5)` gives
340    /// y/ymin/ymax = (5.5, 4, 7), (-2, -3, -1), (2, 0, 4), (-0.5, -1, 0)
341    /// (#154) — positives (a, c) stack above 0 using only their own sum
342    /// (7) as the total; negatives (b, d) stack below 0 using only their
343    /// own sum (-3), independently.
344    #[test]
345    fn negative_and_positive_values_stack_independently() {
346        let mut df = frame(&[1.0; 4], &["a", "b", "c", "d"], &[3.0, -2.0, 4.0, -1.0]);
347        PositionStack::default()
348            .with_vjust(0.5)
349            .compute(&mut df, &PositionParams::default());
350        let y = col(&df, "y");
351        let ymin = col(&df, "ymin");
352        let ymax = col(&df, "ymax");
353        for (i, (want_y, want_min, want_max)) in [
354            (5.5, 4.0, 7.0),
355            (-2.0, -3.0, -1.0),
356            (2.0, 0.0, 4.0),
357            (-0.5, -1.0, 0.0),
358        ]
359        .into_iter()
360        .enumerate()
361        {
362            assert!((y[i] - want_y).abs() < 1e-9, "y[{i}]={}", y[i]);
363            assert!((ymin[i] - want_min).abs() < 1e-9, "ymin[{i}]={}", ymin[i]);
364            assert!((ymax[i] - want_max).abs() < 1e-9, "ymax[{i}]={}", ymax[i]);
365        }
366    }
367
368    /// All-positive data (the common case) is byte-identical to the
369    /// pre-#154 pooled algorithm: the negative bucket is simply empty.
370    #[test]
371    fn all_positive_values_are_unaffected() {
372        let mut df = frame(&[1.0, 1.0], &["a", "b"], &[3.0, 5.0]);
373        PositionStack.compute(&mut df, &PositionParams::default());
374        assert_eq!(col(&df, "y"), vec![8.0, 5.0]);
375        assert_eq!(col(&df, "ymin"), vec![5.0, 0.0]);
376    }
377
378    /// Review #167 finding 2 on sipemu/ggplot-rs#171 (originally #170's
379    /// NaN bucket fix, which got the *bucket* right but the *arithmetic*
380    /// wrong): a missing `y` joins the positive bucket, like ggplot2,
381    /// but contributes **0** to its running total — not the raw NaN —
382    /// so the *other* row sharing that bucket (`a`) stacks exactly as if
383    /// `b` weren't there at all, instead of turning into NaN too.
384    /// ggplot2 4.0.3 (`layer_data()`, `pos_stack`'s `y <-
385    /// ifelse(is.na(df$y), 0, df$y)`): a=3, b=NaN, c=-2 gives a 0..3,
386    /// c -2..0, b's own y/ymin left `NA`. `c` (the negative bucket) was
387    /// already unaffected either way — it shares no bucket with `b`.
388    #[test]
389    fn missing_y_contributes_zero_and_does_not_poison_its_bucket() {
390        let mut df = frame(&[1.0, 1.0, 1.0], &["a", "b", "c"], &[3.0, f64::NAN, -2.0]);
391        PositionStack.compute(&mut df, &PositionParams::default());
392        let y = col(&df, "y");
393        let ymin = col(&df, "ymin");
394        assert_eq!(
395            (y[0], ymin[0]),
396            (3.0, 0.0),
397            "a must stack as if b's row weren't there at all: y={y:?} ymin={ymin:?}"
398        );
399        assert!(
400            y[1].is_nan() && ymin[1].is_nan(),
401            "b's own y/ymin must stay NaN (not drawn): y={y:?} ymin={ymin:?}"
402        );
403        assert_eq!(
404            (y[2], ymin[2]),
405            (0.0, -2.0),
406            "c (negative bucket) must be unaffected: y={y:?} ymin={ymin:?}"
407        );
408    }
409
410    /// The pre-existing bug finding 2 also fixes: a `Value::Na` `y` (not
411    /// just a `Float(NaN)`) used to fall back to `0.0` via
412    /// `as_f64().unwrap_or(0.0)` and get stacked as a *real* point at
413    /// the top of its bucket, drawing a line straight through a
414    /// supposedly-removed row. It must now be treated exactly like a
415    /// `Float(NaN)` — contributing 0, with its own output left NaN.
416    #[test]
417    fn value_na_is_treated_like_float_nan_not_zero() {
418        let mut df = DataFrame::new();
419        df.add_column(
420            "x".into(),
421            vec![Value::Float(1.0), Value::Float(1.0), Value::Float(1.0)],
422        );
423        df.add_column(
424            "fill".into(),
425            vec![
426                Value::Str("a".into()),
427                Value::Str("b".into()),
428                Value::Str("c".into()),
429            ],
430        );
431        df.add_column(
432            "y".into(),
433            vec![Value::Float(3.0), Value::Na, Value::Float(-2.0)],
434        );
435        PositionStack.compute(&mut df, &PositionParams::default());
436        let y = col(&df, "y");
437        let ymin = col(&df, "ymin");
438        assert_eq!(
439            (y[0], ymin[0]),
440            (3.0, 0.0),
441            "a must be unaffected by b's Value::Na: y={y:?} ymin={ymin:?}"
442        );
443        assert!(
444            y[1].is_nan() && ymin[1].is_nan(),
445            "a Value::Na row must not become a real point at the stack top: \
446             y={y:?} ymin={ymin:?}"
447        );
448    }
449
450    #[test]
451    fn silhouette_centres_each_stack_on_zero() {
452        let mut df = frame(
453            &[1.0, 1.0, 2.0, 2.0],
454            &["a", "b", "a", "b"],
455            &[2.0, 4.0, 1.0, 5.0],
456        );
457        position_stack()
458            .with_offset(StackOffset::Silhouette)
459            .compute(&mut df, &PositionParams::default());
460        // x=1: total 6 → [-3, 3]; "a" on top: [1, 3], "b" [-3, 1].
461        assert_eq!(col(&df, "ymin"), vec![1.0, -3.0, 2.0, -3.0]);
462        assert_eq!(col(&df, "y"), vec![3.0, 1.0, 3.0, 2.0]);
463    }
464
465    #[test]
466    fn wiggle_is_centred_and_keeps_thickness() {
467        let x = [1.0, 1.0, 2.0, 2.0, 3.0, 3.0];
468        let mut df = frame(
469            &x,
470            &["a", "b", "a", "b", "a", "b"],
471            &[1.0, 3.0, 4.0, 2.0, 2.0, 2.0],
472        );
473        position_stack()
474            .with_offset(StackOffset::Wiggle)
475            .compute(&mut df, &PositionParams::default());
476        let (lo, hi) = (col(&df, "ymin"), col(&df, "y"));
477        for i in 0..6 {
478            let want = [1.0, 3.0, 4.0, 2.0, 2.0, 2.0][i];
479            assert!((hi[i] - lo[i] - want).abs() < 1e-12, "row {i}");
480        }
481        // Mid-lines of the three stacks average 0.
482        let mid: f64 = [0usize, 2, 4]
483            .iter()
484            .map(|&i| (hi[i] + lo[i + 1]) / 2.0)
485            .sum::<f64>();
486        assert!(mid.abs() < 1e-9, "{mid}");
487    }
488}