Skip to main content

ggplot_rs/position/
jitter.rs

1use crate::data::{DataFrame, Value};
2use crate::rng::SplitMix64;
3
4use super::{Position, PositionParams};
5
6/// Default seed: the same data must render the same SVG (stable snapshots,
7/// cacheable output, no flicker when a host re-renders), as with ggplot2's
8/// `position_jitter(seed = …)`.
9pub const JITTER_SEED: u64 = 0x6A17_7E2D;
10
11/// Add random noise to x and y positions to reduce overplotting.
12///
13/// Each value moves by a uniform amount in `[-width, width)` (x) and
14/// `[-height, height)` (y). `None` (the default) means "40% of the data's
15/// resolution", as in ggplot2: the smallest gap between distinct values, or 1
16/// for integer/constant data — so integer-coded categories (1, 2, 3, …) get
17/// ±0.4 and finely-spaced continuous data gets proportionally small noise.
18/// `Some(0.0)` disables jitter along that axis.
19#[derive(Clone, Debug)]
20pub struct PositionJitter {
21    /// Horizontal jitter amount in data units; `None` = 0.4 × resolution(x).
22    pub width: Option<f64>,
23    /// Vertical jitter amount in data units; `None` = 0.4 × resolution(y).
24    pub height: Option<f64>,
25    /// Seed of the deterministic noise stream.
26    pub seed: u64,
27}
28
29impl Default for PositionJitter {
30    fn default() -> Self {
31        PositionJitter {
32            width: None,
33            height: None,
34            seed: JITTER_SEED,
35        }
36    }
37}
38
39impl PositionJitter {
40    /// Explicit jitter amounts (data units), default seed.
41    pub fn new(width: f64, height: f64) -> Self {
42        PositionJitter {
43            width: Some(width),
44            height: Some(height),
45            seed: JITTER_SEED,
46        }
47    }
48
49    /// Set the horizontal jitter amount (data units).
50    pub fn with_width(mut self, width: f64) -> Self {
51        self.width = Some(width);
52        self
53    }
54
55    /// Set the vertical jitter amount (data units).
56    pub fn with_height(mut self, height: f64) -> Self {
57        self.height = Some(height);
58        self
59    }
60
61    /// Use a different seed (ggplot2's `position_jitter(seed = …)`).
62    pub fn with_seed(mut self, seed: u64) -> Self {
63        self.seed = seed;
64        self
65    }
66}
67
68/// ggplot2's `resolution(x, zero = FALSE)`: the smallest positive gap between
69/// distinct finite values; 1 for integer-typed data, a single distinct value, or
70/// no numeric values at all.
71pub fn resolution(values: &[Value]) -> f64 {
72    let mut all_int = true;
73    let mut xs: Vec<f64> = Vec::with_capacity(values.len());
74    for v in values {
75        match v {
76            Value::Integer(i) => xs.push(*i as f64),
77            Value::Float(f) if f.is_finite() => {
78                all_int = false;
79                xs.push(*f);
80            }
81            Value::DateTime(s) => {
82                all_int = false;
83                xs.push(*s as f64);
84            }
85            _ => {}
86        }
87    }
88    if all_int || xs.len() < 2 {
89        return 1.0;
90    }
91    xs.sort_by(|a, b| a.total_cmp(b));
92    let span = xs[xs.len() - 1] - xs[0];
93    // Ignore floating-point dust between "equal" values (ggplot2 uses a
94    // tolerance of sqrt(.Machine$double.eps) relative to the range).
95    let tol = span.abs() * 1e-8;
96    let min_gap = xs
97        .windows(2)
98        .map(|w| w[1] - w[0])
99        .filter(|d| *d > tol)
100        .fold(f64::INFINITY, f64::min);
101    if min_gap.is_finite() && min_gap > 0.0 {
102        min_gap
103    } else {
104        1.0
105    }
106}
107
108fn jitter_column(col: &mut [Value], amount: f64, rng: &mut SplitMix64) {
109    for v in col.iter_mut() {
110        if let Some(x) = v.as_f64() {
111            *v = Value::Float(x + rng.range_f64(-amount, amount));
112        }
113    }
114}
115
116impl Position for PositionJitter {
117    fn compute(&self, data: &mut DataFrame, _params: &PositionParams) {
118        let mut rng = SplitMix64::new(self.seed);
119
120        if data.column("x").is_some_and(super::is_discrete_x) {
121            // A category can't move: jitter its offset (one category = 1,
122            // ggplot2's resolution of a mapped discrete axis).
123            let w = self.width.unwrap_or(0.4);
124            if w > 0.0 && w.is_finite() {
125                super::add_x_offsets(data, |_| rng.range_f64(-w, w));
126            }
127        } else if let Some(x_col) = data.column_mut("x") {
128            let w = self.width.unwrap_or_else(|| 0.4 * resolution(x_col));
129            if w > 0.0 && w.is_finite() {
130                jitter_column(x_col, w, &mut rng);
131            }
132        }
133
134        if let Some(y_col) = data.column_mut("y") {
135            let h = self.height.unwrap_or_else(|| 0.4 * resolution(y_col));
136            if h > 0.0 && h.is_finite() {
137                jitter_column(y_col, h, &mut rng);
138            }
139        }
140    }
141
142    fn name(&self) -> &str {
143        "jitter"
144    }
145
146    /// ggplot2 4.0.3 measures `position_jitter`'s default width/height
147    /// (`resolution(x) * 0.4`) from each facet panel's own x/y, not the
148    /// rows pooled across panels (#153) — verified with `layer_data()`:
149    /// two panels with very different x spacing (e.g. integers vs.
150    /// hundredths) get differently-scaled jitter even under fixed facet
151    /// scales. The RNG draw is also per panel (it resets from `seed` at
152    /// the top of `compute`, as it already did), matching ggplot2's own
153    /// per-panel restart — verified by the identical normalized jitter
154    /// fractions ggplot2 produces across panels for the same seed.
155    fn per_panel(&self) -> bool {
156        true
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn frame(x: Vec<Value>, y: Vec<Value>) -> DataFrame {
165        let mut df = DataFrame::new();
166        df.add_column("x".into(), x);
167        df.add_column("y".into(), y);
168        df
169    }
170
171    fn floats(v: &[f64]) -> Vec<Value> {
172        v.iter().map(|f| Value::Float(*f)).collect()
173    }
174
175    #[test]
176    fn discrete_x_jitters_through_the_offset_column() {
177        let mut df = frame(
178            ["a", "a", "b", "b"]
179                .iter()
180                .map(|s| Value::Str(s.to_string()))
181                .collect(),
182            floats(&[1.0, 2.0, 3.0, 4.0]),
183        );
184        PositionJitter::default()
185            .with_height(0.0)
186            .compute(&mut df, &PositionParams::default());
187        assert!(
188            matches!(df.column("x").unwrap()[0], Value::Str(_)),
189            "x unchanged"
190        );
191        let off: Vec<f64> = df
192            .column(crate::position::DODGE_OFFSET_COL)
193            .expect("offset column")
194            .iter()
195            .map(|v| v.as_f64().unwrap())
196            .collect();
197        assert!(off.iter().all(|o| o.abs() <= 0.4), "{off:?}");
198        assert!(off.iter().any(|o| o.abs() > 0.01), "{off:?}");
199    }
200
201    #[test]
202    fn resolution_matches_ggplot2() {
203        assert_eq!(resolution(&floats(&[1.0, 2.0, 3.0])), 1.0);
204        assert_eq!(resolution(&floats(&[0.1, 0.3, 0.2])), 0.09999999999999998);
205        assert_eq!(resolution(&floats(&[5.0])), 1.0);
206        assert_eq!(resolution(&floats(&[2.0, 2.0])), 1.0);
207        assert_eq!(
208            resolution(&[Value::Integer(10), Value::Integer(20)]),
209            1.0,
210            "integer data has resolution 1"
211        );
212        assert_eq!(resolution(&[Value::Str("a".into())]), 1.0);
213    }
214
215    #[test]
216    fn default_width_scales_with_resolution() {
217        // Values spaced 0.01 apart: jitter must stay within ±0.004.
218        let xs: Vec<f64> = (0..50).map(|i| i as f64 * 0.01).collect();
219        let mut df = frame(floats(&xs), floats(&xs));
220        PositionJitter::default().compute(&mut df, &PositionParams::default());
221        for (orig, v) in xs.iter().zip(df.column("x").unwrap()) {
222            let d = (v.as_f64().unwrap() - orig).abs();
223            assert!(d <= 0.004 + 1e-12, "moved {d}");
224        }
225        // Integer-coded positions keep the classic ±0.4.
226        let mut df = frame(floats(&[1.0, 2.0, 3.0]), floats(&[1.0, 2.0, 3.0]));
227        PositionJitter::default().compute(&mut df, &PositionParams::default());
228        let moved: Vec<f64> = df
229            .column("x")
230            .unwrap()
231            .iter()
232            .zip([1.0, 2.0, 3.0])
233            .map(|(v, o)| (v.as_f64().unwrap() - o).abs())
234            .collect();
235        assert!(moved.iter().all(|d| *d <= 0.4));
236        assert!(moved.iter().any(|d| *d > 0.01));
237    }
238
239    #[test]
240    fn seed_controls_output() {
241        let run = |p: PositionJitter| {
242            let mut df = frame(floats(&[1.0, 2.0, 3.0]), floats(&[1.0, 2.0, 3.0]));
243            p.compute(&mut df, &PositionParams::default());
244            df.column("x").unwrap().to_vec()
245        };
246        assert_eq!(
247            run(PositionJitter::default()),
248            run(PositionJitter::default())
249        );
250        assert_ne!(
251            run(PositionJitter::default()),
252            run(PositionJitter::default().with_seed(1))
253        );
254        assert_eq!(
255            run(PositionJitter::default().with_seed(1)),
256            run(PositionJitter::default().with_seed(1))
257        );
258    }
259
260    #[test]
261    fn zero_amount_disables_axis() {
262        let mut df = frame(floats(&[1.0, 2.0]), floats(&[5.0, 6.0]));
263        PositionJitter::new(0.3, 0.0).compute(&mut df, &PositionParams::default());
264        assert_eq!(df.column("y").unwrap(), &floats(&[5.0, 6.0])[..]);
265        assert_ne!(df.column("x").unwrap(), &floats(&[1.0, 2.0])[..]);
266    }
267}