Skip to main content

ggplot_rs/stat/
align.rs

1//! `stat_align` — the default stat of `geom_area` (ggplot2 ≥ 3.4).
2//!
3//! Stacking adds y values that share an x. Series sampled at different x
4//! would then stack on gaps (a series above one that has no row at some x
5//! dips there). `stat_align` interpolates every group onto one shared set of
6//! x positions first, so stacked areas stay continuous (#172).
7//!
8//! As ggplot2's `StatAlign`:
9//! * one group in the panel: the data passes through unchanged;
10//! * the shared positions are every x of the panel plus each group's zero
11//!   crossings, and a tiny step `adjust` either side of each (0.1 % of the x
12//!   range, at most a third of the smallest gap between positions);
13//! * each group is linearly interpolated at the positions inside its own x
14//!   range, and padded with `y = 0` one `adjust` beyond both ends;
15//! * a group with a single distinct x is dropped; within a group, repeated x
16//!   keep their first and last row, the first moved one `adjust` left (a
17//!   vertical step);
18//! * every other column is carried from the group's first row.
19use crate::aes::Aesthetic;
20use crate::data::{DataFrame, Value};
21use crate::scale::ScaleSet;
22
23use super::Stat;
24
25/// Interpolates every group of a panel onto shared x positions (see the
26/// module docs).
27#[derive(Debug, Clone, Copy, Default)]
28pub struct StatAlign;
29
30impl Stat for StatAlign {
31    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
32        align_panel(data)
33    }
34
35    fn required_aes(&self) -> Vec<Aesthetic> {
36        vec![Aesthetic::X, Aesthetic::Y]
37    }
38
39    fn panelwise(&self) -> bool {
40        true
41    }
42
43    fn name(&self) -> &str {
44        "align"
45    }
46}
47
48/// One group's rows: (x, y) sorted by x, and the row the other columns are
49/// carried from.
50struct Group {
51    pts: Vec<(f64, f64)>,
52    first: usize,
53}
54
55fn align_panel(data: &DataFrame) -> DataFrame {
56    let (Some(xs), Some(ys)) = (data.column("x"), data.column("y")) else {
57        return data.clone();
58    };
59    // A discrete x (or any value that isn't a number): nothing to align.
60    let xf: Option<Vec<f64>> = xs.iter().map(Value::as_f64).collect();
61    let yf: Option<Vec<f64>> = ys.iter().map(Value::as_f64).collect();
62    let (Some(xf), Some(yf)) = (xf, yf) else {
63        return data.clone();
64    };
65    let keys: Vec<String> = crate::build::detect_group_columns(data)
66        .into_iter()
67        .filter(|c| c != "x")
68        .collect();
69    // Rows of each group, in first-appearance order.
70    let mut order: Vec<String> = Vec::new();
71    let mut rows: std::collections::HashMap<String, Vec<usize>> = Default::default();
72    for i in 0..data.nrows() {
73        let k = keys
74            .iter()
75            .map(|c| {
76                data.column(c)
77                    .map_or(String::new(), |v| v[i].to_group_key())
78            })
79            .collect::<Vec<_>>()
80            .join("\u{1f}");
81        rows.entry(k.clone())
82            .or_insert_with(|| {
83                order.push(k.clone());
84                Vec::new()
85            })
86            .push(i);
87    }
88    if order.len() < 2 {
89        return data.clone();
90    }
91    let groups: Vec<Group> = order
92        .iter()
93        .map(|k| {
94            let ix = &rows[k];
95            let mut pts: Vec<(f64, f64)> = ix.iter().map(|&i| (xf[i], yf[i])).collect();
96            pts.sort_by(|a, b| a.0.total_cmp(&b.0));
97            Group { pts, first: ix[0] }
98        })
99        .collect();
100
101    // The shared positions: every x, plus each group's zero crossings.
102    let mut locs: Vec<f64> = xf.iter().copied().filter(|x| x.is_finite()).collect();
103    for g in &groups {
104        for w in g.pts.windows(2) {
105            let ((x0, y0), (x1, y1)) = (w[0], w[1]);
106            if (y0 < 0.0) != (y1 < 0.0) && y1 != y0 {
107                locs.push(x0 - y0 * (x1 - x0) / (y1 - y0));
108            }
109        }
110    }
111    sort_dedup(&mut locs);
112    if locs.len() < 2 {
113        return data.clone();
114    }
115    let range = locs[locs.len() - 1] - locs[0];
116    let min_gap = locs
117        .windows(2)
118        .map(|w| w[1] - w[0])
119        .fold(f64::INFINITY, f64::min);
120    let adjust = (range * 0.001).min(min_gap / 3.0);
121    let mut all: Vec<f64> = locs
122        .iter()
123        .flat_map(|&l| [l - adjust, l, l + adjust])
124        .collect();
125    sort_dedup(&mut all);
126
127    let datetime = xs.iter().all(Value::is_datetime);
128    let as_x = |v: f64| {
129        if datetime {
130            Value::DateTime(v.round() as i64)
131        } else {
132            Value::Float(v)
133        }
134    };
135    let names: Vec<String> = data
136        .column_names()
137        .into_iter()
138        .filter(|n| *n != "x" && *n != "y")
139        .map(str::to_string)
140        .collect();
141    let mut out_x: Vec<Value> = Vec::new();
142    let mut out_y: Vec<Value> = Vec::new();
143    let mut out_rest: Vec<Vec<Value>> = vec![Vec::new(); names.len()];
144    for g in &groups {
145        if g.pts.iter().all(|p| p.0 == g.pts[0].0) {
146            continue; // a single distinct x: ggplot2 drops the group
147        }
148        let pts = dedup_steps(&g.pts, adjust);
149        let (lo, hi) = (pts[0].0, pts[pts.len() - 1].0);
150        let mut gx = vec![lo - adjust];
151        let mut gy = vec![0.0];
152        for &l in all.iter().filter(|&&l| l >= lo && l <= hi) {
153            gx.push(l);
154            gy.push(interpolate(&pts, l));
155        }
156        gx.push(hi + adjust);
157        gy.push(0.0);
158        for (x, y) in gx.into_iter().zip(gy) {
159            out_x.push(as_x(x));
160            out_y.push(Value::Float(y));
161            for (c, n) in names.iter().enumerate() {
162                out_rest[c].push(data.column(n).map_or(Value::Na, |v| v[g.first].clone()));
163            }
164        }
165    }
166    let mut out = DataFrame::new();
167    out.add_column("x".into(), out_x);
168    out.add_column("y".into(), out_y);
169    for (n, v) in names.into_iter().zip(out_rest) {
170        out.add_column(n, v);
171    }
172    out
173}
174
175fn sort_dedup(v: &mut Vec<f64>) {
176    v.retain(|x| x.is_finite());
177    v.sort_by(f64::total_cmp);
178    v.dedup();
179}
180
181/// Repeated x within a group: keep the first and last row, the first moved
182/// `adjust` to the left (ggplot2 draws a vertical step there).
183fn dedup_steps(pts: &[(f64, f64)], adjust: f64) -> Vec<(f64, f64)> {
184    let mut out = Vec::with_capacity(pts.len());
185    let mut i = 0;
186    while i < pts.len() {
187        let mut j = i;
188        while j + 1 < pts.len() && pts[j + 1].0 == pts[i].0 {
189            j += 1;
190        }
191        if j == i {
192            out.push(pts[i]);
193        } else {
194            out.push((pts[i].0 - adjust, pts[i].1));
195            out.push(pts[j]);
196        }
197        i = j + 1;
198    }
199    out
200}
201
202/// Linear interpolation at `x` (inside the points' range; the points are
203/// sorted by x with no ties).
204fn interpolate(pts: &[(f64, f64)], x: f64) -> f64 {
205    let k = pts.partition_point(|p| p.0 < x);
206    if k < pts.len() && pts[k].0 == x {
207        return pts[k].1;
208    }
209    if k == 0 || k == pts.len() {
210        return f64::NAN; // outside (not reached: callers stay in range)
211    }
212    let ((x0, y0), (x1, y1)) = (pts[k - 1], pts[k]);
213    y0 + (y1 - y0) * (x - x0) / (x1 - x0)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn panel(rows: &[(f64, f64, &str)]) -> DataFrame {
221        let mut d = DataFrame::new();
222        d.add_column("x".into(), rows.iter().map(|r| Value::Float(r.0)).collect());
223        d.add_column("y".into(), rows.iter().map(|r| Value::Float(r.1)).collect());
224        d.add_column(
225            "fill".into(),
226            rows.iter().map(|r| Value::Str(r.2.into())).collect(),
227        );
228        d
229    }
230    fn col(d: &DataFrame, n: &str) -> Vec<f64> {
231        d.column(n)
232            .unwrap()
233            .iter()
234            .map(|v| v.as_f64().unwrap())
235            .collect()
236    }
237
238    #[test]
239    fn matches_ggplot2_on_a_gap() {
240        // ggplot2 4.0.3, layer_data(ggplot(d, aes(x, y, fill = g)) +
241        // geom_area()) before stacking: a = 1 at x 1..4, b = 2 at x 1, 3, 4.
242        let d = panel(&[
243            (1.0, 1.0, "a"),
244            (2.0, 1.0, "a"),
245            (3.0, 1.0, "a"),
246            (4.0, 1.0, "a"),
247            (1.0, 2.0, "b"),
248            (3.0, 2.0, "b"),
249            (4.0, 2.0, "b"),
250        ]);
251        let out = align_panel(&d);
252        let x = col(&out, "x");
253        let y = col(&out, "y");
254        let want_x = [
255            0.997, 1.0, 1.003, 1.997, 2.0, 2.003, 2.997, 3.0, 3.003, 3.997, 4.0, 4.003,
256        ];
257        assert_eq!(out.nrows(), 24);
258        for (i, w) in want_x.iter().enumerate() {
259            assert!((x[i] - w).abs() < 1e-9, "a x[{i}] = {}", x[i]);
260            assert!((x[12 + i] - w).abs() < 1e-9, "b x[{i}] = {}", x[12 + i]);
261        }
262        // Padding rows are 0; b is 2 everywhere in between, including x = 2
263        // where it has no row of its own.
264        assert_eq!((y[0], y[11], y[12], y[23]), (0.0, 0.0, 0.0, 0.0));
265        assert!(y[1..11].iter().all(|&v| v == 1.0));
266        assert!(y[13..23].iter().all(|&v| v == 2.0));
267        assert_eq!(out.column("fill").unwrap()[13].as_str(), Some("b"));
268    }
269
270    #[test]
271    fn one_group_passes_through() {
272        let d = panel(&[(1.0, 1.0, "a"), (3.0, 4.0, "a")]);
273        let out = align_panel(&d);
274        assert_eq!(col(&out, "x"), vec![1.0, 3.0]);
275        assert_eq!(col(&out, "y"), vec![1.0, 4.0]);
276    }
277
278    #[test]
279    fn interpolates_and_adds_zero_crossings() {
280        // a crosses zero between x = 0 and 2 (at 1); b only has x 0 and 2.
281        let d = panel(&[
282            (0.0, -1.0, "a"),
283            (2.0, 1.0, "a"),
284            (0.0, 1.0, "b"),
285            (2.0, 3.0, "b"),
286        ]);
287        let out = align_panel(&d);
288        let (x, y) = (col(&out, "x"), col(&out, "y"));
289        let at = |g: usize, want: f64| {
290            let off = if g == 0 { 0 } else { out.nrows() / 2 };
291            let i = (off..off + out.nrows() / 2)
292                .find(|&i| (x[i] - want).abs() < 1e-12)
293                .unwrap();
294            y[i]
295        };
296        assert_eq!(at(0, 1.0), 0.0, "a's zero crossing is a shared position");
297        assert_eq!(at(1, 1.0), 2.0, "b interpolated there");
298    }
299
300    #[test]
301    fn a_single_x_group_is_dropped_and_steps_kept() {
302        let d = panel(&[
303            (1.0, 5.0, "a"),
304            (1.0, 6.0, "a"),
305            (0.0, 1.0, "b"),
306            (2.0, 1.0, "b"),
307        ]);
308        let out = align_panel(&d);
309        assert!(out
310            .column("fill")
311            .unwrap()
312            .iter()
313            .all(|v| v.as_str() == Some("b")));
314        let steps = dedup_steps(&[(0.0, 1.0), (1.0, 2.0), (1.0, 3.0), (2.0, 1.0)], 0.1);
315        assert_eq!(steps, vec![(0.0, 1.0), (0.9, 2.0), (1.0, 3.0), (2.0, 1.0)]);
316    }
317
318    #[test]
319    fn datetimes_stay_datetimes() {
320        let mut d = DataFrame::new();
321        d.add_column(
322            "x".into(),
323            vec![
324                Value::DateTime(0),
325                Value::DateTime(86_400),
326                Value::DateTime(0),
327                Value::DateTime(172_800),
328            ],
329        );
330        d.add_column("y".into(), vec![Value::Float(1.0); 4]);
331        d.add_column(
332            "fill".into(),
333            vec![
334                Value::Str("a".into()),
335                Value::Str("a".into()),
336                Value::Str("b".into()),
337                Value::Str("b".into()),
338            ],
339        );
340        let out = align_panel(&d);
341        assert!(out.column("x").unwrap().iter().all(Value::is_datetime));
342    }
343}