Skip to main content

ggplot_rs/stat/
bin.rs

1use crate::aes::Aesthetic;
2use crate::data::{DataFrame, Value};
3use crate::scale::ScaleSet;
4
5use super::Stat;
6
7/// ggplot2 bin alignment: place bins so an edge falls on `boundary` (mod
8/// `width`), then return the left edge of the first bin (`origin`, ≤ `min`) and
9/// the number of bins needed to cover `[min, max]`.
10pub(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
17/// 1-D histogram alignment: a *bin* is centered on 0 (`boundary = width/2`),
18/// matching `geom_histogram`.
19fn aligned_bins(min: f64, max: f64, width: f64) -> (f64, usize) {
20    aligned_bins_at(min, max, width, width / 2.0)
21}
22
23/// Bins continuous x values into histogram bins.
24pub struct StatBin {
25    pub bins: usize,
26    pub binwidth: Option<f64>,
27}
28
29impl StatBin {
30    /// Set bin width (overrides bins count).
31    pub fn with_binwidth(mut self, width: f64) -> Self {
32        self.binwidth = Some(width);
33        self
34    }
35
36    /// Set number of bins.
37    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    /// #197: `panelwise` (every group's rows arrive together, grouped only
55    /// by facet) so bins are computed once over the whole panel — ggplot2's
56    /// own `stat_bin` behaviour — then counted per `aes(fill=)`/`aes(color=)`
57    /// group. Ungrouped input (the common case: no fill/color/group aes) is
58    /// just one implicit group, so this is byte-identical to before #197.
59    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        // Handle case where all values are the same
74        let (min, max) = if (max - min).abs() < f64::EPSILON {
75            (min - 0.5, max + 0.5)
76        } else {
77            (min, max)
78        };
79
80        // Match ggplot2's bin_breaks: for a bin count, width spans the range in
81        // `bins - 1` steps; bins are then aligned to `boundary = width/2` so a
82        // bin is centered on 0 (the origin is shifted left of the data min),
83        // rather than starting exactly at the data minimum.
84        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        // ggplot2's default bins are right-closed: (a, b]. A point on a
95        // boundary falls in the lower bin.
96        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        // Rows grouped by every column but `x` (the aes `fill`/`color`/
102        // `group` columns, if any) — first-appearance order, as `StatAlign`
103        // groups a panel. A plain, ungrouped histogram has no such columns,
104        // so every row joins the one group keyed `""`.
105        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        // Carry every other input column (fill/color/group, and anything
129        // else the caller mapped) from the group's first row onto all of
130        // that group's output rows — the same idiom `StatAlign`/`StatQQ`
131        // use to keep a layer's grouping visible after the stat runs.
132        // Excludes this stat's own output columns, not just `x`: aes
133        // resolution can leave the *source* column (e.g. a data column
134        // literally named "y", mapped to the x aesthetic) sitting
135        // alongside the renamed one, and carrying it forward here would
136        // silently overwrite the real computed "y"/"count"/etc. with that
137        // unrelated pre-stat column's value.
138        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        // One row per (group, bin) — every bin for every group, so a
153        // sparse group's empty bins still draw a zero-height bar/point
154        // instead of vanishing (ggplot2's own stat_bin behaviour).
155        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        // Expose the count under its ggplot stat name for after_stat expressions
181        // (e.g. after_stat_y("count / sum(count)")).
182        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        // #197's grouped path reduces to exactly this for a single implicit
240        // group: same column set, same values, no "fill"/"color" columns.
241        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        // Two groups with different ranges: every group's bins must land on
263        // the SAME grid (computed from the pooled x range), not each its
264        // own — otherwise overlaid/stacked/dodged bars from different
265        // groups wouldn't line up.
266        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        // Every bin appears for every group (b has none in [0,1], a has
298        // none in [9,10]) — a sparse group still gets a zero-count row,
299        // not a missing one.
300        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        // Group "a" has 3 rows, "b" has 1 — each group's own density must
314        // integrate to 1 (not the pooled total), ggplot2's stat_bin default.
315        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            // A single bin holds every one of the group's values, so its
337            // density * width (its share of that group's own total) is 1 —
338            // independent of the *other* group's size (3 vs. 1 rows).
339            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}