Skip to main content

ggplot_rs/geom/
boxplot.rs

1use crate::aes::Aesthetic;
2use crate::coord::Coord;
3use crate::data::{DataFrame, Value};
4use crate::position::dodge2::PositionDodge2;
5use crate::position::Position;
6use crate::render::backend::{DrawBackend, LineStyle, Linetype, PointShape, PointStyle, RectStyle};
7use crate::render::RenderError;
8use crate::scale::ScaleSet;
9use crate::stat::boxplot::StatBoxplot;
10use crate::stat::Stat;
11use crate::theme::Theme;
12
13use super::{Geom, GeomParams};
14
15/// Boxplot geometry (ggplot2's `geom_boxplot`).
16#[derive(Clone, Debug)]
17pub struct GeomBoxplot {
18    pub fill: (u8, u8, u8),
19    pub color: (u8, u8, u8),
20    pub width: f64,
21    pub alpha: f64,
22    /// Notched box: the sides pinch in to `notch_width` of the box at the
23    /// median, spanning median ± 1.58·IQR/√n (`notch = TRUE`).
24    pub notch: bool,
25    /// Box width at the notch, relative to the box (`notchwidth`, 0.5).
26    pub notch_width: f64,
27    /// Box width ∝ √n of the group (`varwidth = TRUE`).
28    pub varwidth: bool,
29    /// Whisker reach in IQRs passed to the default stat (`coef`, 1.5).
30    pub coef: f64,
31    /// Draw the outlier points (`outliers = FALSE` hides them).
32    pub outliers: bool,
33    /// Outlier point colour; `None` = the box outline colour.
34    pub outlier_color: Option<(u8, u8, u8)>,
35    /// Outlier point shape (`outlier.shape`; default an open circle).
36    pub outlier_shape: PointShape,
37    /// Fill outlier points (`outlier.shape = 19`-like) instead of outlining.
38    pub outlier_filled: bool,
39    /// Outlier point radius in px (`outlier.size`).
40    pub outlier_size: f64,
41    /// Outlier point opacity (`outlier.alpha`).
42    pub outlier_alpha: f64,
43    /// Whisker end-cap width relative to the box (`staplewidth`; 0 = none).
44    /// ggplot2's default is 0; ggplot-rs keeps its historical 0.5 caps.
45    pub staple_width: f64,
46}
47
48impl Default for GeomBoxplot {
49    fn default() -> Self {
50        GeomBoxplot {
51            fill: (255, 255, 255),
52            color: (50, 50, 50),
53            width: 0.75,
54            alpha: 1.0,
55            notch: false,
56            notch_width: 0.5,
57            varwidth: false,
58            coef: 1.5,
59            outliers: true,
60            outlier_color: None,
61            outlier_shape: PointShape::Circle,
62            outlier_filled: false,
63            outlier_size: 2.0,
64            outlier_alpha: 1.0,
65            staple_width: 0.5,
66        }
67    }
68}
69
70impl GeomBoxplot {
71    /// `notch = TRUE`.
72    pub fn notched(mut self) -> Self {
73        self.notch = true;
74        self
75    }
76
77    /// `varwidth = TRUE`.
78    pub fn varwidth(mut self) -> Self {
79        self.varwidth = true;
80        self
81    }
82
83    /// `coef` — whisker reach in IQRs.
84    pub fn with_coef(mut self, coef: f64) -> Self {
85        self.coef = coef;
86        self
87    }
88
89    /// `outliers = FALSE`: no outlier points, and they don't widen the y scale.
90    pub fn hide_outliers(mut self) -> Self {
91        self.outliers = false;
92        self
93    }
94
95    /// `staplewidth` — whisker cap width relative to the box (0 = none).
96    pub fn with_staple_width(mut self, w: f64) -> Self {
97        self.staple_width = w;
98        self
99    }
100}
101
102/// Parse a boxplot `outliers` cell (B3-5, D4 extension): a comma list,
103/// optionally wrapped in `[...]`/`(...)` (`"7, 9"` or `"[7, 9]"`).
104/// Returns the successfully-parsed values and whether any part was
105/// unparsable (skipped).
106fn parse_outliers(s: &str) -> (Vec<f64>, bool) {
107    let trimmed = s.trim();
108    let inner = trimmed
109        .strip_prefix('[')
110        .and_then(|t| t.strip_suffix(']'))
111        .or_else(|| trimmed.strip_prefix('(').and_then(|t| t.strip_suffix(')')))
112        .unwrap_or(trimmed);
113    let mut out = Vec::new();
114    let mut bad = false;
115    for part in inner.split(',') {
116        let part = part.trim();
117        if part.is_empty() {
118            continue;
119        }
120        match part.parse::<f64>() {
121            Ok(v) if v.is_finite() => out.push(v),
122            _ => bad = true,
123        }
124    }
125    (out, bad)
126}
127
128impl Geom for GeomBoxplot {
129    fn draw(
130        &self,
131        data: &DataFrame,
132        coord: &dyn Coord,
133        scales: &ScaleSet,
134        _theme: &Theme,
135        backend: &mut dyn DrawBackend,
136    ) -> Result<(), RenderError> {
137        let x_col = data
138            .column("x")
139            .ok_or(RenderError::MissingAesthetic("x".into()))?;
140        let lower_col = data.column("lower");
141        let middle_col = data.column("middle");
142        let upper_col = data.column("upper");
143        let ymin_col = data.column("ymin");
144        let ymax_col = data.column("ymax");
145        let outliers_col = data.column("outliers");
146        let fill_col = data.column("fill");
147
148        let plot_area = backend.plot_area();
149        let x_scale = scales.get(&Aesthetic::X);
150        let y_scale = scales.get(&Aesthetic::Y);
151
152        let x_is_discrete = x_scale.map(|s| s.is_discrete()).unwrap_or(false);
153        // A dodge (the default `position_dodge2`) shifts each box within its
154        // x slot and shares the slot's width between the groups there (#404).
155        let x_mapper = super::support::XMapper::new(data, scales);
156        // `varwidth`: box width ∝ √n relative to the largest group.
157        let n_col = data.column("n");
158        let max_sqrt_n = n_col
159            .map(|c| {
160                c.iter()
161                    .filter_map(|v| v.as_f64())
162                    .fold(0.0_f64, f64::max)
163                    .sqrt()
164            })
165            .unwrap_or(0.0);
166        let notch_lo_col = data.column("notchlower");
167        let notch_hi_col = data.column("notchupper");
168
169        for i in 0..data.nrows() {
170            let nx = if x_scale.is_some() {
171                x_mapper.map(&x_col[i], i)
172            } else {
173                0.5
174            };
175
176            let half_width = if x_is_discrete {
177                let n = x_scale.map(|s| s.breaks().len()).unwrap_or(1);
178                self.width / (n.max(1) as f64 * 2.5) / x_mapper.slot_n(i)
179            } else {
180                0.03
181            };
182            let half_width = match n_col.and_then(|c| c[i].as_f64()) {
183                Some(n) if self.varwidth && max_sqrt_n > 0.0 => half_width * n.sqrt() / max_sqrt_n,
184                _ => half_width,
185            };
186
187            // Get boxplot values
188            let lower = lower_col.and_then(|c| c[i].as_f64()).unwrap_or(0.0);
189            let middle = middle_col.and_then(|c| c[i].as_f64()).unwrap_or(0.0);
190            let upper = upper_col.and_then(|c| c[i].as_f64()).unwrap_or(0.0);
191            let ymin = ymin_col.and_then(|c| c[i].as_f64()).unwrap_or(lower);
192            let ymax = ymax_col.and_then(|c| c[i].as_f64()).unwrap_or(upper);
193
194            let map_y = |v: f64| y_scale.map(|s| s.map(&Value::Float(v))).unwrap_or(0.0);
195
196            // Hover tooltip for the whole box: group + median (IQR) — the
197            // geom's own default. B3-5: `set_row_extras` below overrides it
198            // with a mapped `Aes::tooltip`/`data_attr`, if any, same as
199            // every other tooltip-consuming geom.
200            let group = super::tip_value(&x_col[i]);
201            let r = |v: f64| (v * 100.0).round() / 100.0;
202            let tip = if group.is_empty() {
203                format!("median {} ({}–{})", r(middle), r(lower), r(upper))
204            } else {
205                format!("{group}: median {} ({}–{})", r(middle), r(lower), r(upper))
206            };
207            let middle_value = Value::Float(middle);
208            super::set_mark(
209                backend,
210                Some(tip),
211                Some(group.clone()).filter(|g| !g.is_empty()),
212                super::series_key(data, i).or(Some(group).filter(|g| !g.is_empty())),
213                super::raw_value(&middle_value),
214            );
215            super::set_mark_formatted(
216                backend,
217                scales,
218                Some(&x_col[i]),
219                &Aesthetic::Y,
220                Some(&middle_value),
221            );
222            super::set_row_extras(backend, data, i);
223
224            // Box (IQR) — honour a mapped `fill` (e.g. by group), else the default.
225            let box_fill = fill_col
226                .and_then(|fc| scales.map_color(&Aesthetic::Fill, &fc[i]))
227                .unwrap_or(self.fill);
228            let (box_left, box_top) = coord.transform((nx - half_width, map_y(upper)), &plot_area);
229            let (box_right, box_bottom) =
230                coord.transform((nx + half_width, map_y(lower)), &plot_area);
231            let box_style = RectStyle {
232                fill: Some(box_fill),
233                stroke: Some(self.color),
234                stroke_width: 1.0,
235                alpha: self.alpha,
236                clip: true,
237                corner_radius: 0.0,
238            };
239            // Notch: the sides pinch in at the median (median ± 1.58·IQR/√n).
240            let notch = match (notch_lo_col, notch_hi_col) {
241                (Some(lo), Some(hi)) if self.notch => {
242                    lo[i].as_f64().zip(hi[i].as_f64()).map(|(lo, hi)| {
243                        // Never wider than the box itself (as ggplot2 warns).
244                        (lo.max(lower), hi.min(upper))
245                    })
246                }
247                _ => None,
248            };
249            let inset = half_width * (1.0 - self.notch_width.clamp(0.0, 1.0));
250            if let Some((nlo, nhi)) = notch {
251                let pts = [
252                    (nx - half_width, upper),
253                    (nx + half_width, upper),
254                    (nx + half_width, nhi),
255                    (nx + half_width - inset, middle),
256                    (nx + half_width, nlo),
257                    (nx + half_width, lower),
258                    (nx - half_width, lower),
259                    (nx - half_width, nlo),
260                    (nx - half_width + inset, middle),
261                    (nx - half_width, nhi),
262                ];
263                let poly: Vec<(f64, f64)> = pts
264                    .iter()
265                    .map(|&(x, y)| coord.transform((x, map_y(y)), &plot_area))
266                    .collect();
267                backend.draw_polygon(&poly, &box_style)?;
268            } else {
269                backend.draw_rect((box_left, box_top), (box_right, box_bottom), &box_style)?;
270            }
271
272            // Median line (inset to the notch's waist when notched)
273            let med_half = if notch.is_some() {
274                half_width - inset
275            } else {
276                half_width
277            };
278            let (med_left, med_y) = coord.transform((nx - med_half, map_y(middle)), &plot_area);
279            let (med_right, _) = coord.transform((nx + med_half, map_y(middle)), &plot_area);
280            backend.draw_line(
281                &[(med_left, med_y), (med_right, med_y)],
282                &LineStyle {
283                    color: self.color,
284                    width: 2.0,
285                    alpha: 1.0,
286                    linetype: Linetype::Solid,
287                    ..Default::default()
288                },
289            )?;
290
291            // Lower whisker
292            let (_, whisker_bottom) = coord.transform((nx, map_y(ymin)), &plot_area);
293            let (center_x, _) = coord.transform((nx, map_y(lower)), &plot_area);
294            backend.draw_line(
295                &[(center_x, box_bottom), (center_x, whisker_bottom)],
296                &LineStyle {
297                    color: self.color,
298                    width: 1.0,
299                    alpha: 1.0,
300                    linetype: Linetype::Solid,
301                    ..Default::default()
302                },
303            )?;
304            // Lower whisker cap (staple)
305            let staple = half_width * self.staple_width.max(0.0);
306            if staple > 0.0 {
307                let (wl, _) = coord.transform((nx - staple, map_y(ymin)), &plot_area);
308                let (wr, _) = coord.transform((nx + staple, map_y(ymin)), &plot_area);
309                backend.draw_line(
310                    &[(wl, whisker_bottom), (wr, whisker_bottom)],
311                    &LineStyle {
312                        color: self.color,
313                        width: 1.0,
314                        alpha: 1.0,
315                        linetype: Linetype::Solid,
316                        ..Default::default()
317                    },
318                )?;
319            }
320
321            // Upper whisker
322            let (_, whisker_top) = coord.transform((nx, map_y(ymax)), &plot_area);
323            backend.draw_line(
324                &[(center_x, box_top), (center_x, whisker_top)],
325                &LineStyle {
326                    color: self.color,
327                    width: 1.0,
328                    alpha: 1.0,
329                    linetype: Linetype::Solid,
330                    ..Default::default()
331                },
332            )?;
333            // Upper whisker cap (staple)
334            if staple > 0.0 {
335                let (wl, _) = coord.transform((nx - staple, map_y(ymax)), &plot_area);
336                let (wr, _) = coord.transform((nx + staple, map_y(ymax)), &plot_area);
337                backend.draw_line(
338                    &[(wl, whisker_top), (wr, whisker_top)],
339                    &LineStyle {
340                        color: self.color,
341                        width: 1.0,
342                        alpha: 1.0,
343                        linetype: Linetype::Solid,
344                        ..Default::default()
345                    },
346                )?;
347            }
348
349            // Outliers — `setup_data` has already normalised this cell to a
350            // clean, bracket-free "v1, v2" list (any unparsable part was
351            // warned on and dropped there), so a plain split/parse suffices.
352            if let Some(oc) = outliers_col.filter(|_| self.outliers) {
353                if let Value::Str(s) = &oc[i] {
354                    for part in s.split(',') {
355                        if let Ok(val) = part.trim().parse::<f64>() {
356                            let ny = map_y(val);
357                            let (ox, oy) = coord.transform((nx, ny), &plot_area);
358                            backend.draw_circle(
359                                (ox, oy),
360                                self.outlier_size,
361                                &PointStyle {
362                                    color: self.outlier_color.unwrap_or(self.color),
363                                    alpha: self.outlier_alpha,
364                                    filled: self.outlier_filled,
365                                    shape: self.outlier_shape,
366                                },
367                            )?;
368                        }
369                    }
370                }
371            }
372        }
373        super::clear_mark(backend);
374        super::clear_row_extras(backend);
375
376        Ok(())
377    }
378
379    fn required_aes(&self) -> Vec<Aesthetic> {
380        // B3-5: `x` is always required; whether `y` or `lower`/`middle`/
381        // `upper` is required depends on the stat (identity vs the default
382        // `StatBoxplot`) — see `validate_layer`, which build.rs calls
383        // separately because `required_aes` has no access to the stat.
384        vec![Aesthetic::X]
385    }
386
387    fn default_stat(&self) -> Box<dyn Stat> {
388        Box::new(StatBoxplot::with_coef(self.coef))
389    }
390
391    /// ggplot2's default: grouped boxes sit side by side in their x slot.
392    fn default_position(&self) -> Box<dyn Position> {
393        Box::new(PositionDodge2::default())
394    }
395
396    fn default_params(&self) -> GeomParams {
397        GeomParams::default()
398    }
399
400    fn name(&self) -> &str {
401        "boxplot"
402    }
403
404    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
405        if let (true, Some(ink)) = (self.color == (50, 50, 50), el.ink) {
406            self.color = ink;
407        }
408        if let (true, Some(paper)) = (self.fill == (255, 255, 255), el.paper) {
409            self.fill = paper;
410        }
411    }
412
413    fn set_series_color(&mut self, color: (u8, u8, u8)) {
414        self.fill = color;
415    }
416
417    fn mapped_only_columns(&self) -> &'static [&'static str] {
418        // #160, follow-up to #158/review #158's final check: `ymin`/`ymax`
419        // get the same "only when actually mapped" treatment as `lower`/
420        // `middle`/`upper`/`outliers` — otherwise a raw, unmapped `ymin`/
421        // `ymax` column (e.g. a SQL alias) satisfied `validate_layer`'s
422        // stat="identity" requirement below the same way a raw `y`
423        // satisfies `required_aes` everywhere else, silently accepting an
424        // incomplete five-number summary ggplot2 itself rejects. This is
425        // scoped to `GeomBoxplot` alone (`build.rs`'s drop loop runs per
426        // layer), so a raw `ymin`/`ymax` on any *other* geom — e.g. a
427        // forecast's confidence band on an unmapped `geom_line` — is
428        // untouched; the universal raw-y rule stays exactly as it is
429        // everywhere else.
430        &["lower", "middle", "upper", "outliers", "ymin", "ymax"]
431    }
432
433    fn validate_layer(&self, pre_stat_columns: &[String], stat_name: &str) -> Result<(), String> {
434        let has = |c: &str| pre_stat_columns.iter().any(|x| x == c);
435        if stat_name == "identity" {
436            // ggplot2 4.0.3's own stat="identity" boxplot requires the
437            // full five-number summary (plan §0's R table) — review #158
438            // finding 5: this previously only required lower/middle/upper,
439            // silently rendering whisker-less boxes with no ymin/ymax.
440            for c in ["lower", "middle", "upper", "ymin", "ymax"] {
441                if !has(c) {
442                    return Err(format!(
443                        "geom_boxplot requires aesthetic '{c}' under stat=\"identity\" but it was not provided"
444                    ));
445                }
446            }
447        } else if !has("y") {
448            return Err("geom_boxplot requires aesthetic 'y' but it was not provided".to_string());
449        }
450        Ok(())
451    }
452
453    fn consumes_tooltips(&self) -> bool {
454        true
455    }
456
457    /// B3-5: normalise the `outliers` cell (strip brackets, drop unparsable
458    /// parts with one warning) and widen `ymin_final`/`ymax_final` to
459    /// include every outlier, so the y scale trains on the full extent
460    /// (allowed #6) instead of silently clipping outliers that fall
461    /// outside the whiskers.
462    fn setup_data(&self, data: &mut DataFrame, warnings: &mut Vec<String>) {
463        if !data.has_column("outliers") {
464            return;
465        }
466        // `outliers = FALSE`: drop them — not drawn, and (as in ggplot2) not
467        // part of the y scale either.
468        if !self.outliers {
469            let n = data.nrows();
470            data.add_column("outliers".to_string(), vec![Value::Na; n]);
471            return;
472        }
473        let n = data.nrows();
474        let outliers_col = data
475            .column("outliers")
476            .map(|c| c.to_vec())
477            .unwrap_or_default();
478        let ymin_col = data.column("ymin").map(|c| c.to_vec());
479        let ymax_col = data.column("ymax").map(|c| c.to_vec());
480
481        let mut cleaned = Vec::with_capacity(n);
482        let mut ymin_final = Vec::with_capacity(n);
483        let mut ymax_final = Vec::with_capacity(n);
484        let mut had_unparsable = false;
485
486        for i in 0..n {
487            let (vals, cleaned_cell) = match outliers_col.get(i) {
488                Some(Value::Str(s)) => {
489                    let (vals, bad) = parse_outliers(s);
490                    had_unparsable |= bad;
491                    let joined = vals
492                        .iter()
493                        .map(|v| v.to_string())
494                        .collect::<Vec<_>>()
495                        .join(", ");
496                    (vals, Value::Str(joined))
497                }
498                // Review #158 finding 5: a numeric `outliers` cell (a
499                // single value, not a comma list) is a single outlier, not
500                // silently ignored. Normalised to the same `Value::Str`
501                // shape as the parsed-list case, so `draw`'s plain
502                // split/parse keeps working unchanged.
503                Some(v) if v.as_f64().is_some_and(f64::is_finite) => {
504                    let f = v.as_f64().unwrap();
505                    (vec![f], Value::Str(f.to_string()))
506                }
507                Some(other) => (Vec::new(), other.clone()),
508                None => (Vec::new(), Value::Na),
509            };
510            cleaned.push(cleaned_cell);
511
512            let mut lo = ymin_col
513                .as_ref()
514                .and_then(|c| c.get(i))
515                .and_then(|v| v.as_f64());
516            let mut hi = ymax_col
517                .as_ref()
518                .and_then(|c| c.get(i))
519                .and_then(|v| v.as_f64());
520            for v in &vals {
521                lo = Some(lo.map_or(*v, |l| l.min(*v)));
522                hi = Some(hi.map_or(*v, |h| h.max(*v)));
523            }
524            ymin_final.push(lo.map(Value::Float).unwrap_or(Value::Na));
525            ymax_final.push(hi.map(Value::Float).unwrap_or(Value::Na));
526        }
527
528        if had_unparsable {
529            warnings.push(
530                "geom_boxplot: outliers(...) contained an unparsable value; skipped".to_string(),
531            );
532        }
533
534        data.add_column("outliers".to_string(), cleaned);
535        // Review #158 finding 5: an internal-prefix name (never a raw
536        // source column can forge it — see `BOXPLOT_YMIN_FINAL`'s doc),
537        // so only this geom's own `setup_data` can ever produce these,
538        // not a SQL alias/leftover column on an unrelated geom.
539        data.add_column(crate::aes::BOXPLOT_YMIN_FINAL.to_string(), ymin_final);
540        data.add_column(crate::aes::BOXPLOT_YMAX_FINAL.to_string(), ymax_final);
541    }
542}