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 name(&self) -> &str {
201 "bin"
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::scale::ScaleSet;
209
210 fn ungrouped(values: &[f64]) -> DataFrame {
211 let mut df = DataFrame::new();
212 df.add_column(
213 "x".into(),
214 values.iter().map(|v| Value::Float(*v)).collect(),
215 );
216 df
217 }
218
219 fn grouped(values: &[f64], groups: &[&str]) -> DataFrame {
220 let mut df = DataFrame::new();
221 df.add_column(
222 "x".into(),
223 values.iter().map(|v| Value::Float(*v)).collect(),
224 );
225 df.add_column(
226 "fill".into(),
227 groups.iter().map(|g| Value::Str((*g).into())).collect(),
228 );
229 df
230 }
231
232 #[test]
233 fn panelwise_is_true() {
234 assert!(StatBin::default().panelwise());
235 }
236
237 #[test]
238 fn ungrouped_output_is_unchanged_by_197() {
239 let data = ungrouped(&[1.0, 1.0, 2.0, 2.0, 2.0, 3.0]);
242 let out = StatBin {
243 bins: 3,
244 binwidth: None,
245 }
246 .compute_group(&data, &ScaleSet::new());
247 assert_eq!(
248 out.column_names(),
249 vec!["x", "y", "count", "density", "xmin", "xmax"]
250 );
251 let y: Vec<f64> = out
252 .column("y")
253 .unwrap()
254 .iter()
255 .map(|v| v.as_f64().unwrap())
256 .collect();
257 assert_eq!(y.iter().sum::<f64>(), 6.0);
258 }
259
260 #[test]
261 fn groups_share_one_set_of_bin_edges() {
262 let data = grouped(&[0.0, 1.0, 9.0, 10.0], &["a", "a", "b", "b"]);
267 let out = StatBin {
268 bins: 5,
269 binwidth: None,
270 }
271 .compute_group(&data, &ScaleSet::new());
272 let xmins: Vec<f64> = out
273 .column("xmin")
274 .unwrap()
275 .iter()
276 .map(|v| v.as_f64().unwrap())
277 .collect();
278 let fills: Vec<String> = out
279 .column("fill")
280 .unwrap()
281 .iter()
282 .map(|v| v.to_group_key())
283 .collect();
284 let a_edges: std::collections::BTreeSet<i64> = xmins
285 .iter()
286 .zip(&fills)
287 .filter(|(_, f)| *f == "a")
288 .map(|(x, _)| (x * 1000.0).round() as i64)
289 .collect();
290 let b_edges: std::collections::BTreeSet<i64> = xmins
291 .iter()
292 .zip(&fills)
293 .filter(|(_, f)| *f == "b")
294 .map(|(x, _)| (x * 1000.0).round() as i64)
295 .collect();
296 assert_eq!(a_edges, b_edges, "both groups share the same bin edges");
297 let a_total: f64 = out
301 .column("y")
302 .unwrap()
303 .iter()
304 .zip(&fills)
305 .filter(|(_, f)| *f == "a")
306 .map(|(v, _)| v.as_f64().unwrap())
307 .sum();
308 assert_eq!(a_total, 2.0);
309 }
310
311 #[test]
312 fn density_is_normalised_within_its_own_group() {
313 let data = grouped(&[1.0, 1.0, 1.0, 1.0], &["a", "a", "a", "b"]);
316 let out = StatBin {
317 bins: 1,
318 binwidth: None,
319 }
320 .compute_group(&data, &ScaleSet::new());
321 let width = out.column("xmax").unwrap()[0].as_f64().unwrap()
322 - out.column("xmin").unwrap()[0].as_f64().unwrap();
323 let fills: Vec<String> = out
324 .column("fill")
325 .unwrap()
326 .iter()
327 .map(|v| v.to_group_key())
328 .collect();
329 let density: Vec<f64> = out
330 .column("density")
331 .unwrap()
332 .iter()
333 .map(|v| v.as_f64().unwrap())
334 .collect();
335 for (f, d) in fills.iter().zip(&density) {
336 assert!((d * width - 1.0).abs() < 1e-9, "{f}: {d}");
340 }
341 }
342
343 #[test]
344 fn carried_columns_stay_constant_within_a_group() {
345 let mut data = grouped(&[1.0, 2.0, 1.0, 2.0], &["a", "a", "b", "b"]);
346 data.add_column(
347 "color".into(),
348 vec![
349 Value::Str("red".into()),
350 Value::Str("red".into()),
351 Value::Str("blue".into()),
352 Value::Str("blue".into()),
353 ],
354 );
355 let out = StatBin {
356 bins: 2,
357 binwidth: None,
358 }
359 .compute_group(&data, &ScaleSet::new());
360 let fills = out.column("fill").unwrap();
361 let colors = out.column("color").unwrap();
362 for (f, c) in fills.iter().zip(colors.iter()) {
363 let expect = if f.to_group_key() == "a" {
364 "red"
365 } else {
366 "blue"
367 };
368 assert_eq!(c.to_group_key(), expect);
369 }
370 }
371}