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}