1use std::cmp::Ordering;
2
3use thiserror::Error;
4
5use crate::border::BorderType;
6use crate::color::{CanvasColor, NamedColor, TermColor, canvas_color_from_term};
7use crate::graphics::{GraphicsArea, RowBuffer, RowCell};
8use crate::math::{extend_limits, format_axis_value, same_value, usize_to_f64};
9use crate::plot::{DecorationPosition, Plot};
10
11const MIN_WIDTH: usize = 10;
12
13#[derive(Debug, Clone, Copy, PartialEq)]
14struct Summary {
15 min: f64,
16 q1: f64,
17 median: f64,
18 q3: f64,
19 max: f64,
20}
21
22impl Summary {
23 fn from_sorted(values: &[f64]) -> Self {
24 Self {
25 min: percentile(values, 0.0),
26 q1: percentile(values, 25.0),
27 median: percentile(values, 50.0),
28 q3: percentile(values, 75.0),
29 max: percentile(values, 100.0),
30 }
31 }
32
33 const fn as_array(self) -> [f64; 5] {
34 [self.min, self.q1, self.median, self.q3, self.max]
35 }
36}
37
38#[derive(Debug, Clone)]
41pub struct BoxplotGraphics {
42 summaries: Vec<Summary>,
43 width_chars: usize,
44 color: CanvasColor,
45 min_x: f64,
46 max_x: f64,
47}
48
49impl BoxplotGraphics {
50 fn new(
51 summaries: Vec<Summary>,
52 width_chars: usize,
53 color: CanvasColor,
54 mut min_x: f64,
55 mut max_x: f64,
56 ) -> Self {
57 if same_value(min_x, max_x) {
58 min_x -= 1.0;
59 max_x += 1.0;
60 }
61
62 Self {
63 summaries,
64 width_chars: width_chars.max(MIN_WIDTH),
65 color,
66 min_x,
67 max_x,
68 }
69 }
70
71 fn min_x(&self) -> f64 {
72 self.min_x
73 }
74
75 fn max_x(&self) -> f64 {
76 self.max_x
77 }
78
79 fn add_series(&mut self, summary: Summary) {
80 self.min_x = self.min_x.min(summary.min);
81 self.max_x = self.max_x.max(summary.max);
82 if same_value(self.min_x, self.max_x) {
83 self.min_x -= 1.0;
84 self.max_x += 1.0;
85 }
86 self.summaries.push(summary);
87 }
88
89 fn transform_to_column(&self, value: f64) -> usize {
90 let width_f64 = usize_to_f64(self.width_chars);
91 let scaled =
92 ((value - self.min_x) / (self.max_x - self.min_x) * width_f64).clamp(1.0, width_f64);
93 round_half_even(scaled).clamp(1, self.width_chars)
94 }
95}
96
97impl GraphicsArea for BoxplotGraphics {
98 fn nrows(&self) -> usize {
99 3 * self.summaries.len()
100 }
101
102 fn ncols(&self) -> usize {
103 self.width_chars
104 }
105
106 fn render_row(&self, row: usize, out: &mut RowBuffer) {
107 out.clear();
108 out.resize(
109 self.width_chars,
110 RowCell {
111 glyph: ' ',
112 color: self.color,
113 },
114 );
115
116 let Some(summary) = self.summaries.get(row / 3) else {
117 return;
118 };
119
120 let row_in_series = row % 3;
121 let (
122 min_char,
123 line_char,
124 left_box_char,
125 line_box_char,
126 median_char,
127 right_box_char,
128 max_char,
129 ) = match row_in_series {
130 0 => ('╷', ' ', '┌', '─', '┬', '┐', '╷'),
131 1 => ('├', '─', '┤', ' ', '│', '├', '┤'),
132 _ => ('╵', ' ', '└', '─', '┴', '┘', '╵'),
133 };
134
135 let transformed = summary
136 .as_array()
137 .map(|value| self.transform_to_column(value));
138
139 let min_col = transformed[0] - 1;
140 let q1_col = transformed[1] - 1;
141 let median_col = transformed[2] - 1;
142 let q3_col = transformed[3] - 1;
143 let max_col = transformed[4] - 1;
144
145 out[min_col].glyph = min_char;
146 out[q1_col].glyph = left_box_char;
147 out[median_col].glyph = median_char;
148 out[q3_col].glyph = right_box_char;
149 out[max_col].glyph = max_char;
150
151 for cell in out
152 .iter_mut()
153 .take(transformed[1].saturating_sub(1))
154 .skip(transformed[0])
155 {
156 cell.glyph = line_char;
157 }
158 for cell in out
159 .iter_mut()
160 .take(transformed[2].saturating_sub(1))
161 .skip(transformed[1])
162 {
163 cell.glyph = line_box_char;
164 }
165 for cell in out
166 .iter_mut()
167 .take(transformed[3].saturating_sub(1))
168 .skip(transformed[2])
169 {
170 cell.glyph = line_box_char;
171 }
172 for cell in out
173 .iter_mut()
174 .take(transformed[4].saturating_sub(1))
175 .skip(transformed[3])
176 {
177 cell.glyph = line_char;
178 }
179 }
180}
181
182#[derive(Debug, Error, PartialEq)]
184#[non_exhaustive]
185pub enum BoxplotError {
186 #[error("labels and series must be the same length")]
188 LengthMismatch,
189 #[error("boxplot requires at least one series, and each series must be non-empty")]
191 EmptySeries,
192 #[error("Can't append empty array to boxplot")]
194 EmptyAppend,
195 #[error("xlim must contain finite values with min <= max")]
197 InvalidXLimits,
198 #[error("invalid numeric value: {value}")]
200 InvalidNumericValue { value: String },
201}
202
203#[derive(Debug, Clone)]
205#[non_exhaustive]
206pub struct BoxplotOptions {
207 pub title: Option<String>,
209 pub xlabel: Option<String>,
211 pub ylabel: Option<String>,
213 pub border: BorderType,
215 pub margin: u16,
217 pub padding: u16,
219 pub labels: bool,
221 pub color: TermColor,
223 pub width: usize,
225 pub xlim: (f64, f64),
227}
228
229impl Default for BoxplotOptions {
230 fn default() -> Self {
231 Self {
232 title: None,
233 xlabel: None,
234 ylabel: None,
235 border: BorderType::Corners,
236 margin: Plot::<BoxplotGraphics>::DEFAULT_MARGIN,
237 padding: Plot::<BoxplotGraphics>::DEFAULT_PADDING,
238 labels: true,
239 color: TermColor::Named(NamedColor::Green),
240 width: 40,
241 xlim: (0.0, 0.0),
242 }
243 }
244}
245
246pub fn boxplot<L, S, V>(
275 labels: &[L],
276 series: &[S],
277 options: BoxplotOptions,
278) -> Result<Plot<BoxplotGraphics>, BoxplotError>
279where
280 L: ToString,
281 S: AsRef<[V]>,
282 V: ToString,
283{
284 if series.is_empty() {
285 return Err(BoxplotError::EmptySeries);
286 }
287 if !labels.is_empty() && labels.len() != series.len() {
288 return Err(BoxplotError::LengthMismatch);
289 }
290 if !valid_limits(options.xlim) {
291 return Err(BoxplotError::InvalidXLimits);
292 }
293
294 let mut summaries = Vec::with_capacity(series.len());
295 for values in series {
296 let parsed = parse_values(values.as_ref())?;
297 if parsed.is_empty() {
298 return Err(BoxplotError::EmptySeries);
299 }
300 summaries.push(Summary::from_sorted(&parsed));
301 }
302
303 let (min_x, max_x) = extend_limits(
304 &summaries
305 .iter()
306 .flat_map(|summary| [summary.min, summary.max])
307 .collect::<Vec<_>>(),
308 options.xlim,
309 );
310
311 let graphics = BoxplotGraphics::new(
312 summaries,
313 options.width,
314 canvas_color_from_term(options.color),
315 min_x,
316 max_x,
317 );
318
319 let mut plot = Plot::new(graphics);
320 plot.title = options.title;
321 plot.xlabel = options.xlabel;
322 plot.ylabel = options.ylabel;
323 plot.border = options.border;
324 plot.margin = options.margin;
325 plot.padding = options.padding;
326 plot.show_labels = options.labels;
327
328 let names: Vec<String> = if labels.is_empty() {
329 vec![String::new(); series.len()]
330 } else {
331 labels.iter().map(ToString::to_string).collect()
332 };
333
334 annotate_x_axis(&mut plot);
335 for (index, name) in names.into_iter().enumerate() {
336 if !name.is_empty() {
337 plot.annotate_left(index * 3 + 1, name, None);
338 }
339 }
340
341 Ok(plot)
342}
343
344pub fn boxplot_add<V>(
351 plot: &mut Plot<BoxplotGraphics>,
352 label: &str,
353 data: &[V],
354) -> Result<(), BoxplotError>
355where
356 V: ToString,
357{
358 if data.is_empty() {
359 return Err(BoxplotError::EmptyAppend);
360 }
361
362 let parsed = parse_values(data)?;
363 if parsed.is_empty() {
364 return Err(BoxplotError::EmptyAppend);
365 }
366
367 let summary = Summary::from_sorted(&parsed);
368 plot.graphics_mut().add_series(summary);
369
370 let row = (plot.graphics().nrows() / 3).saturating_sub(1) * 3 + 1;
371 let name = label.to_owned();
372 if !name.is_empty() {
373 plot.annotate_left(row, name, None);
374 }
375
376 annotate_x_axis(plot);
377 Ok(())
378}
379
380fn parse_values<V: ToString>(values: &[V]) -> Result<Vec<f64>, BoxplotError> {
381 let mut out = Vec::with_capacity(values.len());
382 for value in values {
383 let text = value.to_string();
384 let numeric = text
385 .parse::<f64>()
386 .map_err(|_| BoxplotError::InvalidNumericValue {
387 value: text.clone(),
388 })?;
389 if !numeric.is_finite() {
390 return Err(BoxplotError::InvalidNumericValue { value: text });
391 }
392 out.push(numeric);
393 }
394 out.sort_by(f64::total_cmp);
395 Ok(out)
396}
397
398fn percentile(sorted: &[f64], percentile: f64) -> f64 {
399 if sorted.len() == 1 {
400 return sorted[0];
401 }
402
403 let max_index = usize_to_f64(sorted.len().saturating_sub(1));
404 let rank = (percentile / 100.0) * max_index;
405 let mut lower_index = 0usize;
406 while lower_index + 1 < sorted.len() && usize_to_f64(lower_index + 1) <= rank {
407 lower_index += 1;
408 }
409 let upper_index = if same_value(usize_to_f64(lower_index), rank) {
410 lower_index
411 } else {
412 (lower_index + 1).min(sorted.len().saturating_sub(1))
413 };
414 if lower_index == upper_index {
415 return sorted[lower_index];
416 }
417
418 let fraction = rank - usize_to_f64(lower_index);
419 let lower = sorted[lower_index];
420 let upper = sorted[upper_index];
421 lower + ((upper - lower) * fraction)
422}
423
424fn annotate_x_axis(plot: &mut Plot<BoxplotGraphics>) {
425 let min_x = plot.graphics().min_x();
426 let max_x = plot.graphics().max_x();
427 let mid_value = f64::midpoint(min_x, max_x);
428
429 plot.set_decoration(DecorationPosition::Bl, format_axis_value(min_x));
430 plot.set_decoration(DecorationPosition::B, format_axis_value(mid_value));
431 plot.set_decoration(DecorationPosition::Br, format_axis_value(max_x));
432}
433
434fn valid_limits(limits: (f64, f64)) -> bool {
435 limits.0.is_finite() && limits.1.is_finite() && limits.0 <= limits.1
436}
437
438fn round_half_even(value: f64) -> usize {
439 let floor = value.floor();
440 let fraction = value - floor;
441 let rounded = if fraction < 0.5 {
442 floor
443 } else if fraction > 0.5 {
444 floor + 1.0
445 } else {
446 let is_even = (floor / 2.0).fract().total_cmp(&0.0) == Ordering::Equal;
447 if is_even { floor } else { floor + 1.0 }
448 };
449 format!("{rounded:.0}").parse::<usize>().unwrap_or(0)
450}
451
452#[cfg(test)]
453mod tests {
454 use super::{BoxplotError, BoxplotOptions, boxplot, boxplot_add};
455 use crate::color::{NamedColor, TermColor};
456 use crate::test_util::{assert_fixture_eq, render_plot_text};
457
458 #[test]
459 fn default_fixture() {
460 let plot = boxplot::<&str, &[f64], f64>(
461 &[],
462 &[&[1.0, 2.0, 3.0, 4.0, 5.0]],
463 BoxplotOptions::default(),
464 )
465 .expect("boxplot should succeed");
466 assert_fixture_eq(
467 &render_plot_text(&plot, true),
468 "tests/fixtures/boxplot/default.txt",
469 );
470 }
471
472 #[test]
473 fn default_name_fixture() {
474 let plot = boxplot(
475 &["series1"],
476 &[&[1.0, 2.0, 3.0, 4.0, 5.0]],
477 BoxplotOptions::default(),
478 )
479 .expect("boxplot should succeed");
480 assert_fixture_eq(
481 &render_plot_text(&plot, true),
482 "tests/fixtures/boxplot/default_name.txt",
483 );
484 }
485
486 #[test]
487 fn default_parameters_fixtures() {
488 let plot = boxplot(
489 &["series1"],
490 &[&[1.0, 2.0, 3.0, 4.0, 5.0]],
491 BoxplotOptions {
492 title: Some(String::from("Test")),
493 xlim: (-1.0, 8.0),
494 color: TermColor::Named(NamedColor::Blue),
495 width: 50,
496 border: crate::border::BorderType::Solid,
497 xlabel: Some(String::from("foo")),
498 ..BoxplotOptions::default()
499 },
500 )
501 .expect("boxplot should succeed");
502
503 assert_fixture_eq(
504 &render_plot_text(&plot, true),
505 "tests/fixtures/boxplot/default_parameters.txt",
506 );
507 assert_fixture_eq(
508 &render_plot_text(&plot, false),
509 "tests/fixtures/boxplot/default_parameters_nocolor.txt",
510 );
511 }
512
513 #[test]
514 fn scaling_fixtures() {
515 let max_values = [5.0, 6.0, 10.0, 20.0, 40.0];
516 for (index, max_x) in max_values.into_iter().enumerate() {
517 let plot = boxplot::<&str, &[f64], f64>(
518 &[],
519 &[&[1.0, 2.0, 3.0, 4.0, 5.0]],
520 BoxplotOptions {
521 xlim: (0.0, max_x),
522 ..BoxplotOptions::default()
523 },
524 )
525 .expect("boxplot should succeed");
526
527 assert_fixture_eq(
528 &render_plot_text(&plot, true),
529 format!("tests/fixtures/boxplot/scale{}.txt", index + 1),
530 );
531 }
532 }
533
534 #[test]
535 fn multi_series_and_append_fixtures() {
536 let mut plot = boxplot(
537 &["one", "two"],
538 &[
539 &[1.0, 2.0, 3.0, 4.0, 5.0][..],
540 &[2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0][..],
541 ],
542 BoxplotOptions {
543 title: Some(String::from("Multi-series")),
544 xlabel: Some(String::from("foo")),
545 color: TermColor::Named(NamedColor::Yellow),
546 ..BoxplotOptions::default()
547 },
548 )
549 .expect("boxplot should succeed");
550 assert_fixture_eq(
551 &render_plot_text(&plot, true),
552 "tests/fixtures/boxplot/multi1.txt",
553 );
554
555 boxplot_add(&mut plot, "one more", &[-1.0, 2.0, 3.0, 4.0, 11.0])
556 .expect("append should succeed");
557 assert_fixture_eq(
558 &render_plot_text(&plot, true),
559 "tests/fixtures/boxplot/multi2.txt",
560 );
561
562 boxplot_add(&mut plot, "last one", &[4.0, 2.0, 2.5, 4.0, 14.0])
563 .expect("append should succeed");
564 assert_fixture_eq(
565 &render_plot_text(&plot, true),
566 "tests/fixtures/boxplot/multi3.txt",
567 );
568 }
569
570 #[test]
571 fn validates_inputs() {
572 let empty = boxplot::<&str, &[f64], f64>(&[], &[], BoxplotOptions::default());
573 assert!(matches!(empty, Err(BoxplotError::EmptySeries)));
574
575 let partially_empty = boxplot(
576 &["series1", "series2"],
577 &[&[1.0, 2.0][..], &[][..]],
578 BoxplotOptions::default(),
579 );
580 assert!(matches!(partially_empty, Err(BoxplotError::EmptySeries)));
581
582 let mismatch = boxplot(
583 &["series1", "series2"],
584 &[&[1.0, 2.0, 3.0]],
585 BoxplotOptions::default(),
586 );
587 assert!(matches!(mismatch, Err(BoxplotError::LengthMismatch)));
588
589 let invalid_xlim = boxplot::<&str, &[f64], f64>(
590 &[],
591 &[&[1.0, 2.0]],
592 BoxplotOptions {
593 xlim: (5.0, 1.0),
594 ..BoxplotOptions::default()
595 },
596 );
597 assert!(matches!(invalid_xlim, Err(BoxplotError::InvalidXLimits)));
598
599 let non_finite_xlim = boxplot::<&str, &[f64], f64>(
600 &[],
601 &[&[1.0, 2.0]],
602 BoxplotOptions {
603 xlim: (0.0, f64::INFINITY),
604 ..BoxplotOptions::default()
605 },
606 );
607 assert!(matches!(non_finite_xlim, Err(BoxplotError::InvalidXLimits)));
608
609 let invalid_numeric =
610 boxplot::<&str, &[&str], &str>(&[], &[&["abc"]], BoxplotOptions::default());
611 assert!(matches!(
612 invalid_numeric,
613 Err(BoxplotError::InvalidNumericValue { .. })
614 ));
615
616 let mut plot =
617 boxplot::<&str, &[f64], f64>(&[], &[&[1.0, 2.0, 3.0]], BoxplotOptions::default())
618 .expect("boxplot should succeed");
619 let append_error = boxplot_add(&mut plot, "series", &[] as &[f64]);
620 assert!(matches!(append_error, Err(BoxplotError::EmptyAppend)));
621
622 let append_invalid_numeric = boxplot_add(&mut plot, "bad", &["NaN"]);
623 assert!(matches!(
624 append_invalid_numeric,
625 Err(BoxplotError::InvalidNumericValue { .. })
626 ));
627 }
628
629 #[test]
630 fn shared_renderer_plain_output_is_text_only() {
631 let plot = boxplot::<&str, &[f64], f64>(
632 &[],
633 &[&[1.0, 2.0, 3.0, 4.0, 5.0]],
634 BoxplotOptions::default(),
635 )
636 .expect("boxplot should succeed");
637
638 let plain = render_plot_text(&plot, false);
639 let colored = render_plot_text(&plot, true);
640 let stripped = colored
641 .replace("\x1b[90m", "")
642 .replace("\x1b[39m", "")
643 .replace("\x1b[32m", "")
644 .replace("\x1b[37m", "")
645 .replace("\x1b[0m", "")
646 .replace("\x1b[1m", "")
647 .replace("\x1b[22m", "");
648
649 assert_eq!(plain, stripped);
650 assert!(!plain.contains("\x1b["));
651 }
652}