Skip to main content

ggplot_rs/position/
dodge.rs

1use crate::data::{DataFrame, Value};
2
3use super::{Position, PositionParams, DODGE_N_COL, DODGE_OFFSET_COL};
4
5/// Place groups side-by-side (ggplot2's `position_dodge(width)`).
6///
7/// Within each x position the groups present there (group / fill / colour
8/// level, in first-appearance order — the legend order — or reversed with
9/// [`with_reverse`](Self::with_reverse)) share `width` (in x units; one
10/// category on a discrete axis) and are centred on x:
11/// `offset = (k − (n − 1)/2) · width / n` for the k-th of n groups. On a
12/// continuous axis x itself moves; on a discrete axis the offset is stored in
13/// the `.x_dodge_offset` column and applied by the geoms that support it
14/// (point, pointrange, errorbar, linerange, ribbon, censor marks), so several
15/// models' intervals per term sit side by side — also under `coord_flip`.
16///
17/// `PositionDodge` is also usable as a value (the default, `width` from the
18/// layer, 0.9), as before 0.17.
19#[derive(Clone, Debug, Default, PartialEq)]
20pub struct PositionDodge {
21    /// Total dodge width; `None` = the layer default (0.9).
22    pub width: Option<f64>,
23    /// Reverse the group order (ggplot2 ≥ 3.5 `reverse = TRUE`).
24    pub reverse: bool,
25    /// ggplot2's `preserve = "single"`: every element gets the width of one
26    /// group in the fullest slot, so a slot with fewer groups doesn't widen
27    /// its elements (they stay centred). `false` = `preserve = "total"`.
28    pub preserve_single: bool,
29}
30
31/// The default dodge (`width` 0.9, groups in legend order): lets
32/// `.position(PositionDodge)` keep compiling now that the type has fields.
33#[allow(non_upper_case_globals)]
34pub const PositionDodge: PositionDodge = PositionDodge {
35    width: None,
36    reverse: false,
37    preserve_single: false,
38};
39
40impl PositionDodge {
41    /// Dodge with an explicit total `width` (ggplot2's `position_dodge(width)`).
42    pub fn new(width: f64) -> Self {
43        PositionDodge {
44            width: Some(width),
45            reverse: false,
46            preserve_single: false,
47        }
48    }
49
50    /// Reverse the order of the dodged groups.
51    pub fn with_reverse(mut self, reverse: bool) -> Self {
52        self.reverse = reverse;
53        self
54    }
55
56    /// `preserve = "single"` (see [`preserve_single`](Self::preserve_single)).
57    pub fn with_preserve_single(mut self) -> Self {
58        self.preserve_single = true;
59        self
60    }
61}
62
63/// `position_dodge(width)` — see [`PositionDodge`](struct@PositionDodge).
64pub fn position_dodge(width: f64) -> PositionDodge {
65    PositionDodge::new(width)
66}
67
68impl Position for PositionDodge {
69    fn compute(&self, data: &mut DataFrame, params: &PositionParams) {
70        dodge_rows(
71            data,
72            self.width.unwrap_or(params.width),
73            None,
74            self.reverse,
75            self.preserve_single,
76            params.group_order.as_deref(),
77        );
78    }
79
80    fn name(&self) -> &str {
81        "dodge"
82    }
83
84    /// ggplot2 computes `position_dodge`'s group count/offset per facet
85    /// panel (`compute_panel`), not pooled across panels (#153) — verified
86    /// against ggplot2 4.0.3: a group missing from one panel's x level
87    /// doesn't shift the groups that panel does have.
88    fn per_panel(&self) -> bool {
89        true
90    }
91
92    /// Review #167 finding 2: the group *rank* still comes from the whole
93    /// layer (first-appearance order across every panel), even though the
94    /// per-x group *count* stays per-panel. Without this, a panel whose
95    /// rows happen to list groups in a different order than another panel
96    /// drew that group on the opposite side — a regression vs 0.22.2 and
97    /// ggplot2 4.0.3, which both rank by the global `group` id.
98    fn group_order(&self, data: &DataFrame) -> Option<Vec<String>> {
99        group_keys(data).map(|keys| dedup_order(&keys))
100    }
101}
102
103/// First-appearance-order, de-duplicated group keys — the "legend order"
104/// `group_order` (and, absent an external order, `dodge_rows` itself)
105/// ranks groups by.
106pub(crate) fn dedup_order(keys: &[String]) -> Vec<String> {
107    let set: indexmap::IndexSet<&str> = keys.iter().map(|k| k.as_str()).collect();
108    set.into_iter().map(str::to_string).collect()
109}
110
111/// #428: rank the dodge keys `order` the way the legend lists them — by each
112/// of `group` / `fill` / `colour`'s discrete scale level order (the scale
113/// cloned and trained on this layer's values, so ggplot2's sorted factor
114/// levels or user `limits` win), not by which row happens to come first.
115/// A column without a discrete scale (an explicit `group`) keeps its
116/// first-appearance rank; ties keep `order`'s own order.
117pub(crate) fn order_by_scale_levels(
118    order: Vec<String>,
119    data: &DataFrame,
120    scales: &crate::scale::ScaleSet,
121) -> Vec<String> {
122    use crate::aes::Aesthetic;
123    let Some(keys) = group_keys(data) else {
124        return order;
125    };
126    let ranks: Vec<Vec<f64>> = [
127        ("group", Aesthetic::Group),
128        ("fill", Aesthetic::Fill),
129        ("color", Aesthetic::Color),
130    ]
131    .iter()
132    .filter_map(|(c, aes)| Some((data.column(c)?, aes)))
133    .map(
134        |(col, aes)| match scales.get(aes).filter(|s| s.is_discrete()) {
135            Some(scale) => {
136                let mut scale = scale.clone_box();
137                scale.train(col);
138                col.iter().map(|v| scale.map(v)).collect()
139            }
140            None => {
141                let seen: indexmap::IndexSet<String> =
142                    col.iter().map(Value::to_group_key).collect();
143                col.iter()
144                    .map(|v| seen.get_index_of(&v.to_group_key()).unwrap_or(0) as f64)
145                    .collect()
146            }
147        },
148    )
149    .collect();
150    let mut rank: indexmap::IndexMap<&str, Vec<f64>> = indexmap::IndexMap::new();
151    for (i, k) in keys.iter().enumerate() {
152        rank.entry(k.as_str())
153            .or_insert_with(|| ranks.iter().map(|r| r[i]).collect());
154    }
155    let mut order = order;
156    order.sort_by(|a, b| {
157        let (ra, rb) = (rank.get(a.as_str()), rank.get(b.as_str()));
158        ra.zip(rb).map_or(std::cmp::Ordering::Equal, |(ra, rb)| {
159            ra.iter()
160                .zip(rb)
161                .map(|(x, y)| x.total_cmp(y))
162                .find(|o| o.is_ne())
163                .unwrap_or(std::cmp::Ordering::Equal)
164        })
165    });
166    order
167}
168
169/// The dodge key of each row: its group / fill / colour levels combined
170/// (`None` when the frame has none of these columns).
171pub(crate) fn group_keys(data: &DataFrame) -> Option<Vec<String>> {
172    let cols: Vec<&[Value]> = ["group", "fill", "color"]
173        .iter()
174        .filter_map(|c| data.column(c))
175        .collect();
176    if cols.is_empty() {
177        return None;
178    }
179    Some(
180        (0..data.nrows())
181            .map(|i| {
182                cols.iter()
183                    .map(|c| c[i].to_group_key())
184                    .collect::<Vec<_>>()
185                    .join("\u{1f}")
186            })
187            .collect(),
188    )
189}
190
191/// Shared dodge / dodge2 implementation. `padding` (dodge2) also rewrites
192/// `xmin`/`xmax` on a continuous axis to the shrunken element extent.
193///
194/// `external_order` (review #167 finding 2) is the whole layer's group
195/// rank order, computed by `build::compute_position_per_panel` from
196/// [`Position::group_order`] before the layer's rows were split into
197/// panels. When given, it — not this call's own (possibly per-panel)
198/// `data` — decides group rank; `None` (the non-per-panel call path, and
199/// every pre-#153 caller) falls back to the previous, locally-computed
200/// order.
201pub(crate) fn dodge_rows(
202    data: &mut DataFrame,
203    width: f64,
204    padding: Option<f64>,
205    reverse: bool,
206    preserve_single: bool,
207    external_order: Option<&[String]>,
208) {
209    let Some(x_col) = data.column("x").map(|c| c.to_vec()) else {
210        return;
211    };
212    let Some(keys) = group_keys(data) else {
213        return;
214    };
215    let width = if width.is_finite() && width > 0.0 {
216        width
217    } else {
218        0.9
219    };
220    // Group order (legend order), optionally reversed: the whole layer's
221    // order when given (#167 finding 2), else this call's own data —
222    // identical to the whole layer's order whenever there's no per-panel
223    // split (the non-per-panel path, and direct `compute()` calls in unit
224    // tests below).
225    let mut order: indexmap::IndexSet<&str> = match external_order {
226        Some(ext) => ext.iter().map(|s| s.as_str()).collect(),
227        None => keys.iter().map(|k| k.as_str()).collect(),
228    };
229    if order.len() <= 1 {
230        return;
231    }
232    if reverse {
233        order.reverse();
234    }
235    let rank = |k: &str| order.get_index_of(k).unwrap_or(0);
236
237    // Groups present at each x position, sorted by the global order.
238    let mut slots: indexmap::IndexMap<String, Vec<usize>> = indexmap::IndexMap::new();
239    for (i, x) in x_col.iter().enumerate() {
240        if x.is_na() {
241            continue;
242        }
243        let present = slots.entry(x.to_group_key()).or_default();
244        let r = rank(&keys[i]);
245        if !present.contains(&r) {
246            present.push(r);
247        }
248    }
249    for present in slots.values_mut() {
250        present.sort_unstable();
251    }
252    // `preserve = "single"`: size every element as one of the fullest slot's.
253    let max_n = slots.values().map(Vec::len).max().unwrap_or(1) as f64;
254
255    let discrete = x_col
256        .iter()
257        .any(|v| matches!(v, Value::Str(_) | Value::Bool(_)));
258    let mut offsets = vec![Value::Float(0.0); x_col.len()];
259    let mut counts = vec![Value::Float(1.0); x_col.len()];
260    let mut new_x = x_col.clone();
261    let mut new_xmin = data.column("xmin").map(|c| c.to_vec());
262    let mut new_xmax = data.column("xmax").map(|c| c.to_vec());
263    for (i, x) in x_col.iter().enumerate() {
264        let Some(present) = slots.get(&x.to_group_key()).filter(|_| !x.is_na()) else {
265            continue;
266        };
267        let n = present.len() as f64;
268        let k = present
269            .iter()
270            .position(|&r| r == rank(&keys[i]))
271            .unwrap_or(0) as f64;
272        let n_width = if preserve_single { max_n } else { n };
273        let group_width = width / n_width;
274        let offset = (k - (n - 1.0) / 2.0) * group_width;
275        if discrete {
276            offsets[i] = Value::Float(offset);
277            counts[i] = Value::Float(n_width);
278            continue;
279        }
280        let Some(xv) = x.as_f64() else {
281            continue;
282        };
283        let center = xv + offset;
284        new_x[i] = Value::Float(center);
285        if let Some(pad) = padding {
286            let half = group_width * (1.0 - pad) / 2.0;
287            if let Some(c) = new_xmin.as_mut() {
288                c[i] = Value::Float(center - half);
289            }
290            if let Some(c) = new_xmax.as_mut() {
291                c[i] = Value::Float(center + half);
292            }
293        }
294    }
295
296    if discrete {
297        if let Some(col) = data.column_mut(DODGE_OFFSET_COL) {
298            *col = offsets;
299        } else {
300            data.add_column(DODGE_OFFSET_COL.to_string(), offsets);
301        }
302        if let Some(col) = data.column_mut(DODGE_N_COL) {
303            *col = counts;
304        } else {
305            data.add_column(DODGE_N_COL.to_string(), counts);
306        }
307        return;
308    }
309    if let Some(col) = data.column_mut("x") {
310        *col = new_x;
311    }
312    if padding.is_some() {
313        for (name, vals) in [("xmin", new_xmin), ("xmax", new_xmax)] {
314            if let (Some(vals), Some(col)) = (vals, data.column_mut(name)) {
315                *col = vals;
316            }
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn frame(x: Vec<Value>, g: &[&str]) -> DataFrame {
326        let mut df = DataFrame::new();
327        df.add_column("x".into(), x);
328        df.add_column(
329            "color".into(),
330            g.iter().map(|s| Value::Str(s.to_string())).collect(),
331        );
332        df
333    }
334
335    fn col(df: &DataFrame, c: &str) -> Vec<f64> {
336        df.column(c)
337            .unwrap()
338            .iter()
339            .map(|v| v.as_f64().unwrap())
340            .collect()
341    }
342
343    #[test]
344    fn continuous_dodge_matches_ggplot2() {
345        // ggplot2: position_dodge(width = 0.6) of 3 groups at x = 1 →
346        // 0.8, 1.0, 1.2.
347        let mut df = frame(vec![Value::Float(1.0); 3], &["a", "b", "c"]);
348        PositionDodge::new(0.6).compute(&mut df, &PositionParams::default());
349        let x = col(&df, "x");
350        for (got, want) in x.iter().zip([0.8, 1.0, 1.2]) {
351            assert!((got - want).abs() < 1e-12);
352        }
353        let mut df = frame(vec![Value::Float(1.0); 3], &["a", "b", "c"]);
354        PositionDodge::new(0.6)
355            .with_reverse(true)
356            .compute(&mut df, &PositionParams::default());
357        let x = col(&df, "x");
358        assert!((x[0] - 1.2).abs() < 1e-12 && (x[2] - 0.8).abs() < 1e-12);
359    }
360
361    #[test]
362    fn discrete_dodge_stores_offsets_and_centres_partial_slots() {
363        let x: Vec<Value> = ["t1", "t1", "t2"]
364            .iter()
365            .map(|s| Value::Str(s.to_string()))
366            .collect();
367        let mut df = frame(x, &["m1", "m2", "m2"]);
368        PositionDodge::new(0.5).compute(&mut df, &PositionParams::default());
369        // x itself untouched (a category), offsets in category units.
370        assert_eq!(df.column("x").unwrap()[0].as_str(), Some("t1"));
371        let off = col(&df, DODGE_OFFSET_COL);
372        assert!((off[0] + 0.125).abs() < 1e-12);
373        assert!((off[1] - 0.125).abs() < 1e-12);
374        // t2 has only m2 → centred (ggplot2's preserve = "total").
375        assert_eq!(off[2], 0.0);
376    }
377
378    #[test]
379    fn preserve_single_keeps_element_width_in_partial_slots() {
380        let x: Vec<Value> = ["t1", "t1", "t1", "t2"]
381            .iter()
382            .map(|s| Value::Str(s.to_string()))
383            .collect();
384        let mut df = frame(x, &["a", "b", "c", "a"]);
385        PositionDodge::new(0.9)
386            .with_preserve_single()
387            .compute(&mut df, &PositionParams::default());
388        // Every element is one third of the slot wide, also t2's lone one,
389        // which stays centred.
390        assert_eq!(col(&df, DODGE_N_COL), vec![3.0, 3.0, 3.0, 3.0]);
391        assert_eq!(col(&df, DODGE_OFFSET_COL)[3], 0.0);
392        // Continuous x: t2-like partial slot keeps its centre.
393        let mut df = frame(
394            vec![Value::Float(1.0), Value::Float(1.0), Value::Float(2.0)],
395            &["a", "b", "a"],
396        );
397        PositionDodge::new(0.6)
398            .with_preserve_single()
399            .compute(&mut df, &PositionParams::default());
400        let xs = col(&df, "x");
401        assert!((xs[0] - 0.85).abs() < 1e-12 && (xs[1] - 1.15).abs() < 1e-12);
402        assert!((xs[2] - 2.0).abs() < 1e-12);
403    }
404
405    #[test]
406    fn unit_value_still_works() {
407        let mut df = frame(vec![Value::Float(1.0); 2], &["a", "b"]);
408        PositionDodge.compute(&mut df, &PositionParams::default());
409        let x = col(&df, "x");
410        assert!((x[0] - 0.775).abs() < 1e-12 && (x[1] - 1.225).abs() < 1e-12);
411    }
412}