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::identity::PositionIdentity;
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.
16pub struct GeomBoxplot {
17    pub fill: (u8, u8, u8),
18    pub color: (u8, u8, u8),
19    pub width: f64,
20    pub alpha: f64,
21}
22
23impl Default for GeomBoxplot {
24    fn default() -> Self {
25        GeomBoxplot {
26            fill: (255, 255, 255),
27            color: (50, 50, 50),
28            width: 0.75,
29            alpha: 1.0,
30        }
31    }
32}
33
34/// Parse a boxplot `outliers` cell (B3-5, D4 extension): a comma list,
35/// optionally wrapped in `[...]`/`(...)` (`"7, 9"` or `"[7, 9]"`).
36/// Returns the successfully-parsed values and whether any part was
37/// unparsable (skipped).
38fn parse_outliers(s: &str) -> (Vec<f64>, bool) {
39    let trimmed = s.trim();
40    let inner = trimmed
41        .strip_prefix('[')
42        .and_then(|t| t.strip_suffix(']'))
43        .or_else(|| trimmed.strip_prefix('(').and_then(|t| t.strip_suffix(')')))
44        .unwrap_or(trimmed);
45    let mut out = Vec::new();
46    let mut bad = false;
47    for part in inner.split(',') {
48        let part = part.trim();
49        if part.is_empty() {
50            continue;
51        }
52        match part.parse::<f64>() {
53            Ok(v) if v.is_finite() => out.push(v),
54            _ => bad = true,
55        }
56    }
57    (out, bad)
58}
59
60impl Geom for GeomBoxplot {
61    fn draw(
62        &self,
63        data: &DataFrame,
64        coord: &dyn Coord,
65        scales: &ScaleSet,
66        _theme: &Theme,
67        backend: &mut dyn DrawBackend,
68    ) -> Result<(), RenderError> {
69        let x_col = data
70            .column("x")
71            .ok_or(RenderError::MissingAesthetic("x".into()))?;
72        let lower_col = data.column("lower");
73        let middle_col = data.column("middle");
74        let upper_col = data.column("upper");
75        let ymin_col = data.column("ymin");
76        let ymax_col = data.column("ymax");
77        let outliers_col = data.column("outliers");
78        let fill_col = data.column("fill");
79
80        let plot_area = backend.plot_area();
81        let x_scale = scales.get(&Aesthetic::X);
82        let y_scale = scales.get(&Aesthetic::Y);
83
84        let x_is_discrete = x_scale.map(|s| s.is_discrete()).unwrap_or(false);
85
86        for i in 0..data.nrows() {
87            let nx = x_scale.map(|s| s.map(&x_col[i])).unwrap_or(0.5);
88
89            let half_width = if x_is_discrete {
90                let n = x_scale.map(|s| s.breaks().len()).unwrap_or(1);
91                self.width / (n.max(1) as f64 * 2.5)
92            } else {
93                0.03
94            };
95
96            // Get boxplot values
97            let lower = lower_col.and_then(|c| c[i].as_f64()).unwrap_or(0.0);
98            let middle = middle_col.and_then(|c| c[i].as_f64()).unwrap_or(0.0);
99            let upper = upper_col.and_then(|c| c[i].as_f64()).unwrap_or(0.0);
100            let ymin = ymin_col.and_then(|c| c[i].as_f64()).unwrap_or(lower);
101            let ymax = ymax_col.and_then(|c| c[i].as_f64()).unwrap_or(upper);
102
103            let map_y = |v: f64| y_scale.map(|s| s.map(&Value::Float(v))).unwrap_or(0.0);
104
105            // Hover tooltip for the whole box: group + median (IQR) — the
106            // geom's own default. B3-5: `set_row_extras` below overrides it
107            // with a mapped `Aes::tooltip`/`data_attr`, if any, same as
108            // every other tooltip-consuming geom.
109            let group = super::tip_value(&x_col[i]);
110            let r = |v: f64| (v * 100.0).round() / 100.0;
111            let tip = if group.is_empty() {
112                format!("median {} ({}–{})", r(middle), r(lower), r(upper))
113            } else {
114                format!("{group}: median {} ({}–{})", r(middle), r(lower), r(upper))
115            };
116            let middle_value = Value::Float(middle);
117            super::set_mark(
118                backend,
119                Some(tip),
120                Some(group.clone()).filter(|g| !g.is_empty()),
121                super::series_key(data, i).or(Some(group).filter(|g| !g.is_empty())),
122                super::raw_value(&middle_value),
123            );
124            super::set_mark_formatted(
125                backend,
126                scales,
127                Some(&x_col[i]),
128                &Aesthetic::Y,
129                Some(&middle_value),
130            );
131            super::set_row_extras(backend, data, i);
132
133            // Box (IQR) — honour a mapped `fill` (e.g. by group), else the default.
134            let box_fill = fill_col
135                .and_then(|fc| scales.map_color(&Aesthetic::Fill, &fc[i]))
136                .unwrap_or(self.fill);
137            let (box_left, box_top) = coord.transform((nx - half_width, map_y(upper)), &plot_area);
138            let (box_right, box_bottom) =
139                coord.transform((nx + half_width, map_y(lower)), &plot_area);
140            backend.draw_rect(
141                (box_left, box_top),
142                (box_right, box_bottom),
143                &RectStyle {
144                    fill: Some(box_fill),
145                    stroke: Some(self.color),
146                    stroke_width: 1.0,
147                    alpha: self.alpha,
148                    clip: true,
149                    corner_radius: 0.0,
150                },
151            )?;
152
153            // Median line
154            let (med_left, med_y) = coord.transform((nx - half_width, map_y(middle)), &plot_area);
155            let (med_right, _) = coord.transform((nx + half_width, map_y(middle)), &plot_area);
156            backend.draw_line(
157                &[(med_left, med_y), (med_right, med_y)],
158                &LineStyle {
159                    color: self.color,
160                    width: 2.0,
161                    alpha: 1.0,
162                    linetype: Linetype::Solid,
163                    ..Default::default()
164                },
165            )?;
166
167            // Lower whisker
168            let (_, whisker_bottom) = coord.transform((nx, map_y(ymin)), &plot_area);
169            let (center_x, _) = coord.transform((nx, map_y(lower)), &plot_area);
170            backend.draw_line(
171                &[(center_x, box_bottom), (center_x, whisker_bottom)],
172                &LineStyle {
173                    color: self.color,
174                    width: 1.0,
175                    alpha: 1.0,
176                    linetype: Linetype::Solid,
177                    ..Default::default()
178                },
179            )?;
180            // Lower whisker cap
181            let (wl, _) = coord.transform((nx - half_width * 0.5, map_y(ymin)), &plot_area);
182            let (wr, _) = coord.transform((nx + half_width * 0.5, map_y(ymin)), &plot_area);
183            backend.draw_line(
184                &[(wl, whisker_bottom), (wr, whisker_bottom)],
185                &LineStyle {
186                    color: self.color,
187                    width: 1.0,
188                    alpha: 1.0,
189                    linetype: Linetype::Solid,
190                    ..Default::default()
191                },
192            )?;
193
194            // Upper whisker
195            let (_, whisker_top) = coord.transform((nx, map_y(ymax)), &plot_area);
196            backend.draw_line(
197                &[(center_x, box_top), (center_x, whisker_top)],
198                &LineStyle {
199                    color: self.color,
200                    width: 1.0,
201                    alpha: 1.0,
202                    linetype: Linetype::Solid,
203                    ..Default::default()
204                },
205            )?;
206            // Upper whisker cap
207            let (wl, _) = coord.transform((nx - half_width * 0.5, map_y(ymax)), &plot_area);
208            let (wr, _) = coord.transform((nx + half_width * 0.5, map_y(ymax)), &plot_area);
209            backend.draw_line(
210                &[(wl, whisker_top), (wr, whisker_top)],
211                &LineStyle {
212                    color: self.color,
213                    width: 1.0,
214                    alpha: 1.0,
215                    linetype: Linetype::Solid,
216                    ..Default::default()
217                },
218            )?;
219
220            // Outliers — `setup_data` has already normalised this cell to a
221            // clean, bracket-free "v1, v2" list (any unparsable part was
222            // warned on and dropped there), so a plain split/parse suffices.
223            if let Some(oc) = outliers_col {
224                if let Value::Str(s) = &oc[i] {
225                    for part in s.split(',') {
226                        if let Ok(val) = part.trim().parse::<f64>() {
227                            let ny = map_y(val);
228                            let (ox, oy) = coord.transform((nx, ny), &plot_area);
229                            backend.draw_circle(
230                                (ox, oy),
231                                2.0,
232                                &PointStyle {
233                                    color: self.color,
234                                    alpha: 1.0,
235                                    filled: false,
236                                    shape: PointShape::Circle,
237                                },
238                            )?;
239                        }
240                    }
241                }
242            }
243        }
244        super::clear_mark(backend);
245        super::clear_row_extras(backend);
246
247        Ok(())
248    }
249
250    fn required_aes(&self) -> Vec<Aesthetic> {
251        // B3-5: `x` is always required; whether `y` or `lower`/`middle`/
252        // `upper` is required depends on the stat (identity vs the default
253        // `StatBoxplot`) — see `validate_layer`, which build.rs calls
254        // separately because `required_aes` has no access to the stat.
255        vec![Aesthetic::X]
256    }
257
258    fn default_stat(&self) -> Box<dyn Stat> {
259        Box::new(StatBoxplot)
260    }
261
262    fn default_position(&self) -> Box<dyn Position> {
263        Box::new(PositionIdentity)
264    }
265
266    fn default_params(&self) -> GeomParams {
267        GeomParams::default()
268    }
269
270    fn name(&self) -> &str {
271        "boxplot"
272    }
273
274    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
275        if let (true, Some(ink)) = (self.color == (50, 50, 50), el.ink) {
276            self.color = ink;
277        }
278        if let (true, Some(paper)) = (self.fill == (255, 255, 255), el.paper) {
279            self.fill = paper;
280        }
281    }
282
283    fn set_series_color(&mut self, color: (u8, u8, u8)) {
284        self.fill = color;
285    }
286
287    fn mapped_only_columns(&self) -> &'static [&'static str] {
288        // #160, follow-up to #158/review #158's final check: `ymin`/`ymax`
289        // get the same "only when actually mapped" treatment as `lower`/
290        // `middle`/`upper`/`outliers` — otherwise a raw, unmapped `ymin`/
291        // `ymax` column (e.g. a SQL alias) satisfied `validate_layer`'s
292        // stat="identity" requirement below the same way a raw `y`
293        // satisfies `required_aes` everywhere else, silently accepting an
294        // incomplete five-number summary ggplot2 itself rejects. This is
295        // scoped to `GeomBoxplot` alone (`build.rs`'s drop loop runs per
296        // layer), so a raw `ymin`/`ymax` on any *other* geom — e.g. a
297        // forecast's confidence band on an unmapped `geom_line` — is
298        // untouched; the universal raw-y rule stays exactly as it is
299        // everywhere else.
300        &["lower", "middle", "upper", "outliers", "ymin", "ymax"]
301    }
302
303    fn validate_layer(&self, pre_stat_columns: &[String], stat_name: &str) -> Result<(), String> {
304        let has = |c: &str| pre_stat_columns.iter().any(|x| x == c);
305        if stat_name == "identity" {
306            // ggplot2 4.0.3's own stat="identity" boxplot requires the
307            // full five-number summary (plan §0's R table) — review #158
308            // finding 5: this previously only required lower/middle/upper,
309            // silently rendering whisker-less boxes with no ymin/ymax.
310            for c in ["lower", "middle", "upper", "ymin", "ymax"] {
311                if !has(c) {
312                    return Err(format!(
313                        "geom_boxplot requires aesthetic '{c}' under stat=\"identity\" but it was not provided"
314                    ));
315                }
316            }
317        } else if !has("y") {
318            return Err("geom_boxplot requires aesthetic 'y' but it was not provided".to_string());
319        }
320        Ok(())
321    }
322
323    fn consumes_tooltips(&self) -> bool {
324        true
325    }
326
327    /// B3-5: normalise the `outliers` cell (strip brackets, drop unparsable
328    /// parts with one warning) and widen `ymin_final`/`ymax_final` to
329    /// include every outlier, so the y scale trains on the full extent
330    /// (allowed #6) instead of silently clipping outliers that fall
331    /// outside the whiskers.
332    fn setup_data(&self, data: &mut DataFrame, warnings: &mut Vec<String>) {
333        if !data.has_column("outliers") {
334            return;
335        }
336        let n = data.nrows();
337        let outliers_col = data
338            .column("outliers")
339            .map(|c| c.to_vec())
340            .unwrap_or_default();
341        let ymin_col = data.column("ymin").map(|c| c.to_vec());
342        let ymax_col = data.column("ymax").map(|c| c.to_vec());
343
344        let mut cleaned = Vec::with_capacity(n);
345        let mut ymin_final = Vec::with_capacity(n);
346        let mut ymax_final = Vec::with_capacity(n);
347        let mut had_unparsable = false;
348
349        for i in 0..n {
350            let (vals, cleaned_cell) = match outliers_col.get(i) {
351                Some(Value::Str(s)) => {
352                    let (vals, bad) = parse_outliers(s);
353                    had_unparsable |= bad;
354                    let joined = vals
355                        .iter()
356                        .map(|v| v.to_string())
357                        .collect::<Vec<_>>()
358                        .join(", ");
359                    (vals, Value::Str(joined))
360                }
361                // Review #158 finding 5: a numeric `outliers` cell (a
362                // single value, not a comma list) is a single outlier, not
363                // silently ignored. Normalised to the same `Value::Str`
364                // shape as the parsed-list case, so `draw`'s plain
365                // split/parse keeps working unchanged.
366                Some(v) if v.as_f64().is_some_and(f64::is_finite) => {
367                    let f = v.as_f64().unwrap();
368                    (vec![f], Value::Str(f.to_string()))
369                }
370                Some(other) => (Vec::new(), other.clone()),
371                None => (Vec::new(), Value::Na),
372            };
373            cleaned.push(cleaned_cell);
374
375            let mut lo = ymin_col
376                .as_ref()
377                .and_then(|c| c.get(i))
378                .and_then(|v| v.as_f64());
379            let mut hi = ymax_col
380                .as_ref()
381                .and_then(|c| c.get(i))
382                .and_then(|v| v.as_f64());
383            for v in &vals {
384                lo = Some(lo.map_or(*v, |l| l.min(*v)));
385                hi = Some(hi.map_or(*v, |h| h.max(*v)));
386            }
387            ymin_final.push(lo.map(Value::Float).unwrap_or(Value::Na));
388            ymax_final.push(hi.map(Value::Float).unwrap_or(Value::Na));
389        }
390
391        if had_unparsable {
392            warnings.push(
393                "geom_boxplot: outliers(...) contained an unparsable value; skipped".to_string(),
394            );
395        }
396
397        data.add_column("outliers".to_string(), cleaned);
398        // Review #158 finding 5: an internal-prefix name (never a raw
399        // source column can forge it — see `BOXPLOT_YMIN_FINAL`'s doc),
400        // so only this geom's own `setup_data` can ever produce these,
401        // not a SQL alias/leftover column on an unrelated geom.
402        data.add_column(crate::aes::BOXPLOT_YMIN_FINAL.to_string(), ymin_final);
403        data.add_column(crate::aes::BOXPLOT_YMAX_FINAL.to_string(), ymax_final);
404    }
405}