Skip to main content

ggplot_rs/position/
fill.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3
4use crate::data::{DataFrame, Value};
5
6use super::{Position, PositionParams};
7
8/// Normalized stacking to 100% (ggplot2's `position_fill(vjust = )`) —
9/// like [`PositionStack`](struct@crate::position::stack::PositionStack) but
10/// scaled to `[0, 1]` per x group.
11///
12/// `vjust` places the row's `y` within its stacked segment, same as
13/// `PositionStack`: `1` (the default) is the segment's top, byte-
14/// identical to before B3-4 (no `ymax` column). Anything else writes
15/// `y = (1 − vjust)·ymin + vjust·ymax` plus the `ymax` column.
16#[derive(Clone, Debug, PartialEq)]
17pub struct PositionFill {
18    pub vjust: f64,
19}
20
21impl Default for PositionFill {
22    fn default() -> Self {
23        PositionFill { vjust: 1.0 }
24    }
25}
26
27/// The default fill (`vjust` 1): lets `.position(PositionFill)` keep
28/// compiling now that the type has fields.
29#[allow(non_upper_case_globals)]
30pub const PositionFill: PositionFill = PositionFill { vjust: 1.0 };
31
32impl PositionFill {
33    /// Place `y` within each stacked segment (`0` = bottom, `0.5` =
34    /// middle, `1` = top, the default).
35    pub fn with_vjust(mut self, vjust: f64) -> Self {
36        self.vjust = vjust;
37        self
38    }
39}
40
41/// `position_fill()` / `position_fill().with_vjust(v)` — see
42/// [`PositionFill`](struct@PositionFill).
43pub fn position_fill() -> PositionFill {
44    PositionFill::default()
45}
46
47impl Position for PositionFill {
48    fn compute(&self, data: &mut DataFrame, _params: &PositionParams) {
49        let x_col = match data.column("x") {
50            Some(c) => c.to_vec(),
51            None => return,
52        };
53        let y_col = match data.column("y") {
54            Some(c) => c.to_vec(),
55            None => return,
56        };
57        super::preserve_raw_y(data, &y_col);
58
59        let n = y_col.len();
60        let mut tops = vec![0.0_f64; n];
61        let mut bottoms = vec![0.0_f64; n];
62
63        // ggplot2 normalizes a group's positive and negative values
64        // independently within each x (#154, same split as
65        // `PositionStack`): the positive rows at an x fill `[0, 1]` using
66        // only their own sum as the denominator, and the negative rows
67        // fill `[-1, 0]` using their own (absolute) sum — each bucket's
68        // first group (in row/legend order) lands farthest from zero, as
69        // `PositionStack` already does. "top"/"bottom" below are in that
70        // accumulation order, not value order, so the final `ymin`/
71        // `ymax`/`y` take the min/max of the pair.
72        //
73        // A missing row (`super::stack_row` — shared with `PositionStack`,
74        // review #167 finding 3 on #171) joins the positive bucket but
75        // contributes 0 to its total/denominator, and its own top/bottom
76        // are left NaN below, matching ggplot2's `position_fill`.
77        for positive in [true, false] {
78            let sign_total = if positive { 1.0 } else { -1.0 };
79            let mut totals: HashMap<Cow<'_, str>, f64> = HashMap::new();
80            for (x, y) in x_col.iter().zip(y_col.iter()) {
81                let row = super::stack_row(y);
82                if row.is_positive_bucket() != positive {
83                    continue;
84                }
85                *totals.entry(x.key_str()).or_insert(0.0) += row.contribution();
86            }
87
88            let mut consumed: HashMap<Cow<'_, str>, f64> = HashMap::new();
89            for (i, (x, y)) in x_col.iter().zip(y_col.iter()).enumerate() {
90                let row = super::stack_row(y);
91                if row.is_positive_bucket() != positive {
92                    continue;
93                }
94                let x_key = x.key_str();
95                let total = totals.get(&x_key).copied().unwrap_or(0.0);
96                let denom = if total.abs() < f64::EPSILON {
97                    1.0
98                } else {
99                    total.abs()
100                };
101                let run = consumed.get(&x_key).copied().unwrap_or(0.0);
102
103                let Some(y_val) = row.value() else {
104                    tops[i] = f64::NAN;
105                    bottoms[i] = f64::NAN;
106                    continue;
107                };
108
109                let norm_y = y_val / denom;
110                tops[i] = sign_total - run;
111                bottoms[i] = sign_total - run - norm_y;
112
113                *consumed.entry(x_key).or_insert(0.0) += norm_y;
114            }
115        }
116
117        let vjust = if self.vjust.is_finite() {
118            self.vjust
119        } else {
120            1.0
121        };
122
123        let write_ymax = (vjust - 1.0).abs() > f64::EPSILON;
124        let lo_hi: Vec<(f64, f64)> = tops
125            .iter()
126            .zip(bottoms.iter())
127            .map(|(&t, &b)| (t.min(b), t.max(b)))
128            .collect();
129        let new_y: Vec<Value> = if write_ymax {
130            lo_hi
131                .iter()
132                .map(|&(lo, hi)| Value::Float((1.0 - vjust) * lo + vjust * hi))
133                .collect()
134        } else {
135            lo_hi.iter().map(|&(_, hi)| Value::Float(hi)).collect()
136        };
137        let ymin_vals: Vec<Value> = lo_hi.iter().map(|&(lo, _)| Value::Float(lo)).collect();
138
139        if let Some(col) = data.column_mut("y") {
140            *col = new_y;
141        }
142        if !data.has_column("ymin") {
143            data.add_column("ymin".to_string(), ymin_vals);
144        } else if let Some(col) = data.column_mut("ymin") {
145            *col = ymin_vals;
146        }
147        if write_ymax {
148            let ymax_vals: Vec<Value> = lo_hi.iter().map(|&(_, hi)| Value::Float(hi)).collect();
149            if !data.has_column("ymax") {
150                data.add_column("ymax".to_string(), ymax_vals);
151            } else if let Some(col) = data.column_mut("ymax") {
152                *col = ymax_vals;
153            }
154        }
155    }
156
157    fn name(&self) -> &str {
158        "fill"
159    }
160
161    fn per_panel(&self) -> bool {
162        true
163    }
164
165    fn validate(&self, warnings: &mut Vec<String>) {
166        if !self.vjust.is_finite() {
167            warnings.push(format!(
168                "position_fill: vjust({}) must be finite; falling back to 1",
169                self.vjust
170            ));
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn frame(x: &[f64], g: &[&str], y: &[f64]) -> DataFrame {
180        let mut df = DataFrame::new();
181        df.add_column("x".into(), x.iter().map(|v| Value::Float(*v)).collect());
182        df.add_column(
183            "fill".into(),
184            g.iter().map(|s| Value::Str(s.to_string())).collect(),
185        );
186        df.add_column("y".into(), y.iter().map(|v| Value::Float(*v)).collect());
187        df
188    }
189
190    fn col(df: &DataFrame, c: &str) -> Vec<f64> {
191        df.column(c)
192            .unwrap()
193            .iter()
194            .map(|v| v.as_f64().unwrap())
195            .collect()
196    }
197
198    /// R parity (ggplot2 4.0.3, `layer_data()`): x=1, fill groups a/b/c/d
199    /// with y = 3, -2, 4, -1 and `position_fill(vjust = 0)` gives
200    /// y/ymin/ymax = (0.571429, 0.571429, 1), (-1, -1, -0.333333),
201    /// (0, 0, 0.571429), (-0.333333, -0.333333, 0) (#154) — positives
202    /// (a, c) normalize to `[0, 1]` using only their own sum (7) as the
203    /// denominator; negatives (b, d) normalize to `[-1, 0]` using only
204    /// their own (absolute) sum (3), independently.
205    #[test]
206    fn negative_and_positive_values_fill_independently() {
207        let mut df = frame(&[1.0; 4], &["a", "b", "c", "d"], &[3.0, -2.0, 4.0, -1.0]);
208        PositionFill::default()
209            .with_vjust(0.0)
210            .compute(&mut df, &PositionParams::default());
211        let y = col(&df, "y");
212        let ymin = col(&df, "ymin");
213        let ymax = col(&df, "ymax");
214        for (i, (want_y, want_min, want_max)) in [
215            (4.0 / 7.0, 4.0 / 7.0, 1.0),
216            (-1.0, -1.0, -1.0 / 3.0),
217            (0.0, 0.0, 4.0 / 7.0),
218            (-1.0 / 3.0, -1.0 / 3.0, 0.0),
219        ]
220        .into_iter()
221        .enumerate()
222        {
223            assert!((y[i] - want_y).abs() < 1e-6, "y[{i}]={}", y[i]);
224            assert!((ymin[i] - want_min).abs() < 1e-6, "ymin[{i}]={}", ymin[i]);
225            assert!((ymax[i] - want_max).abs() < 1e-6, "ymax[{i}]={}", ymax[i]);
226        }
227    }
228
229    /// All-positive data (the common case) is byte-identical to the
230    /// pre-#154 pooled algorithm: the negative bucket is simply empty.
231    #[test]
232    fn all_positive_values_are_unaffected() {
233        let mut df = frame(&[1.0, 1.0], &["a", "b"], &[3.0, 5.0]);
234        PositionFill.compute(&mut df, &PositionParams::default());
235        let y = col(&df, "y");
236        assert!((y[0] - 1.0).abs() < 1e-9);
237        assert!((y[1] - 0.625).abs() < 1e-9);
238    }
239
240    /// Review #167 finding 3 on sipemu/ggplot-rs#171: `position_fill`
241    /// shares `PositionStack`'s missing-value helper (finding 2), so a
242    /// missing `y` contributes 0 to its bucket's normalisation
243    /// denominator instead of poisoning it with NaN. ggplot2 4.0.3
244    /// (`layer_data()`): a=3, b=NaN, c=-2 gives a 0..1, c -1..0, b's own
245    /// y/ymin left `NA`.
246    #[test]
247    fn missing_y_contributes_zero_and_does_not_poison_its_bucket() {
248        let mut df = frame(&[1.0, 1.0, 1.0], &["a", "b", "c"], &[3.0, f64::NAN, -2.0]);
249        PositionFill.compute(&mut df, &PositionParams::default());
250        let y = col(&df, "y");
251        let ymin = col(&df, "ymin");
252        assert_eq!(
253            (y[0], ymin[0]),
254            (1.0, 0.0),
255            "a must normalize as if b's row weren't there at all: y={y:?} ymin={ymin:?}"
256        );
257        assert!(
258            y[1].is_nan() && ymin[1].is_nan(),
259            "b's own y/ymin must stay NaN (not drawn): y={y:?} ymin={ymin:?}"
260        );
261        assert_eq!(
262            (y[2], ymin[2]),
263            (0.0, -1.0),
264            "c (negative bucket) must be unaffected: y={y:?} ymin={ymin:?}"
265        );
266    }
267}