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#[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 pub notch: bool,
25 pub notch_width: f64,
27 pub varwidth: bool,
29 pub coef: f64,
31 pub outliers: bool,
33 pub outlier_color: Option<(u8, u8, u8)>,
35 pub outlier_shape: PointShape,
37 pub outlier_filled: bool,
39 pub outlier_size: f64,
41 pub outlier_alpha: f64,
43 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 pub fn notched(mut self) -> Self {
73 self.notch = true;
74 self
75 }
76
77 pub fn varwidth(mut self) -> Self {
79 self.varwidth = true;
80 self
81 }
82
83 pub fn with_coef(mut self, coef: f64) -> Self {
85 self.coef = coef;
86 self
87 }
88
89 pub fn hide_outliers(mut self) -> Self {
91 self.outliers = false;
92 self
93 }
94
95 pub fn with_staple_width(mut self, w: f64) -> Self {
97 self.staple_width = w;
98 self
99 }
100}
101
102fn 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 let x_mapper = super::support::XMapper::new(data, scales);
156 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 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 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 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 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 (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 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 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 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 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 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 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 vec![Aesthetic::X]
385 }
386
387 fn default_stat(&self) -> Box<dyn Stat> {
388 Box::new(StatBoxplot::with_coef(self.coef))
389 }
390
391 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 &["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 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 fn setup_data(&self, data: &mut DataFrame, warnings: &mut Vec<String>) {
463 if !data.has_column("outliers") {
464 return;
465 }
466 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 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 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}