Skip to main content

ggplot_rs/geom/
histogram.rs

1use crate::aes::Aesthetic;
2use crate::coord::Coord;
3use crate::data::DataFrame;
4use crate::position::identity::PositionIdentity;
5use crate::position::Position;
6use crate::render::backend::{DrawBackend, RectStyle};
7use crate::render::RenderError;
8use crate::scale::ScaleSet;
9use crate::stat::bin::StatBin;
10use crate::stat::Stat;
11use crate::theme::Theme;
12
13use super::{Geom, GeomParams};
14
15/// Histogram geometry (bar chart with StatBin).
16pub struct GeomHistogram {
17    pub bins: usize,
18    pub binwidth: Option<f64>,
19    pub fill: (u8, u8, u8),
20    pub color: (u8, u8, u8),
21    pub alpha: f64,
22}
23
24impl GeomHistogram {
25    /// Set bin width (overrides bins count).
26    pub fn with_binwidth(mut self, width: f64) -> Self {
27        self.binwidth = Some(width);
28        self
29    }
30
31    /// Set number of bins.
32    pub fn with_bins(mut self, bins: usize) -> Self {
33        self.bins = bins;
34        self.binwidth = None;
35        self
36    }
37}
38
39impl Default for GeomHistogram {
40    fn default() -> Self {
41        GeomHistogram {
42            bins: 30,
43            binwidth: None,
44            fill: (97, 156, 255),
45            color: (50, 50, 50),
46            alpha: 1.0,
47        }
48    }
49}
50
51impl Geom for GeomHistogram {
52    fn draw(
53        &self,
54        data: &DataFrame,
55        coord: &dyn Coord,
56        scales: &ScaleSet,
57        _theme: &Theme,
58        backend: &mut dyn DrawBackend,
59    ) -> Result<(), RenderError> {
60        let xmin_col = data.column("xmin");
61        let xmax_col = data.column("xmax");
62        let y_col = data
63            .column("y")
64            .ok_or(RenderError::MissingAesthetic("y".into()))?;
65        // #197: a stacked segment's bottom (`position_stack`/`position_fill`
66        // write `ymin`); an overlaid/dodged/plain histogram has none, so
67        // every bar still starts at 0, as before.
68        let ymin_col = data.column("ymin");
69        // #197: `aes(fill = group)` — resolved per row through the fill
70        // scale, the same `fill_col`/`map_color` idiom `GeomBar` uses; the
71        // outline stays this geom's own single colour, as `GeomBar`'s does.
72        let fill_col = data.column("fill");
73
74        let plot_area = backend.plot_area();
75        let x_scale = scales.get(&Aesthetic::X);
76        let y_scale = scales.get(&Aesthetic::Y);
77
78        for i in 0..data.nrows() {
79            let (left_n, right_n) = match (xmin_col, xmax_col) {
80                (Some(xmin), Some(xmax)) => {
81                    let ln = x_scale.map(|s| s.map(&xmin[i])).unwrap_or(0.0);
82                    let rn = x_scale.map(|s| s.map(&xmax[i])).unwrap_or(1.0);
83                    (ln, rn)
84                }
85                _ => {
86                    // Fall back to x column with small width
87                    let x_col = data
88                        .column("x")
89                        .ok_or(RenderError::MissingAesthetic("x".into()))?;
90                    let nx = x_scale.map(|s| s.map(&x_col[i])).unwrap_or(0.0);
91                    (nx - 0.02, nx + 0.02)
92                }
93            };
94
95            let fill = fill_col
96                .and_then(|c| scales.map_color(&Aesthetic::Fill, &c[i]))
97                .unwrap_or(self.fill);
98
99            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
100            let ny_base = ymin_col
101                .and_then(|c| c[i].as_f64())
102                .and_then(|v| y_scale.map(|s| s.map(&crate::data::Value::Float(v))))
103                .unwrap_or_else(|| {
104                    y_scale
105                        .map(|s| s.map(&crate::data::Value::Float(0.0)))
106                        .unwrap_or(0.0)
107                });
108
109            let (left_px, top_px) = coord.transform((left_n, ny), &plot_area);
110            let (right_px, bottom_px) = coord.transform((right_n, ny_base), &plot_area);
111
112            // Hover tooltip: the bin range and its count.
113            let cnt = super::tip_value(&y_col[i]);
114            let tip = match (xmin_col, xmax_col) {
115                // Edges rounded to the bin width's precision: "[223.4,
116                // 230.7)", not the raw "[223.35, 230.7266)".
117                (Some(lo), Some(hi)) => {
118                    let (a, b) = (lo[i].as_f64(), hi[i].as_f64());
119                    let d = a.zip(b).map(|(a, b)| {
120                        (1.0 - (b - a).abs().log10().floor()).clamp(0.0, 8.0) as usize
121                    });
122                    let e = |v: Option<f64>, raw: &crate::data::Value| match (v, d) {
123                        (Some(v), Some(d)) if v.is_finite() => edge(v, d),
124                        _ => super::tip_value(raw),
125                    };
126                    format!("[{}, {}): {cnt}", e(a, &lo[i]), e(b, &hi[i]))
127                }
128                _ => format!("count: {cnt}"),
129            };
130            super::set_mark(
131                backend,
132                Some(tip),
133                data.column("x").map(|c| super::tip_value(&c[i])),
134                super::series_key(data, i),
135                super::measured_value(data, i),
136            );
137            super::set_mark_formatted(
138                backend,
139                scales,
140                data.column("x").map(|c| &c[i]),
141                &Aesthetic::Y,
142                super::measured_raw(data, i),
143            );
144            super::set_row_extras(backend, data, i);
145
146            backend.draw_rect(
147                (left_px, top_px.min(bottom_px)),
148                (right_px, top_px.max(bottom_px)),
149                &RectStyle {
150                    fill: Some(fill),
151                    stroke: Some(self.color),
152                    stroke_width: 0.5,
153                    alpha: self.alpha,
154                    clip: true,
155                    corner_radius: 0.0,
156                },
157            )?;
158        }
159        super::clear_mark(backend);
160        super::clear_row_extras(backend);
161
162        Ok(())
163    }
164
165    fn required_aes(&self) -> Vec<Aesthetic> {
166        vec![Aesthetic::X]
167    }
168
169    fn default_stat(&self) -> Box<dyn Stat> {
170        let mut stat = StatBin {
171            bins: self.bins,
172            binwidth: self.binwidth,
173        };
174        if let Some(bw) = self.binwidth {
175            stat = stat.with_binwidth(bw);
176        }
177        Box::new(stat)
178    }
179
180    fn default_position(&self) -> Box<dyn Position> {
181        Box::new(PositionIdentity)
182    }
183
184    fn default_params(&self) -> GeomParams {
185        GeomParams::default()
186    }
187
188    fn name(&self) -> &str {
189        "histogram"
190    }
191
192    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
193        if let (true, Some(ink)) = (self.color == (50, 50, 50), el.ink) {
194            self.color = ink;
195        }
196    }
197
198    fn consumes_tooltips(&self) -> bool {
199        true
200    }
201
202    fn include_zero_baseline(&self) -> bool {
203        true
204    }
205
206    fn set_series_color(&mut self, color: (u8, u8, u8)) {
207        self.fill = color;
208    }
209}
210
211/// A bin edge with `d` decimals, trailing zeros dropped (`223.40` → `223.4`).
212fn edge(v: f64, d: usize) -> String {
213    let s = format!("{v:.d$}");
214    if s.contains('.') {
215        s.trim_end_matches('0').trim_end_matches('.').to_string()
216    } else {
217        s
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use crate::aes::{AesMapping, Aesthetic, MappingStage};
224    use crate::data::Value;
225    use crate::prelude::*;
226
227    /// Review R-7 (nit): an after_stat `data_attr` on a histogram — there's
228    /// no builder for it (`Aes::data_attr` only maps BeforeStat), so this
229    /// manually constructs the mapping, the same way a caller reaching for
230    /// R's `stage()`-equivalent would have to. It must actually work (the
231    /// underlying gap was `GeomHistogram` never calling
232    /// `geom::set_row_extras` at all, not anything specific to after_stat).
233    #[test]
234    fn after_stat_data_attr_works_on_a_histogram() {
235        let n = 50;
236        let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64 * 0.2)).collect();
237        let mut aes = Aes::new().x("x");
238        aes.mappings.push(AesMapping {
239            column: "count".to_string(),
240            aesthetic: Aesthetic::DataAttr("data-n".to_string()),
241            stage: MappingStage::AfterStat,
242        });
243
244        let svg = GGPlot::new(vec![("x".into(), x)])
245            .aes(aes)
246            .geom_histogram()
247            .render_svg_native_with_size(400, 300)
248            .unwrap();
249
250        assert!(svg.contains("data-n=\""));
251    }
252
253    /// A bar's tooltip rounds its bin edges to the bin width's precision
254    /// (no raw "238.753").
255    #[test]
256    fn tooltip_edges_follow_the_bin_width() {
257        assert_eq!(super::edge(223.36, 1), "223.4");
258        assert_eq!(super::edge(230.0, 1), "230");
259        assert_eq!(super::edge(0.125, 3), "0.125");
260        let x: Vec<Value> = (0..200)
261            .map(|i| Value::Float(100.0 + i as f64 * 1.37))
262            .collect();
263        let svg = GGPlot::new(vec![("x".into(), x)])
264            .aes(Aes::new().x("x"))
265            .geom_histogram()
266            .render_svg_native_with_size(400, 300)
267            .unwrap();
268        let tip = svg
269            .split("<title>")
270            .nth(1)
271            .unwrap()
272            .split("</title>")
273            .next()
274            .unwrap();
275        let edges = tip.trim_start_matches('[').split(')').next().unwrap();
276        for e in edges.split(", ") {
277            let dec = e.split('.').nth(1).map_or(0, str::len);
278            assert!(dec <= 2, "{tip}");
279        }
280    }
281
282    /// Every `data-geom="histogram"` rect's `fill="#rrggbb"`.
283    fn histogram_fills(svg: &str) -> Vec<String> {
284        let mut out = Vec::new();
285        let mut rest = svg;
286        while let Some(i) = rest.find("<rect ") {
287            let end = i + rest[i..].find('>').unwrap();
288            let tag = &rest[i..end];
289            if tag.contains("data-geom=\"histogram\"") {
290                let k = tag.find(" fill=\"").unwrap() + 7;
291                out.push(tag[k..k + tag[k..].find('"').unwrap()].to_string());
292            }
293            rest = &rest[end + 1..];
294        }
295        out
296    }
297
298    fn grouped_data() -> Vec<(String, Vec<Value>)> {
299        let n = 40;
300        let v: Vec<Value> = (0..n).map(|i| Value::Float((i % 10) as f64)).collect();
301        let g: Vec<Value> = (0..n)
302            .map(|i| {
303                Value::Str(if i < n / 2 {
304                    "ref".into()
305                } else {
306                    "cur".into()
307                })
308            })
309            .collect();
310        vec![("v".into(), v), ("g".into(), g)]
311    }
312
313    /// #197: `aes(fill = group)` draws each group's bars in a distinct
314    /// colour (overlaid, `position_identity` — the default) with a legend,
315    /// instead of one combined series in a single literal colour.
316    #[test]
317    fn grouped_fill_draws_distinct_colours_with_a_legend() {
318        let svg = GGPlot::new(grouped_data())
319            .aes(Aes::new().x("v").fill("g"))
320            .geom_histogram_with(GeomHistogram {
321                alpha: 0.6,
322                ..Default::default()
323            })
324            .render_svg_native_with_size(500, 350)
325            .unwrap();
326        let fills: std::collections::BTreeSet<String> = histogram_fills(&svg).into_iter().collect();
327        assert!(fills.len() >= 2, "expected ≥2 fill colours: {fills:?}");
328        assert!(
329            svg.contains(">ref<") && svg.contains(">cur<"),
330            "legend: {svg}"
331        );
332    }
333
334    /// #197: bins are computed once over the pooled x range, so every
335    /// group's bars share the same edges (needed for overlay/stack/dodge to
336    /// line up) — not each group its own range.
337    #[test]
338    fn grouped_fill_shares_bin_edges() {
339        let svg = GGPlot::new(grouped_data())
340            .aes(Aes::new().x("v").fill("g"))
341            .geom_histogram()
342            .render_svg_native_with_size(500, 350)
343            .unwrap();
344        let mut widths = std::collections::BTreeSet::new();
345        let mut rest = svg.as_str();
346        while let Some(i) = rest.find("<rect ") {
347            let end = i + rest[i..].find('>').unwrap();
348            let tag = &rest[i..end];
349            if tag.contains("data-geom=\"histogram\"") {
350                let k = tag.find(" width=\"").unwrap() + 8;
351                let w: f64 = tag[k..k + tag[k..].find('"').unwrap()].parse().unwrap();
352                widths.insert((w * 100.0).round() as i64);
353            }
354            rest = &rest[end + 1..];
355        }
356        assert_eq!(widths.len(), 1, "every bar the same width: {widths:?}");
357    }
358
359    /// #197: `position_stack` — a stacked histogram's upper group starts
360    /// where the lower one's bar ends (reads the new `ymin` column), not at
361    /// 0 for every group.
362    #[test]
363    fn grouped_fill_can_be_stacked() {
364        let svg = GGPlot::new(grouped_data())
365            .aes(Aes::new().x("v").fill("g"))
366            .geom_histogram()
367            .position(crate::position::stack::PositionStack)
368            .render_svg_native_with_size(500, 350)
369            .unwrap();
370        // Two bars at the same x: their y-ranges (top_px..bottom_px) must
371        // not both start at the panel's zero baseline.
372        let mut tops = Vec::new();
373        let mut rest = svg.as_str();
374        while let Some(i) = rest.find("<rect ") {
375            let end = i + rest[i..].find('>').unwrap();
376            let tag = &rest[i..end];
377            if tag.contains("data-geom=\"histogram\"") {
378                let k = tag.find(" y=\"").unwrap() + 4;
379                let y: f64 = tag[k..k + tag[k..].find('"').unwrap()].parse().unwrap();
380                tops.push(y);
381            }
382            rest = &rest[end + 1..];
383        }
384        let distinct: std::collections::BTreeSet<i64> =
385            tops.iter().map(|t| (t * 10.0).round() as i64).collect();
386        assert!(
387            distinct.len() > 1,
388            "stacked bars should start at different heights: {tops:?}"
389        );
390    }
391
392    /// #197: `position_dodge2` — ggplot2's own answer for dodging a
393    /// variable-width stat (bins can differ in width); groups at the same
394    /// bin sit side by side instead of overlapping.
395    #[test]
396    fn grouped_fill_can_be_dodged() {
397        // `position_dodge2`'s width must match the bars' own width to not
398        // spill into the next bin — ggplot2's own convention (a caller
399        // writes `position_dodge2(width = <bar width>)`); an explicit
400        // `binwidth` here makes that width known.
401        let svg = GGPlot::new(grouped_data())
402            .aes(Aes::new().x("v").fill("g"))
403            .geom_histogram_with(GeomHistogram {
404                binwidth: Some(1.0),
405                ..Default::default()
406            })
407            .position(crate::position::dodge2::PositionDodge2::default().with_width(1.0))
408            .render_svg_native_with_size(500, 350)
409            .unwrap();
410        let mut spans: Vec<(f64, f64)> = Vec::new();
411        let mut rest = svg.as_str();
412        while let Some(i) = rest.find("<rect ") {
413            let end = i + rest[i..].find('>').unwrap();
414            let tag = &rest[i..end];
415            if tag.contains("data-geom=\"histogram\"") {
416                let attr = |a: &str| -> f64 {
417                    let k = tag.find(&format!(" {a}=\"")).unwrap() + a.len() + 3;
418                    tag[k..k + tag[k..].find('"').unwrap()].parse().unwrap()
419                };
420                let x = attr("x");
421                spans.push((x, x + attr("width")));
422            }
423            rest = &rest[end + 1..];
424        }
425        // No two bars' x-spans should overlap once dodged.
426        for i in 0..spans.len() {
427            for j in (i + 1)..spans.len() {
428                let (a, b) = (spans[i], spans[j]);
429                let overlap = a.0 < b.1 - 0.01 && b.0 < a.1 - 0.01;
430                assert!(!overlap, "bars {a:?} and {b:?} overlap after dodge2");
431            }
432        }
433    }
434}