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