1use crate::data::{DataFrame, Value};
2use crate::rng::SplitMix64;
3
4use super::{Position, PositionParams};
5
6pub const JITTER_SEED: u64 = 0x6A17_7E2D;
10
11#[derive(Clone, Debug)]
20pub struct PositionJitter {
21 pub width: Option<f64>,
23 pub height: Option<f64>,
25 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 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 pub fn with_width(mut self, width: f64) -> Self {
51 self.width = Some(width);
52 self
53 }
54
55 pub fn with_height(mut self, height: f64) -> Self {
57 self.height = Some(height);
58 self
59 }
60
61 pub fn with_seed(mut self, seed: u64) -> Self {
63 self.seed = seed;
64 self
65 }
66}
67
68pub 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 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 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 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 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 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}