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        let seq = super::stacking_sequence(data, params.group_order.as_deref());
209        for positive in [true, false] {
210            let mut totals: HashMap<Cow<'_, str>, f64> = HashMap::new();
211            for (x, y) in x_col.iter().zip(y_col.iter()) {
212                let row = super::stack_row(y);
213                if row.is_positive_bucket() != positive {
214                    continue;
215                }
216                *totals.entry(x.key_str()).or_insert(0.0) += row.contribution();
217            }
218
219            let mut consumed: HashMap<Cow<'_, str>, f64> = HashMap::new();
220            for &i in &seq {
221                let (x, y) = (&x_col[i], &y_col[i]);
222                let row = super::stack_row(y);
223                if row.is_positive_bucket() != positive {
224                    continue;
225                }
226                let x_key = x.key_str();
227                let total = totals.get(&x_key).copied().unwrap_or(0.0);
228                let run = consumed.get(&x_key).copied().unwrap_or(0.0);
229
230                let Some(y_val) = row.value() else {
231                    // Missing: draw nothing for this row (NA in
232                    // ggplot2), but don't advance `consumed` — it
233                    // contributed 0, so the next row in this bucket
234                    // still starts from the same `run`.
235                    tops[i] = f64::NAN;
236                    bottoms[i] = f64::NAN;
237                    continue;
238                };
239
240                // This group occupies [total - run - y, total - run]
241                // within its own (same-sign) bucket.
242                tops[i] = total - run;
243                bottoms[i] = total - run - y_val;
244
245                *consumed.entry(x_key).or_insert(0.0) += y_val;
246            }
247        }
248
249        if self.offset != StackOffset::Zero {
250            let shift = baseline_shift(self.offset, data, &x_col, &y_col);
251            for i in 0..n {
252                tops[i] += shift[i];
253                bottoms[i] += shift[i];
254            }
255        }
256
257        // A non-finite vjust is validated (and warned on) separately; here
258        // it falls back to 1 (the byte-identical top-anchored path).
259        let vjust = if self.vjust.is_finite() {
260            self.vjust
261        } else {
262            1.0
263        };
264
265        let write_ymax = (vjust - 1.0).abs() > f64::EPSILON;
266        let lo_hi: Vec<(f64, f64)> = tops
267            .iter()
268            .zip(bottoms.iter())
269            .map(|(&t, &b)| (t.min(b), t.max(b)))
270            .collect();
271        let new_y: Vec<Value> = if write_ymax {
272            lo_hi
273                .iter()
274                .map(|&(lo, hi)| Value::Float((1.0 - vjust) * lo + vjust * hi))
275                .collect()
276        } else {
277            lo_hi.iter().map(|&(_, hi)| Value::Float(hi)).collect()
278        };
279        let ymin_vals: Vec<Value> = lo_hi.iter().map(|&(lo, _)| Value::Float(lo)).collect();
280
281        if let Some(col) = data.column_mut("y") {
282            *col = new_y;
283        }
284        if !data.has_column("ymin") {
285            data.add_column("ymin".to_string(), ymin_vals);
286        } else if let Some(col) = data.column_mut("ymin") {
287            *col = ymin_vals;
288        }
289        if write_ymax {
290            let ymax_vals: Vec<Value> = lo_hi.iter().map(|&(_, hi)| Value::Float(hi)).collect();
291            if !data.has_column("ymax") {
292                data.add_column("ymax".to_string(), ymax_vals);
293            } else if let Some(col) = data.column_mut("ymax") {
294                *col = ymax_vals;
295            }
296        }
297    }
298
299    fn name(&self) -> &str {
300        "stack"
301    }
302
303    fn group_order(&self, data: &DataFrame) -> Option<Vec<String>> {
304        super::dodge::group_keys(data).map(|keys| super::dodge::dedup_order(&keys))
305    }
306
307    fn per_panel(&self) -> bool {
308        true
309    }
310
311    fn validate(&self, warnings: &mut Vec<String>) {
312        if !self.vjust.is_finite() {
313            warnings.push(format!(
314                "position_stack: vjust({}) must be finite; falling back to 1",
315                self.vjust
316            ));
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn frame(x: &[f64], g: &[&str], y: &[f64]) -> DataFrame {
326        let mut df = DataFrame::new();
327        df.add_column("x".into(), x.iter().map(|v| Value::Float(*v)).collect());
328        df.add_column(
329            "fill".into(),
330            g.iter().map(|s| Value::Str(s.to_string())).collect(),
331        );
332        df.add_column("y".into(), y.iter().map(|v| Value::Float(*v)).collect());
333        df
334    }
335
336    fn col(df: &DataFrame, c: &str) -> Vec<f64> {
337        df.column(c)
338            .unwrap()
339            .iter()
340            .map(|v| v.as_f64().unwrap())
341            .collect()
342    }
343
344    /// R parity (ggplot2 4.0.3, `layer_data()`): x=1, fill groups a/b/c/d
345    /// with y = 3, -2, 4, -1 and `position_stack(vjust = 0.5)` gives
346    /// y/ymin/ymax = (5.5, 4, 7), (-2, -3, -1), (2, 0, 4), (-0.5, -1, 0)
347    /// (#154) — positives (a, c) stack above 0 using only their own sum
348    /// (7) as the total; negatives (b, d) stack below 0 using only their
349    /// own sum (-3), independently.
350    #[test]
351    fn negative_and_positive_values_stack_independently() {
352        let mut df = frame(&[1.0; 4], &["a", "b", "c", "d"], &[3.0, -2.0, 4.0, -1.0]);
353        PositionStack::default()
354            .with_vjust(0.5)
355            .compute(&mut df, &PositionParams::default());
356        let y = col(&df, "y");
357        let ymin = col(&df, "ymin");
358        let ymax = col(&df, "ymax");
359        for (i, (want_y, want_min, want_max)) in [
360            (5.5, 4.0, 7.0),
361            (-2.0, -3.0, -1.0),
362            (2.0, 0.0, 4.0),
363            (-0.5, -1.0, 0.0),
364        ]
365        .into_iter()
366        .enumerate()
367        {
368            assert!((y[i] - want_y).abs() < 1e-9, "y[{i}]={}", y[i]);
369            assert!((ymin[i] - want_min).abs() < 1e-9, "ymin[{i}]={}", ymin[i]);
370            assert!((ymax[i] - want_max).abs() < 1e-9, "ymax[{i}]={}", ymax[i]);
371        }
372    }
373
374    /// All-positive data (the common case) is byte-identical to the
375    /// pre-#154 pooled algorithm: the negative bucket is simply empty.
376    #[test]
377    fn all_positive_values_are_unaffected() {
378        let mut df = frame(&[1.0, 1.0], &["a", "b"], &[3.0, 5.0]);
379        PositionStack.compute(&mut df, &PositionParams::default());
380        assert_eq!(col(&df, "y"), vec![8.0, 5.0]);
381        assert_eq!(col(&df, "ymin"), vec![5.0, 0.0]);
382    }
383
384    /// Review #167 finding 2 on sipemu/ggplot-rs#171 (originally #170's
385    /// NaN bucket fix, which got the *bucket* right but the *arithmetic*
386    /// wrong): a missing `y` joins the positive bucket, like ggplot2,
387    /// but contributes **0** to its running total — not the raw NaN —
388    /// so the *other* row sharing that bucket (`a`) stacks exactly as if
389    /// `b` weren't there at all, instead of turning into NaN too.
390    /// ggplot2 4.0.3 (`layer_data()`, `pos_stack`'s `y <-
391    /// ifelse(is.na(df$y), 0, df$y)`): a=3, b=NaN, c=-2 gives a 0..3,
392    /// c -2..0, b's own y/ymin left `NA`. `c` (the negative bucket) was
393    /// already unaffected either way — it shares no bucket with `b`.
394    #[test]
395    fn missing_y_contributes_zero_and_does_not_poison_its_bucket() {
396        let mut df = frame(&[1.0, 1.0, 1.0], &["a", "b", "c"], &[3.0, f64::NAN, -2.0]);
397        PositionStack.compute(&mut df, &PositionParams::default());
398        let y = col(&df, "y");
399        let ymin = col(&df, "ymin");
400        assert_eq!(
401            (y[0], ymin[0]),
402            (3.0, 0.0),
403            "a must stack as if b's row weren't there at all: y={y:?} ymin={ymin:?}"
404        );
405        assert!(
406            y[1].is_nan() && ymin[1].is_nan(),
407            "b's own y/ymin must stay NaN (not drawn): y={y:?} ymin={ymin:?}"
408        );
409        assert_eq!(
410            (y[2], ymin[2]),
411            (0.0, -2.0),
412            "c (negative bucket) must be unaffected: y={y:?} ymin={ymin:?}"
413        );
414    }
415
416    /// The pre-existing bug finding 2 also fixes: a `Value::Na` `y` (not
417    /// just a `Float(NaN)`) used to fall back to `0.0` via
418    /// `as_f64().unwrap_or(0.0)` and get stacked as a *real* point at
419    /// the top of its bucket, drawing a line straight through a
420    /// supposedly-removed row. It must now be treated exactly like a
421    /// `Float(NaN)` — contributing 0, with its own output left NaN.
422    #[test]
423    fn value_na_is_treated_like_float_nan_not_zero() {
424        let mut df = DataFrame::new();
425        df.add_column(
426            "x".into(),
427            vec![Value::Float(1.0), Value::Float(1.0), Value::Float(1.0)],
428        );
429        df.add_column(
430            "fill".into(),
431            vec![
432                Value::Str("a".into()),
433                Value::Str("b".into()),
434                Value::Str("c".into()),
435            ],
436        );
437        df.add_column(
438            "y".into(),
439            vec![Value::Float(3.0), Value::Na, Value::Float(-2.0)],
440        );
441        PositionStack.compute(&mut df, &PositionParams::default());
442        let y = col(&df, "y");
443        let ymin = col(&df, "ymin");
444        assert_eq!(
445            (y[0], ymin[0]),
446            (3.0, 0.0),
447            "a must be unaffected by b's Value::Na: y={y:?} ymin={ymin:?}"
448        );
449        assert!(
450            y[1].is_nan() && ymin[1].is_nan(),
451            "a Value::Na row must not become a real point at the stack top: \
452             y={y:?} ymin={ymin:?}"
453        );
454    }
455
456    #[test]
457    fn silhouette_centres_each_stack_on_zero() {
458        let mut df = frame(
459            &[1.0, 1.0, 2.0, 2.0],
460            &["a", "b", "a", "b"],
461            &[2.0, 4.0, 1.0, 5.0],
462        );
463        position_stack()
464            .with_offset(StackOffset::Silhouette)
465            .compute(&mut df, &PositionParams::default());
466        // x=1: total 6 → [-3, 3]; "a" on top: [1, 3], "b" [-3, 1].
467        assert_eq!(col(&df, "ymin"), vec![1.0, -3.0, 2.0, -3.0]);
468        assert_eq!(col(&df, "y"), vec![3.0, 1.0, 3.0, 2.0]);
469    }
470
471    #[test]
472    fn wiggle_is_centred_and_keeps_thickness() {
473        let x = [1.0, 1.0, 2.0, 2.0, 3.0, 3.0];
474        let mut df = frame(
475            &x,
476            &["a", "b", "a", "b", "a", "b"],
477            &[1.0, 3.0, 4.0, 2.0, 2.0, 2.0],
478        );
479        position_stack()
480            .with_offset(StackOffset::Wiggle)
481            .compute(&mut df, &PositionParams::default());
482        let (lo, hi) = (col(&df, "ymin"), col(&df, "y"));
483        for i in 0..6 {
484            let want = [1.0, 3.0, 4.0, 2.0, 2.0, 2.0][i];
485            assert!((hi[i] - lo[i] - want).abs() < 1e-12, "row {i}");
486        }
487        // Mid-lines of the three stacks average 0.
488        let mid: f64 = [0usize, 2, 4]
489            .iter()
490            .map(|&i| (hi[i] + lo[i + 1]) / 2.0)
491            .sum::<f64>();
492        assert!(mid.abs() < 1e-9, "{mid}");
493    }
494}
495
496#[cfg(test)]
497mod level_order_tests {
498    /// The stacked rects' (fill, ymin) pairs per x in an SVG, bottom first.
499    fn stacks(svg: &str) -> Vec<Vec<String>> {
500        let mut bars: Vec<(f64, f64, String)> = Vec::new();
501        for tag in svg
502            .split('<')
503            .filter(|t| t.starts_with("rect") && t.contains("gg-bar"))
504        {
505            let attr = |n: &str| {
506                let k = format!(" {n}=\"");
507                tag.find(&k).map(|i| {
508                    let r = &tag[i + k.len()..];
509                    r[..r.find('"').unwrap()].to_string()
510                })
511            };
512            let (Some(x), Some(y), Some(fill)) = (attr("x"), attr("y"), attr("fill")) else {
513                continue;
514            };
515            bars.push((x.parse().unwrap(), y.parse().unwrap(), fill));
516        }
517        let mut xs: Vec<f64> = bars.iter().map(|b| b.0).collect();
518        xs.sort_by(f64::total_cmp);
519        xs.dedup();
520        xs.iter()
521            .map(|&x| {
522                let mut col: Vec<&(f64, f64, String)> = bars.iter().filter(|b| b.0 == x).collect();
523                // svg y grows down: the bottom segment has the largest y
524                col.sort_by(|a, b| b.1.total_cmp(&a.1));
525                col.iter().map(|b| b.2.clone()).collect()
526            })
527            .collect()
528    }
529
530    /// #496: every bar stacks in the fill levels' order, not row order.
531    #[test]
532    fn stacks_follow_the_fill_levels() {
533        use crate::aes::Aesthetic;
534        use crate::prelude::*;
535        use crate::scale::color::ScaleColorDiscrete;
536        let txt = |v: &[&str]| {
537            v.iter()
538                .map(|s| Value::Str(s.to_string()))
539                .collect::<Vec<_>>()
540        };
541        for fill in [false, true] {
542            let mut plot = GGPlot::new(vec![
543                ("x".to_string(), txt(&["a", "a", "a", "b", "b", "b"])),
544                (
545                    "n".to_string(),
546                    [1.0, 2.0, 3.0, 3.0, 2.0, 1.0].map(Value::Float).to_vec(),
547                ),
548                (
549                    "k".to_string(),
550                    txt(&["ok", "miss", "early", "early", "ok", "miss"]),
551                ),
552            ])
553            .aes(Aes::new().x("x").y("n").fill("k"))
554            .scale_fill(ScaleColorDiscrete::new(Aesthetic::Fill).with_levels(vec![
555                "miss".into(),
556                "early".into(),
557                "ok".into(),
558            ]));
559            plot = if fill {
560                plot.geom_col()
561                    .position(crate::position::fill::PositionFill::default())
562            } else {
563                plot.geom_col().position(super::position_stack())
564            };
565            let svg = plot.render_svg_native_at(0.0, 0.0, 300, 200).unwrap();
566            let s = stacks(&svg);
567            assert_eq!(s.len(), 2, "fill={fill}");
568            assert_eq!(s[0], s[1], "fill={fill}: both bars stack alike");
569        }
570    }
571}