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}
24
25impl Default for PositionStack {
26    fn default() -> Self {
27        PositionStack { vjust: 1.0 }
28    }
29}
30
31/// The default stack (`vjust` 1): lets `.position(PositionStack)` keep
32/// compiling now that the type has fields.
33#[allow(non_upper_case_globals)]
34pub const PositionStack: PositionStack = PositionStack { vjust: 1.0 };
35
36impl PositionStack {
37    /// Place `y` within each stacked segment (`0` = bottom, `0.5` =
38    /// middle, `1` = top, the default).
39    pub fn with_vjust(mut self, vjust: f64) -> Self {
40        self.vjust = vjust;
41        self
42    }
43}
44
45/// `position_stack()` / `position_stack().with_vjust(v)` — see
46/// [`PositionStack`](struct@PositionStack).
47pub fn position_stack() -> PositionStack {
48    PositionStack::default()
49}
50
51impl Position for PositionStack {
52    fn compute(&self, data: &mut DataFrame, _params: &PositionParams) {
53        // Group by x, accumulate y values
54        let x_col = match data.column("x") {
55            Some(c) => c.to_vec(),
56            None => return,
57        };
58        let y_col = match data.column("y") {
59            Some(c) => c.to_vec(),
60            None => return,
61        };
62        super::preserve_raw_y(data, &y_col);
63
64        let n = y_col.len();
65        let mut tops = vec![0.0_f64; n];
66        let mut bottoms = vec![0.0_f64; n];
67
68        // ggplot2 stacks a group's positive and negative values
69        // independently within each x (#154, ggplot2's internal
70        // `pos`/`neg` split in `collide`/`pos_stack`): run the same top-
71        // down, legend-order accumulation twice, once over the rows
72        // with y >= 0 and once over y < 0, so e.g. a bar chart mixing a
73        // group's positive and negative segments at the same x gets two
74        // independent stacks (one above zero, one below) instead of a
75        // single pooled running total that lets a negative row cancel
76        // into a positive one's segment. ggplot2 stacks the first group
77        // at the top of its bucket (so top-to-bottom matches the
78        // legend), so each bucket still accumulates downward from its
79        // own total rather than upward from 0 — "top"/"bottom" here are
80        // in that accumulation order, not value order (a negative
81        // bucket's "top", nearer 0, has the smaller `tops[i]`), so
82        // `ymin`/`ymax`/`y` below always take the min/max of the pair
83        // rather than assume `tops[i] >= bottoms[i]`.
84        // Per-x accumulators keyed by the borrowed x key: O(1) per row
85        // instead of a linear scan over the distinct x values.
86        //
87        // A missing row (`super::stack_row`, review #167 finding 2 on
88        // #171) joins the positive bucket but contributes 0 to its
89        // total, and its own top/bottom are left NaN below — so it
90        // doesn't poison any other row's stack, matching ggplot2's
91        // `pos_stack` (`y <- ifelse(is.na(df$y), 0, df$y)`).
92        for positive in [true, false] {
93            let mut totals: HashMap<Cow<'_, str>, f64> = HashMap::new();
94            for (x, y) in x_col.iter().zip(y_col.iter()) {
95                let row = super::stack_row(y);
96                if row.is_positive_bucket() != positive {
97                    continue;
98                }
99                *totals.entry(x.key_str()).or_insert(0.0) += row.contribution();
100            }
101
102            let mut consumed: HashMap<Cow<'_, str>, f64> = HashMap::new();
103            for (i, (x, y)) in x_col.iter().zip(y_col.iter()).enumerate() {
104                let row = super::stack_row(y);
105                if row.is_positive_bucket() != positive {
106                    continue;
107                }
108                let x_key = x.key_str();
109                let total = totals.get(&x_key).copied().unwrap_or(0.0);
110                let run = consumed.get(&x_key).copied().unwrap_or(0.0);
111
112                let Some(y_val) = row.value() else {
113                    // Missing: draw nothing for this row (NA in
114                    // ggplot2), but don't advance `consumed` — it
115                    // contributed 0, so the next row in this bucket
116                    // still starts from the same `run`.
117                    tops[i] = f64::NAN;
118                    bottoms[i] = f64::NAN;
119                    continue;
120                };
121
122                // This group occupies [total - run - y, total - run]
123                // within its own (same-sign) bucket.
124                tops[i] = total - run;
125                bottoms[i] = total - run - y_val;
126
127                *consumed.entry(x_key).or_insert(0.0) += y_val;
128            }
129        }
130
131        // A non-finite vjust is validated (and warned on) separately; here
132        // it falls back to 1 (the byte-identical top-anchored path).
133        let vjust = if self.vjust.is_finite() {
134            self.vjust
135        } else {
136            1.0
137        };
138
139        let write_ymax = (vjust - 1.0).abs() > f64::EPSILON;
140        let lo_hi: Vec<(f64, f64)> = tops
141            .iter()
142            .zip(bottoms.iter())
143            .map(|(&t, &b)| (t.min(b), t.max(b)))
144            .collect();
145        let new_y: Vec<Value> = if write_ymax {
146            lo_hi
147                .iter()
148                .map(|&(lo, hi)| Value::Float((1.0 - vjust) * lo + vjust * hi))
149                .collect()
150        } else {
151            lo_hi.iter().map(|&(_, hi)| Value::Float(hi)).collect()
152        };
153        let ymin_vals: Vec<Value> = lo_hi.iter().map(|&(lo, _)| Value::Float(lo)).collect();
154
155        if let Some(col) = data.column_mut("y") {
156            *col = new_y;
157        }
158        if !data.has_column("ymin") {
159            data.add_column("ymin".to_string(), ymin_vals);
160        } else if let Some(col) = data.column_mut("ymin") {
161            *col = ymin_vals;
162        }
163        if write_ymax {
164            let ymax_vals: Vec<Value> = lo_hi.iter().map(|&(_, hi)| Value::Float(hi)).collect();
165            if !data.has_column("ymax") {
166                data.add_column("ymax".to_string(), ymax_vals);
167            } else if let Some(col) = data.column_mut("ymax") {
168                *col = ymax_vals;
169            }
170        }
171    }
172
173    fn name(&self) -> &str {
174        "stack"
175    }
176
177    fn per_panel(&self) -> bool {
178        true
179    }
180
181    fn validate(&self, warnings: &mut Vec<String>) {
182        if !self.vjust.is_finite() {
183            warnings.push(format!(
184                "position_stack: vjust({}) must be finite; falling back to 1",
185                self.vjust
186            ));
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn frame(x: &[f64], g: &[&str], y: &[f64]) -> DataFrame {
196        let mut df = DataFrame::new();
197        df.add_column("x".into(), x.iter().map(|v| Value::Float(*v)).collect());
198        df.add_column(
199            "fill".into(),
200            g.iter().map(|s| Value::Str(s.to_string())).collect(),
201        );
202        df.add_column("y".into(), y.iter().map(|v| Value::Float(*v)).collect());
203        df
204    }
205
206    fn col(df: &DataFrame, c: &str) -> Vec<f64> {
207        df.column(c)
208            .unwrap()
209            .iter()
210            .map(|v| v.as_f64().unwrap())
211            .collect()
212    }
213
214    /// R parity (ggplot2 4.0.3, `layer_data()`): x=1, fill groups a/b/c/d
215    /// with y = 3, -2, 4, -1 and `position_stack(vjust = 0.5)` gives
216    /// y/ymin/ymax = (5.5, 4, 7), (-2, -3, -1), (2, 0, 4), (-0.5, -1, 0)
217    /// (#154) — positives (a, c) stack above 0 using only their own sum
218    /// (7) as the total; negatives (b, d) stack below 0 using only their
219    /// own sum (-3), independently.
220    #[test]
221    fn negative_and_positive_values_stack_independently() {
222        let mut df = frame(&[1.0; 4], &["a", "b", "c", "d"], &[3.0, -2.0, 4.0, -1.0]);
223        PositionStack::default()
224            .with_vjust(0.5)
225            .compute(&mut df, &PositionParams::default());
226        let y = col(&df, "y");
227        let ymin = col(&df, "ymin");
228        let ymax = col(&df, "ymax");
229        for (i, (want_y, want_min, want_max)) in [
230            (5.5, 4.0, 7.0),
231            (-2.0, -3.0, -1.0),
232            (2.0, 0.0, 4.0),
233            (-0.5, -1.0, 0.0),
234        ]
235        .into_iter()
236        .enumerate()
237        {
238            assert!((y[i] - want_y).abs() < 1e-9, "y[{i}]={}", y[i]);
239            assert!((ymin[i] - want_min).abs() < 1e-9, "ymin[{i}]={}", ymin[i]);
240            assert!((ymax[i] - want_max).abs() < 1e-9, "ymax[{i}]={}", ymax[i]);
241        }
242    }
243
244    /// All-positive data (the common case) is byte-identical to the
245    /// pre-#154 pooled algorithm: the negative bucket is simply empty.
246    #[test]
247    fn all_positive_values_are_unaffected() {
248        let mut df = frame(&[1.0, 1.0], &["a", "b"], &[3.0, 5.0]);
249        PositionStack.compute(&mut df, &PositionParams::default());
250        assert_eq!(col(&df, "y"), vec![8.0, 5.0]);
251        assert_eq!(col(&df, "ymin"), vec![5.0, 0.0]);
252    }
253
254    /// Review #167 finding 2 on sipemu/ggplot-rs#171 (originally #170's
255    /// NaN bucket fix, which got the *bucket* right but the *arithmetic*
256    /// wrong): a missing `y` joins the positive bucket, like ggplot2,
257    /// but contributes **0** to its running total — not the raw NaN —
258    /// so the *other* row sharing that bucket (`a`) stacks exactly as if
259    /// `b` weren't there at all, instead of turning into NaN too.
260    /// ggplot2 4.0.3 (`layer_data()`, `pos_stack`'s `y <-
261    /// ifelse(is.na(df$y), 0, df$y)`): a=3, b=NaN, c=-2 gives a 0..3,
262    /// c -2..0, b's own y/ymin left `NA`. `c` (the negative bucket) was
263    /// already unaffected either way — it shares no bucket with `b`.
264    #[test]
265    fn missing_y_contributes_zero_and_does_not_poison_its_bucket() {
266        let mut df = frame(&[1.0, 1.0, 1.0], &["a", "b", "c"], &[3.0, f64::NAN, -2.0]);
267        PositionStack.compute(&mut df, &PositionParams::default());
268        let y = col(&df, "y");
269        let ymin = col(&df, "ymin");
270        assert_eq!(
271            (y[0], ymin[0]),
272            (3.0, 0.0),
273            "a must stack as if b's row weren't there at all: y={y:?} ymin={ymin:?}"
274        );
275        assert!(
276            y[1].is_nan() && ymin[1].is_nan(),
277            "b's own y/ymin must stay NaN (not drawn): y={y:?} ymin={ymin:?}"
278        );
279        assert_eq!(
280            (y[2], ymin[2]),
281            (0.0, -2.0),
282            "c (negative bucket) must be unaffected: y={y:?} ymin={ymin:?}"
283        );
284    }
285
286    /// The pre-existing bug finding 2 also fixes: a `Value::Na` `y` (not
287    /// just a `Float(NaN)`) used to fall back to `0.0` via
288    /// `as_f64().unwrap_or(0.0)` and get stacked as a *real* point at
289    /// the top of its bucket, drawing a line straight through a
290    /// supposedly-removed row. It must now be treated exactly like a
291    /// `Float(NaN)` — contributing 0, with its own output left NaN.
292    #[test]
293    fn value_na_is_treated_like_float_nan_not_zero() {
294        let mut df = DataFrame::new();
295        df.add_column(
296            "x".into(),
297            vec![Value::Float(1.0), Value::Float(1.0), Value::Float(1.0)],
298        );
299        df.add_column(
300            "fill".into(),
301            vec![
302                Value::Str("a".into()),
303                Value::Str("b".into()),
304                Value::Str("c".into()),
305            ],
306        );
307        df.add_column(
308            "y".into(),
309            vec![Value::Float(3.0), Value::Na, Value::Float(-2.0)],
310        );
311        PositionStack.compute(&mut df, &PositionParams::default());
312        let y = col(&df, "y");
313        let ymin = col(&df, "ymin");
314        assert_eq!(
315            (y[0], ymin[0]),
316            (3.0, 0.0),
317            "a must be unaffected by b's Value::Na: y={y:?} ymin={ymin:?}"
318        );
319        assert!(
320            y[1].is_nan() && ymin[1].is_nan(),
321            "a Value::Na row must not become a real point at the stack top: \
322             y={y:?} ymin={ymin:?}"
323        );
324    }
325}