Skip to main content

unicode_plot/
boxplot.rs

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/// Graphics area for box-and-whisker plots. Each series occupies three
39/// character rows rendering the five-number summary (min, Q1, median, Q3, max).
40#[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/// Errors returned by boxplot construction and mutation.
183#[derive(Debug, Error, PartialEq)]
184#[non_exhaustive]
185pub enum BoxplotError {
186    /// Labels and series counts do not match.
187    #[error("labels and series must be the same length")]
188    LengthMismatch,
189    /// No series provided, or a series contains no valid data.
190    #[error("boxplot requires at least one series, and each series must be non-empty")]
191    EmptySeries,
192    /// Attempted to append an empty data set.
193    #[error("Can't append empty array to boxplot")]
194    EmptyAppend,
195    /// Explicit x limits are non-finite or inverted.
196    #[error("xlim must contain finite values with min <= max")]
197    InvalidXLimits,
198    /// A data value could not be parsed as a finite number.
199    #[error("invalid numeric value: {value}")]
200    InvalidNumericValue { value: String },
201}
202
203/// Configuration for boxplot construction.
204#[derive(Debug, Clone)]
205#[non_exhaustive]
206pub struct BoxplotOptions {
207    /// Plot title displayed above the chart.
208    pub title: Option<String>,
209    /// Label below the x-axis.
210    pub xlabel: Option<String>,
211    /// Label to the left of the y-axis.
212    pub ylabel: Option<String>,
213    /// Border style (default: [`BorderType::Corners`]).
214    pub border: BorderType,
215    /// Left margin in characters (default: 3).
216    pub margin: u16,
217    /// Padding between border and content (default: 1).
218    pub padding: u16,
219    /// Whether to show series name labels (default: true).
220    pub labels: bool,
221    /// Box color (default: green).
222    pub color: TermColor,
223    /// Box area width in characters (default: 40).
224    pub width: usize,
225    /// Explicit x-axis limits. `(0.0, 0.0)` means auto-detect from data.
226    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
246/// Constructs a boxplot from one or more numeric series.
247///
248/// # Examples
249///
250/// ```
251/// use unicode_plot::{BoxplotOptions, boxplot};
252///
253/// let mut options = BoxplotOptions::default();
254/// options.title = Some("Example Boxplot".to_owned());
255///
256/// let series = vec![
257///     vec![1.0, 2.0, 2.5, 3.5, 4.0],
258///     vec![2.0, 2.2, 2.8, 3.0, 3.7],
259/// ];
260/// let plot = boxplot(&["group-a", "group-b"], &series, options).unwrap();
261///
262/// let mut buf = Vec::new();
263/// plot.render(&mut buf, false).unwrap();
264/// let rendered = String::from_utf8(buf).unwrap();
265/// assert!(rendered.contains("Example Boxplot"));
266/// ```
267///
268/// # Errors
269///
270/// Returns [`BoxplotError::LengthMismatch`] when `labels` is non-empty and does
271/// not match the number of series, [`BoxplotError::EmptySeries`] when no data is
272/// provided, [`BoxplotError::InvalidXLimits`] for invalid x limits, and
273/// [`BoxplotError::InvalidNumericValue`] when data fails numeric parsing.
274pub 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
344/// Appends a series to an existing boxplot.
345///
346/// # Errors
347///
348/// Returns [`BoxplotError::EmptyAppend`] when `data` is empty and
349/// [`BoxplotError::InvalidNumericValue`] when values fail numeric parsing.
350pub 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}