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                },
150            )?;
151
152            // Median line
153            let (med_left, med_y) = coord.transform((nx - half_width, map_y(middle)), &plot_area);
154            let (med_right, _) = coord.transform((nx + half_width, map_y(middle)), &plot_area);
155            backend.draw_line(
156                &[(med_left, med_y), (med_right, med_y)],
157                &LineStyle {
158                    color: self.color,
159                    width: 2.0,
160                    alpha: 1.0,
161                    linetype: Linetype::Solid,
162                    ..Default::default()
163                },
164            )?;
165
166            // Lower whisker
167            let (_, whisker_bottom) = coord.transform((nx, map_y(ymin)), &plot_area);
168            let (center_x, _) = coord.transform((nx, map_y(lower)), &plot_area);
169            backend.draw_line(
170                &[(center_x, box_bottom), (center_x, whisker_bottom)],
171                &LineStyle {
172                    color: self.color,
173                    width: 1.0,
174                    alpha: 1.0,
175                    linetype: Linetype::Solid,
176                    ..Default::default()
177                },
178            )?;
179            // Lower whisker cap
180            let (wl, _) = coord.transform((nx - half_width * 0.5, map_y(ymin)), &plot_area);
181            let (wr, _) = coord.transform((nx + half_width * 0.5, map_y(ymin)), &plot_area);
182            backend.draw_line(
183                &[(wl, whisker_bottom), (wr, whisker_bottom)],
184                &LineStyle {
185                    color: self.color,
186                    width: 1.0,
187                    alpha: 1.0,
188                    linetype: Linetype::Solid,
189                    ..Default::default()
190                },
191            )?;
192
193            // Upper whisker
194            let (_, whisker_top) = coord.transform((nx, map_y(ymax)), &plot_area);
195            backend.draw_line(
196                &[(center_x, box_top), (center_x, whisker_top)],
197                &LineStyle {
198                    color: self.color,
199                    width: 1.0,
200                    alpha: 1.0,
201                    linetype: Linetype::Solid,
202                    ..Default::default()
203                },
204            )?;
205            // Upper whisker cap
206            let (wl, _) = coord.transform((nx - half_width * 0.5, map_y(ymax)), &plot_area);
207            let (wr, _) = coord.transform((nx + half_width * 0.5, map_y(ymax)), &plot_area);
208            backend.draw_line(
209                &[(wl, whisker_top), (wr, whisker_top)],
210                &LineStyle {
211                    color: self.color,
212                    width: 1.0,
213                    alpha: 1.0,
214                    linetype: Linetype::Solid,
215                    ..Default::default()
216                },
217            )?;
218
219            // Outliers — `setup_data` has already normalised this cell to a
220            // clean, bracket-free "v1, v2" list (any unparsable part was
221            // warned on and dropped there), so a plain split/parse suffices.
222            if let Some(oc) = outliers_col {
223                if let Value::Str(s) = &oc[i] {
224                    for part in s.split(',') {
225                        if let Ok(val) = part.trim().parse::<f64>() {
226                            let ny = map_y(val);
227                            let (ox, oy) = coord.transform((nx, ny), &plot_area);
228                            backend.draw_circle(
229                                (ox, oy),
230                                2.0,
231                                &PointStyle {
232                                    color: self.color,
233                                    alpha: 1.0,
234                                    filled: false,
235                                    shape: PointShape::Circle,
236                                },
237                            )?;
238                        }
239                    }
240                }
241            }
242        }
243        super::clear_mark(backend);
244        super::clear_row_extras(backend);
245
246        Ok(())
247    }
248
249    fn required_aes(&self) -> Vec<Aesthetic> {
250        // B3-5: `x` is always required; whether `y` or `lower`/`middle`/
251        // `upper` is required depends on the stat (identity vs the default
252        // `StatBoxplot`) — see `validate_layer`, which build.rs calls
253        // separately because `required_aes` has no access to the stat.
254        vec![Aesthetic::X]
255    }
256
257    fn default_stat(&self) -> Box<dyn Stat> {
258        Box::new(StatBoxplot)
259    }
260
261    fn default_position(&self) -> Box<dyn Position> {
262        Box::new(PositionIdentity)
263    }
264
265    fn default_params(&self) -> GeomParams {
266        GeomParams::default()
267    }
268
269    fn name(&self) -> &str {
270        "boxplot"
271    }
272
273    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
274        if let (true, Some(ink)) = (self.color == (50, 50, 50), el.ink) {
275            self.color = ink;
276        }
277        if let (true, Some(paper)) = (self.fill == (255, 255, 255), el.paper) {
278            self.fill = paper;
279        }
280    }
281
282    fn set_series_color(&mut self, color: (u8, u8, u8)) {
283        self.fill = color;
284    }
285
286    fn mapped_only_columns(&self) -> &'static [&'static str] {
287        &["lower", "middle", "upper", "outliers"]
288    }
289
290    fn validate_layer(&self, pre_stat_columns: &[String], stat_name: &str) -> Result<(), String> {
291        let has = |c: &str| pre_stat_columns.iter().any(|x| x == c);
292        if stat_name == "identity" {
293            // ggplot2 4.0.3's own stat="identity" boxplot requires the
294            // full five-number summary (plan §0's R table) — review #158
295            // finding 5: this previously only required lower/middle/upper,
296            // silently rendering whisker-less boxes with no ymin/ymax.
297            for c in ["lower", "middle", "upper", "ymin", "ymax"] {
298                if !has(c) {
299                    return Err(format!(
300                        "geom_boxplot requires aesthetic '{c}' under stat=\"identity\" but it was not provided"
301                    ));
302                }
303            }
304        } else if !has("y") {
305            return Err("geom_boxplot requires aesthetic 'y' but it was not provided".to_string());
306        }
307        Ok(())
308    }
309
310    fn consumes_tooltips(&self) -> bool {
311        true
312    }
313
314    /// B3-5: normalise the `outliers` cell (strip brackets, drop unparsable
315    /// parts with one warning) and widen `ymin_final`/`ymax_final` to
316    /// include every outlier, so the y scale trains on the full extent
317    /// (allowed #6) instead of silently clipping outliers that fall
318    /// outside the whiskers.
319    fn setup_data(&self, data: &mut DataFrame, warnings: &mut Vec<String>) {
320        if !data.has_column("outliers") {
321            return;
322        }
323        let n = data.nrows();
324        let outliers_col = data
325            .column("outliers")
326            .map(|c| c.to_vec())
327            .unwrap_or_default();
328        let ymin_col = data.column("ymin").map(|c| c.to_vec());
329        let ymax_col = data.column("ymax").map(|c| c.to_vec());
330
331        let mut cleaned = Vec::with_capacity(n);
332        let mut ymin_final = Vec::with_capacity(n);
333        let mut ymax_final = Vec::with_capacity(n);
334        let mut had_unparsable = false;
335
336        for i in 0..n {
337            let (vals, cleaned_cell) = match outliers_col.get(i) {
338                Some(Value::Str(s)) => {
339                    let (vals, bad) = parse_outliers(s);
340                    had_unparsable |= bad;
341                    let joined = vals
342                        .iter()
343                        .map(|v| v.to_string())
344                        .collect::<Vec<_>>()
345                        .join(", ");
346                    (vals, Value::Str(joined))
347                }
348                // Review #158 finding 5: a numeric `outliers` cell (a
349                // single value, not a comma list) is a single outlier, not
350                // silently ignored. Normalised to the same `Value::Str`
351                // shape as the parsed-list case, so `draw`'s plain
352                // split/parse keeps working unchanged.
353                Some(v) if v.as_f64().is_some_and(f64::is_finite) => {
354                    let f = v.as_f64().unwrap();
355                    (vec![f], Value::Str(f.to_string()))
356                }
357                Some(other) => (Vec::new(), other.clone()),
358                None => (Vec::new(), Value::Na),
359            };
360            cleaned.push(cleaned_cell);
361
362            let mut lo = ymin_col
363                .as_ref()
364                .and_then(|c| c.get(i))
365                .and_then(|v| v.as_f64());
366            let mut hi = ymax_col
367                .as_ref()
368                .and_then(|c| c.get(i))
369                .and_then(|v| v.as_f64());
370            for v in &vals {
371                lo = Some(lo.map_or(*v, |l| l.min(*v)));
372                hi = Some(hi.map_or(*v, |h| h.max(*v)));
373            }
374            ymin_final.push(lo.map(Value::Float).unwrap_or(Value::Na));
375            ymax_final.push(hi.map(Value::Float).unwrap_or(Value::Na));
376        }
377
378        if had_unparsable {
379            warnings.push(
380                "geom_boxplot: outliers(...) contained an unparsable value; skipped".to_string(),
381            );
382        }
383
384        data.add_column("outliers".to_string(), cleaned);
385        // Review #158 finding 5: an internal-prefix name (never a raw
386        // source column can forge it — see `BOXPLOT_YMIN_FINAL`'s doc),
387        // so only this geom's own `setup_data` can ever produce these,
388        // not a SQL alias/leftover column on an unrelated geom.
389        data.add_column(crate::aes::BOXPLOT_YMIN_FINAL.to_string(), ymin_final);
390        data.add_column(crate::aes::BOXPLOT_YMAX_FINAL.to_string(), ymax_final);
391    }
392}