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
15pub 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 pub fn with_binwidth(mut self, width: f64) -> Self {
27 self.binwidth = Some(width);
28 self
29 }
30
31 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 let ymin_col = data.column("ymin");
69 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 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 let cnt = super::tip_value(&y_col[i]);
114 let tip = match (xmin_col, xmax_col) {
115 (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
211fn 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 #[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 #[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 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 #[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 #[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 #[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 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 #[test]
396 fn grouped_fill_can_be_dodged() {
397 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 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}