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                (Some(lo), Some(hi)) => format!(
116                    "[{}, {}): {cnt}",
117                    super::tip_value(&lo[i]),
118                    super::tip_value(&hi[i])
119                ),
120                _ => format!("count: {cnt}"),
121            };
122            super::set_mark(
123                backend,
124                Some(tip),
125                data.column("x").map(|c| super::tip_value(&c[i])),
126                super::series_key(data, i),
127                super::measured_value(data, i),
128            );
129            super::set_mark_formatted(
130                backend,
131                scales,
132                data.column("x").map(|c| &c[i]),
133                &Aesthetic::Y,
134                super::measured_raw(data, i),
135            );
136            super::set_row_extras(backend, data, i);
137
138            backend.draw_rect(
139                (left_px, top_px.min(bottom_px)),
140                (right_px, top_px.max(bottom_px)),
141                &RectStyle {
142                    fill: Some(fill),
143                    stroke: Some(self.color),
144                    stroke_width: 0.5,
145                    alpha: self.alpha,
146                    clip: true,
147                    corner_radius: 0.0,
148                },
149            )?;
150        }
151        super::clear_mark(backend);
152        super::clear_row_extras(backend);
153
154        Ok(())
155    }
156
157    fn required_aes(&self) -> Vec<Aesthetic> {
158        vec![Aesthetic::X]
159    }
160
161    fn default_stat(&self) -> Box<dyn Stat> {
162        let mut stat = StatBin {
163            bins: self.bins,
164            binwidth: self.binwidth,
165        };
166        if let Some(bw) = self.binwidth {
167            stat = stat.with_binwidth(bw);
168        }
169        Box::new(stat)
170    }
171
172    fn default_position(&self) -> Box<dyn Position> {
173        Box::new(PositionIdentity)
174    }
175
176    fn default_params(&self) -> GeomParams {
177        GeomParams::default()
178    }
179
180    fn name(&self) -> &str {
181        "histogram"
182    }
183
184    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
185        if let (true, Some(ink)) = (self.color == (50, 50, 50), el.ink) {
186            self.color = ink;
187        }
188    }
189
190    fn consumes_tooltips(&self) -> bool {
191        true
192    }
193
194    fn include_zero_baseline(&self) -> bool {
195        true
196    }
197
198    fn set_series_color(&mut self, color: (u8, u8, u8)) {
199        self.fill = color;
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use crate::aes::{AesMapping, Aesthetic, MappingStage};
206    use crate::data::Value;
207    use crate::prelude::*;
208
209    /// Review R-7 (nit): an after_stat `data_attr` on a histogram — there's
210    /// no builder for it (`Aes::data_attr` only maps BeforeStat), so this
211    /// manually constructs the mapping, the same way a caller reaching for
212    /// R's `stage()`-equivalent would have to. It must actually work (the
213    /// underlying gap was `GeomHistogram` never calling
214    /// `geom::set_row_extras` at all, not anything specific to after_stat).
215    #[test]
216    fn after_stat_data_attr_works_on_a_histogram() {
217        let n = 50;
218        let x: Vec<Value> = (0..n).map(|i| Value::Float(i as f64 * 0.2)).collect();
219        let mut aes = Aes::new().x("x");
220        aes.mappings.push(AesMapping {
221            column: "count".to_string(),
222            aesthetic: Aesthetic::DataAttr("data-n".to_string()),
223            stage: MappingStage::AfterStat,
224        });
225
226        let svg = GGPlot::new(vec![("x".into(), x)])
227            .aes(aes)
228            .geom_histogram()
229            .render_svg_native_with_size(400, 300)
230            .unwrap();
231
232        assert!(svg.contains("data-n=\""));
233    }
234
235    /// Every `data-geom="histogram"` rect's `fill="#rrggbb"`.
236    fn histogram_fills(svg: &str) -> Vec<String> {
237        let mut out = Vec::new();
238        let mut rest = svg;
239        while let Some(i) = rest.find("<rect ") {
240            let end = i + rest[i..].find('>').unwrap();
241            let tag = &rest[i..end];
242            if tag.contains("data-geom=\"histogram\"") {
243                let k = tag.find(" fill=\"").unwrap() + 7;
244                out.push(tag[k..k + tag[k..].find('"').unwrap()].to_string());
245            }
246            rest = &rest[end + 1..];
247        }
248        out
249    }
250
251    fn grouped_data() -> Vec<(String, Vec<Value>)> {
252        let n = 40;
253        let v: Vec<Value> = (0..n).map(|i| Value::Float((i % 10) as f64)).collect();
254        let g: Vec<Value> = (0..n)
255            .map(|i| {
256                Value::Str(if i < n / 2 {
257                    "ref".into()
258                } else {
259                    "cur".into()
260                })
261            })
262            .collect();
263        vec![("v".into(), v), ("g".into(), g)]
264    }
265
266    /// #197: `aes(fill = group)` draws each group's bars in a distinct
267    /// colour (overlaid, `position_identity` — the default) with a legend,
268    /// instead of one combined series in a single literal colour.
269    #[test]
270    fn grouped_fill_draws_distinct_colours_with_a_legend() {
271        let svg = GGPlot::new(grouped_data())
272            .aes(Aes::new().x("v").fill("g"))
273            .geom_histogram_with(GeomHistogram {
274                alpha: 0.6,
275                ..Default::default()
276            })
277            .render_svg_native_with_size(500, 350)
278            .unwrap();
279        let fills: std::collections::BTreeSet<String> = histogram_fills(&svg).into_iter().collect();
280        assert!(fills.len() >= 2, "expected ≥2 fill colours: {fills:?}");
281        assert!(
282            svg.contains(">ref<") && svg.contains(">cur<"),
283            "legend: {svg}"
284        );
285    }
286
287    /// #197: bins are computed once over the pooled x range, so every
288    /// group's bars share the same edges (needed for overlay/stack/dodge to
289    /// line up) — not each group its own range.
290    #[test]
291    fn grouped_fill_shares_bin_edges() {
292        let svg = GGPlot::new(grouped_data())
293            .aes(Aes::new().x("v").fill("g"))
294            .geom_histogram()
295            .render_svg_native_with_size(500, 350)
296            .unwrap();
297        let mut widths = std::collections::BTreeSet::new();
298        let mut rest = svg.as_str();
299        while let Some(i) = rest.find("<rect ") {
300            let end = i + rest[i..].find('>').unwrap();
301            let tag = &rest[i..end];
302            if tag.contains("data-geom=\"histogram\"") {
303                let k = tag.find(" width=\"").unwrap() + 8;
304                let w: f64 = tag[k..k + tag[k..].find('"').unwrap()].parse().unwrap();
305                widths.insert((w * 100.0).round() as i64);
306            }
307            rest = &rest[end + 1..];
308        }
309        assert_eq!(widths.len(), 1, "every bar the same width: {widths:?}");
310    }
311
312    /// #197: `position_stack` — a stacked histogram's upper group starts
313    /// where the lower one's bar ends (reads the new `ymin` column), not at
314    /// 0 for every group.
315    #[test]
316    fn grouped_fill_can_be_stacked() {
317        let svg = GGPlot::new(grouped_data())
318            .aes(Aes::new().x("v").fill("g"))
319            .geom_histogram()
320            .position(crate::position::stack::PositionStack)
321            .render_svg_native_with_size(500, 350)
322            .unwrap();
323        // Two bars at the same x: their y-ranges (top_px..bottom_px) must
324        // not both start at the panel's zero baseline.
325        let mut tops = Vec::new();
326        let mut rest = svg.as_str();
327        while let Some(i) = rest.find("<rect ") {
328            let end = i + rest[i..].find('>').unwrap();
329            let tag = &rest[i..end];
330            if tag.contains("data-geom=\"histogram\"") {
331                let k = tag.find(" y=\"").unwrap() + 4;
332                let y: f64 = tag[k..k + tag[k..].find('"').unwrap()].parse().unwrap();
333                tops.push(y);
334            }
335            rest = &rest[end + 1..];
336        }
337        let distinct: std::collections::BTreeSet<i64> =
338            tops.iter().map(|t| (t * 10.0).round() as i64).collect();
339        assert!(
340            distinct.len() > 1,
341            "stacked bars should start at different heights: {tops:?}"
342        );
343    }
344
345    /// #197: `position_dodge2` — ggplot2's own answer for dodging a
346    /// variable-width stat (bins can differ in width); groups at the same
347    /// bin sit side by side instead of overlapping.
348    #[test]
349    fn grouped_fill_can_be_dodged() {
350        // `position_dodge2`'s width must match the bars' own width to not
351        // spill into the next bin — ggplot2's own convention (a caller
352        // writes `position_dodge2(width = <bar width>)`); an explicit
353        // `binwidth` here makes that width known.
354        let svg = GGPlot::new(grouped_data())
355            .aes(Aes::new().x("v").fill("g"))
356            .geom_histogram_with(GeomHistogram {
357                binwidth: Some(1.0),
358                ..Default::default()
359            })
360            .position(crate::position::dodge2::PositionDodge2::default().with_width(1.0))
361            .render_svg_native_with_size(500, 350)
362            .unwrap();
363        let mut spans: Vec<(f64, f64)> = Vec::new();
364        let mut rest = svg.as_str();
365        while let Some(i) = rest.find("<rect ") {
366            let end = i + rest[i..].find('>').unwrap();
367            let tag = &rest[i..end];
368            if tag.contains("data-geom=\"histogram\"") {
369                let attr = |a: &str| -> f64 {
370                    let k = tag.find(&format!(" {a}=\"")).unwrap() + a.len() + 3;
371                    tag[k..k + tag[k..].find('"').unwrap()].parse().unwrap()
372                };
373                let x = attr("x");
374                spans.push((x, x + attr("width")));
375            }
376            rest = &rest[end + 1..];
377        }
378        // No two bars' x-spans should overlap once dodged.
379        for i in 0..spans.len() {
380            for j in (i + 1)..spans.len() {
381                let (a, b) = (spans[i], spans[j]);
382                let overlap = a.0 < b.1 - 0.01 && b.0 < a.1 - 0.01;
383                assert!(!overlap, "bars {a:?} and {b:?} overlap after dodge2");
384            }
385        }
386    }
387}