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() {
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 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 vec![Aesthetic::X]
337 }
338
339 fn default_stat(&self) -> Box<dyn Stat> {
340 Box::new(StatBoxplot::with_coef(self.coef))
341 }
342
343 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 &["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 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 fn setup_data(&self, data: &mut DataFrame, warnings: &mut Vec<String>) {
415 if !data.has_column("outliers") {
416 return;
417 }
418 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 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 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}