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), the
273            // whiskers and their staples: each end transformed as a whole
274            // point, so they stay right under coord_flip (mixing one
275            // point's x with another's y drew them with zero length there).
276            let med_half = if notch.is_some() {
277                half_width - inset
278            } else {
279                half_width
280            };
281            let t = |x: f64, y: f64| coord.transform((x, map_y(y)), &plot_area);
282            let line = |w: f64| LineStyle {
283                color: self.color,
284                width: w,
285                alpha: 1.0,
286                linetype: Linetype::Solid,
287                ..Default::default()
288            };
289            backend.draw_line(
290                &[t(nx - med_half, middle), t(nx + med_half, middle)],
291                &line(2.0),
292            )?;
293            let staple = half_width * self.staple_width.max(0.0);
294            for (edge, end) in [(lower, ymin), (upper, ymax)] {
295                backend.draw_line(&[t(nx, edge), t(nx, end)], &line(1.0))?;
296                if staple > 0.0 {
297                    backend.draw_line(&[t(nx - staple, end), t(nx + staple, end)], &line(1.0))?;
298                }
299            }
300
301            // Outliers — `setup_data` has already normalised this cell to a
302            // clean, bracket-free "v1, v2" list (any unparsable part was
303            // warned on and dropped there), so a plain split/parse suffices.
304            if let Some(oc) = outliers_col.filter(|_| self.outliers) {
305                if let Value::Str(s) = &oc[i] {
306                    for part in s.split(',') {
307                        if let Ok(val) = part.trim().parse::<f64>() {
308                            let ny = map_y(val);
309                            let (ox, oy) = coord.transform((nx, ny), &plot_area);
310                            backend.draw_circle(
311                                (ox, oy),
312                                self.outlier_size,
313                                &PointStyle {
314                                    color: self.outlier_color.unwrap_or(self.color),
315                                    alpha: self.outlier_alpha,
316                                    filled: self.outlier_filled,
317                                    shape: self.outlier_shape,
318                                },
319                            )?;
320                        }
321                    }
322                }
323            }
324        }
325        super::clear_mark(backend);
326        super::clear_row_extras(backend);
327
328        Ok(())
329    }
330
331    fn required_aes(&self) -> Vec<Aesthetic> {
332        // B3-5: `x` is always required; whether `y` or `lower`/`middle`/
333        // `upper` is required depends on the stat (identity vs the default
334        // `StatBoxplot`) — see `validate_layer`, which build.rs calls
335        // separately because `required_aes` has no access to the stat.
336        vec![Aesthetic::X]
337    }
338
339    fn default_stat(&self) -> Box<dyn Stat> {
340        Box::new(StatBoxplot::with_coef(self.coef))
341    }
342
343    /// ggplot2's default: grouped boxes sit side by side in their x slot.
344    fn default_position(&self) -> Box<dyn Position> {
345        Box::new(PositionDodge2::default())
346    }
347
348    fn default_params(&self) -> GeomParams {
349        GeomParams::default()
350    }
351
352    fn name(&self) -> &str {
353        "boxplot"
354    }
355
356    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
357        if let (true, Some(ink)) = (self.color == (50, 50, 50), el.ink) {
358            self.color = ink;
359        }
360        if let (true, Some(paper)) = (self.fill == (255, 255, 255), el.paper) {
361            self.fill = paper;
362        }
363    }
364
365    fn set_series_color(&mut self, color: (u8, u8, u8)) {
366        self.fill = color;
367    }
368
369    fn mapped_only_columns(&self) -> &'static [&'static str] {
370        // #160, follow-up to #158/review #158's final check: `ymin`/`ymax`
371        // get the same "only when actually mapped" treatment as `lower`/
372        // `middle`/`upper`/`outliers` — otherwise a raw, unmapped `ymin`/
373        // `ymax` column (e.g. a SQL alias) satisfied `validate_layer`'s
374        // stat="identity" requirement below the same way a raw `y`
375        // satisfies `required_aes` everywhere else, silently accepting an
376        // incomplete five-number summary ggplot2 itself rejects. This is
377        // scoped to `GeomBoxplot` alone (`build.rs`'s drop loop runs per
378        // layer), so a raw `ymin`/`ymax` on any *other* geom — e.g. a
379        // forecast's confidence band on an unmapped `geom_line` — is
380        // untouched; the universal raw-y rule stays exactly as it is
381        // everywhere else.
382        &["lower", "middle", "upper", "outliers", "ymin", "ymax"]
383    }
384
385    fn validate_layer(&self, pre_stat_columns: &[String], stat_name: &str) -> Result<(), String> {
386        let has = |c: &str| pre_stat_columns.iter().any(|x| x == c);
387        if stat_name == "identity" {
388            // ggplot2 4.0.3's own stat="identity" boxplot requires the
389            // full five-number summary (plan §0's R table) — review #158
390            // finding 5: this previously only required lower/middle/upper,
391            // silently rendering whisker-less boxes with no ymin/ymax.
392            for c in ["lower", "middle", "upper", "ymin", "ymax"] {
393                if !has(c) {
394                    return Err(format!(
395                        "geom_boxplot requires aesthetic '{c}' under stat=\"identity\" but it was not provided"
396                    ));
397                }
398            }
399        } else if !has("y") {
400            return Err("geom_boxplot requires aesthetic 'y' but it was not provided".to_string());
401        }
402        Ok(())
403    }
404
405    fn consumes_tooltips(&self) -> bool {
406        true
407    }
408
409    /// B3-5: normalise the `outliers` cell (strip brackets, drop unparsable
410    /// parts with one warning) and widen `ymin_final`/`ymax_final` to
411    /// include every outlier, so the y scale trains on the full extent
412    /// (allowed #6) instead of silently clipping outliers that fall
413    /// outside the whiskers.
414    fn setup_data(&self, data: &mut DataFrame, warnings: &mut Vec<String>) {
415        if !data.has_column("outliers") {
416            return;
417        }
418        // `outliers = FALSE`: drop them — not drawn, and (as in ggplot2) not
419        // part of the y scale either.
420        if !self.outliers {
421            let n = data.nrows();
422            data.add_column("outliers".to_string(), vec![Value::Na; n]);
423            return;
424        }
425        let n = data.nrows();
426        let outliers_col = data
427            .column("outliers")
428            .map(|c| c.to_vec())
429            .unwrap_or_default();
430        let ymin_col = data.column("ymin").map(|c| c.to_vec());
431        let ymax_col = data.column("ymax").map(|c| c.to_vec());
432
433        let mut cleaned = Vec::with_capacity(n);
434        let mut ymin_final = Vec::with_capacity(n);
435        let mut ymax_final = Vec::with_capacity(n);
436        let mut had_unparsable = false;
437
438        for i in 0..n {
439            let (vals, cleaned_cell) = match outliers_col.get(i) {
440                Some(Value::Str(s)) => {
441                    let (vals, bad) = parse_outliers(s);
442                    had_unparsable |= bad;
443                    let joined = vals
444                        .iter()
445                        .map(|v| v.to_string())
446                        .collect::<Vec<_>>()
447                        .join(", ");
448                    (vals, Value::Str(joined))
449                }
450                // Review #158 finding 5: a numeric `outliers` cell (a
451                // single value, not a comma list) is a single outlier, not
452                // silently ignored. Normalised to the same `Value::Str`
453                // shape as the parsed-list case, so `draw`'s plain
454                // split/parse keeps working unchanged.
455                Some(v) if v.as_f64().is_some_and(f64::is_finite) => {
456                    let f = v.as_f64().unwrap();
457                    (vec![f], Value::Str(f.to_string()))
458                }
459                Some(other) => (Vec::new(), other.clone()),
460                None => (Vec::new(), Value::Na),
461            };
462            cleaned.push(cleaned_cell);
463
464            let mut lo = ymin_col
465                .as_ref()
466                .and_then(|c| c.get(i))
467                .and_then(|v| v.as_f64());
468            let mut hi = ymax_col
469                .as_ref()
470                .and_then(|c| c.get(i))
471                .and_then(|v| v.as_f64());
472            for v in &vals {
473                lo = Some(lo.map_or(*v, |l| l.min(*v)));
474                hi = Some(hi.map_or(*v, |h| h.max(*v)));
475            }
476            ymin_final.push(lo.map(Value::Float).unwrap_or(Value::Na));
477            ymax_final.push(hi.map(Value::Float).unwrap_or(Value::Na));
478        }
479
480        if had_unparsable {
481            warnings.push(
482                "geom_boxplot: outliers(...) contained an unparsable value; skipped".to_string(),
483            );
484        }
485
486        data.add_column("outliers".to_string(), cleaned);
487        // Review #158 finding 5: an internal-prefix name (never a raw
488        // source column can forge it — see `BOXPLOT_YMIN_FINAL`'s doc),
489        // so only this geom's own `setup_data` can ever produce these,
490        // not a SQL alias/leftover column on an unrelated geom.
491        data.add_column(crate::aes::BOXPLOT_YMIN_FINAL.to_string(), ymin_final);
492        data.add_column(crate::aes::BOXPLOT_YMAX_FINAL.to_string(), ymax_final);
493    }
494}