1use crate::aes::Aesthetic;
2use crate::data::{DataFrame, Value};
3use crate::scale::ScaleSet;
4
5use super::Stat;
6
7pub(crate) fn aligned_bins_at(min: f64, max: f64, width: f64, boundary: f64) -> (f64, usize) {
11 let shift = ((min - boundary) / width).floor();
12 let origin = boundary + shift * width;
13 let n = (((max - origin) / width).ceil() as usize).max(1);
14 (origin, n)
15}
16
17fn aligned_bins(min: f64, max: f64, width: f64) -> (f64, usize) {
20 aligned_bins_at(min, max, width, width / 2.0)
21}
22
23pub struct StatBin {
25 pub bins: usize,
26 pub binwidth: Option<f64>,
27}
28
29impl StatBin {
30 pub fn with_binwidth(mut self, width: f64) -> Self {
32 self.binwidth = Some(width);
33 self
34 }
35
36 pub fn with_bins(mut self, bins: usize) -> Self {
38 self.bins = bins;
39 self.binwidth = None;
40 self
41 }
42}
43
44impl Default for StatBin {
45 fn default() -> Self {
46 StatBin {
47 bins: 30,
48 binwidth: None,
49 }
50 }
51}
52
53impl Stat for StatBin {
54 fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
60 let x_col = match data.column("x") {
61 Some(c) => c,
62 None => return DataFrame::new(),
63 };
64
65 let values: Vec<f64> = x_col.iter().filter_map(|v| v.as_f64()).collect();
66 if values.is_empty() {
67 return DataFrame::new();
68 }
69
70 let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
71 let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
72
73 let (min, max) = if (max - min).abs() < f64::EPSILON {
75 (min - 0.5, max + 0.5)
76 } else {
77 (min, max)
78 };
79
80 let (bin_width, origin, n_bins) = if let Some(bw) = self.binwidth {
85 let (o, n) = aligned_bins(min, max, bw);
86 (bw, o, n)
87 } else if self.bins <= 1 {
88 (max - min, min, 1)
89 } else {
90 let bw = (max - min) / (self.bins - 1) as f64;
91 let (o, n) = aligned_bins(min, max, bw);
92 (bw, o, n)
93 };
94 let bin_of = |v: f64| -> usize {
97 let raw = ((v - origin) / bin_width).ceil() as i64 - 1;
98 raw.clamp(0, n_bins as i64 - 1) as usize
99 };
100
101 let group_cols: Vec<String> = crate::build::detect_group_columns(data)
106 .into_iter()
107 .filter(|c| c != "x")
108 .collect();
109 let mut order: Vec<String> = Vec::new();
110 let mut first_row: std::collections::HashMap<String, usize> = Default::default();
111 let mut counts: std::collections::HashMap<String, Vec<usize>> = Default::default();
112 for (i, x) in x_col.iter().enumerate() {
113 let Some(v) = x.as_f64() else {
114 continue;
115 };
116 let key: String = group_cols
117 .iter()
118 .map(|c| data.column(c).unwrap()[i].to_group_key())
119 .collect::<Vec<_>>()
120 .join("\u{1f}");
121 if !first_row.contains_key(&key) {
122 order.push(key.clone());
123 first_row.insert(key.clone(), i);
124 }
125 counts.entry(key).or_insert_with(|| vec![0usize; n_bins])[bin_of(v)] += 1;
126 }
127
128 const OUTPUT_COLS: [&str; 6] = ["x", "y", "count", "density", "xmin", "xmax"];
139 let carry: Vec<String> = data
140 .column_names()
141 .into_iter()
142 .filter(|n| !OUTPUT_COLS.contains(n))
143 .map(str::to_string)
144 .collect();
145
146 let mut x_vals = Vec::new();
147 let mut y_vals = Vec::new();
148 let mut density_vals = Vec::new();
149 let mut xmin_vals = Vec::new();
150 let mut xmax_vals = Vec::new();
151 let mut carried: Vec<Vec<Value>> = vec![Vec::new(); carry.len()];
152 for key in &order {
156 let group_counts = &counts[key];
157 let total = group_counts.iter().sum::<usize>() as f64;
158 let first = first_row[key];
159 for (i, &count) in group_counts.iter().enumerate() {
160 let bin_min = origin + i as f64 * bin_width;
161 let bin_max = bin_min + bin_width;
162 x_vals.push(Value::Float((bin_min + bin_max) / 2.0));
163 y_vals.push(Value::Float(count as f64));
164 density_vals.push(Value::Float(if total > 0.0 {
165 count as f64 / (total * bin_width)
166 } else {
167 0.0
168 }));
169 xmin_vals.push(Value::Float(bin_min));
170 xmax_vals.push(Value::Float(bin_max));
171 for (c, name) in carry.iter().enumerate() {
172 carried[c].push(data.column(name).unwrap()[first].clone());
173 }
174 }
175 }
176
177 let mut result = DataFrame::new();
178 result.add_column("x".to_string(), x_vals);
179 result.add_column("y".to_string(), y_vals.clone());
180 result.add_column("count".to_string(), y_vals);
183 result.add_column("density".to_string(), density_vals);
184 result.add_column("xmin".to_string(), xmin_vals);
185 result.add_column("xmax".to_string(), xmax_vals);
186 for (name, vals) in carry.into_iter().zip(carried) {
187 result.add_column(name, vals);
188 }
189 result
190 }
191
192 fn required_aes(&self) -> Vec<Aesthetic> {
193 vec![Aesthetic::X]
194 }
195
196 fn panelwise(&self) -> bool {
197 true
198 }
199
200 fn computed_y(&self) -> bool {
201 true
202 }
203
204 fn name(&self) -> &str {
205 "bin"
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::scale::ScaleSet;
213
214 fn ungrouped(values: &[f64]) -> DataFrame {
215 let mut df = DataFrame::new();
216 df.add_column(
217 "x".into(),
218 values.iter().map(|v| Value::Float(*v)).collect(),
219 );
220 df
221 }
222
223 fn grouped(values: &[f64], groups: &[&str]) -> DataFrame {
224 let mut df = DataFrame::new();
225 df.add_column(
226 "x".into(),
227 values.iter().map(|v| Value::Float(*v)).collect(),
228 );
229 df.add_column(
230 "fill".into(),
231 groups.iter().map(|g| Value::Str((*g).into())).collect(),
232 );
233 df
234 }
235
236 #[test]
237 fn panelwise_is_true() {
238 assert!(StatBin::default().panelwise());
239 }
240
241 #[test]
242 fn ungrouped_output_is_unchanged_by_197() {
243 let data = ungrouped(&[1.0, 1.0, 2.0, 2.0, 2.0, 3.0]);
246 let out = StatBin {
247 bins: 3,
248 binwidth: None,
249 }
250 .compute_group(&data, &ScaleSet::new());
251 assert_eq!(
252 out.column_names(),
253 vec!["x", "y", "count", "density", "xmin", "xmax"]
254 );
255 let y: Vec<f64> = out
256 .column("y")
257 .unwrap()
258 .iter()
259 .map(|v| v.as_f64().unwrap())
260 .collect();
261 assert_eq!(y.iter().sum::<f64>(), 6.0);
262 }
263
264 #[test]
265 fn groups_share_one_set_of_bin_edges() {
266 let data = grouped(&[0.0, 1.0, 9.0, 10.0], &["a", "a", "b", "b"]);
271 let out = StatBin {
272 bins: 5,
273 binwidth: None,
274 }
275 .compute_group(&data, &ScaleSet::new());
276 let xmins: Vec<f64> = out
277 .column("xmin")
278 .unwrap()
279 .iter()
280 .map(|v| v.as_f64().unwrap())
281 .collect();
282 let fills: Vec<String> = out
283 .column("fill")
284 .unwrap()
285 .iter()
286 .map(|v| v.to_group_key())
287 .collect();
288 let a_edges: std::collections::BTreeSet<i64> = xmins
289 .iter()
290 .zip(&fills)
291 .filter(|(_, f)| *f == "a")
292 .map(|(x, _)| (x * 1000.0).round() as i64)
293 .collect();
294 let b_edges: std::collections::BTreeSet<i64> = xmins
295 .iter()
296 .zip(&fills)
297 .filter(|(_, f)| *f == "b")
298 .map(|(x, _)| (x * 1000.0).round() as i64)
299 .collect();
300 assert_eq!(a_edges, b_edges, "both groups share the same bin edges");
301 let a_total: f64 = out
305 .column("y")
306 .unwrap()
307 .iter()
308 .zip(&fills)
309 .filter(|(_, f)| *f == "a")
310 .map(|(v, _)| v.as_f64().unwrap())
311 .sum();
312 assert_eq!(a_total, 2.0);
313 }
314
315 #[test]
316 fn density_is_normalised_within_its_own_group() {
317 let data = grouped(&[1.0, 1.0, 1.0, 1.0], &["a", "a", "a", "b"]);
320 let out = StatBin {
321 bins: 1,
322 binwidth: None,
323 }
324 .compute_group(&data, &ScaleSet::new());
325 let width = out.column("xmax").unwrap()[0].as_f64().unwrap()
326 - out.column("xmin").unwrap()[0].as_f64().unwrap();
327 let fills: Vec<String> = out
328 .column("fill")
329 .unwrap()
330 .iter()
331 .map(|v| v.to_group_key())
332 .collect();
333 let density: Vec<f64> = out
334 .column("density")
335 .unwrap()
336 .iter()
337 .map(|v| v.as_f64().unwrap())
338 .collect();
339 for (f, d) in fills.iter().zip(&density) {
340 assert!((d * width - 1.0).abs() < 1e-9, "{f}: {d}");
344 }
345 }
346
347 #[test]
348 fn carried_columns_stay_constant_within_a_group() {
349 let mut data = grouped(&[1.0, 2.0, 1.0, 2.0], &["a", "a", "b", "b"]);
350 data.add_column(
351 "color".into(),
352 vec![
353 Value::Str("red".into()),
354 Value::Str("red".into()),
355 Value::Str("blue".into()),
356 Value::Str("blue".into()),
357 ],
358 );
359 let out = StatBin {
360 bins: 2,
361 binwidth: None,
362 }
363 .compute_group(&data, &ScaleSet::new());
364 let fills = out.column("fill").unwrap();
365 let colors = out.column("color").unwrap();
366 for (f, c) in fills.iter().zip(colors.iter()) {
367 let expect = if f.to_group_key() == "a" {
368 "red"
369 } else {
370 "blue"
371 };
372 assert_eq!(c.to_group_key(), expect);
373 }
374 }
375}