use std::cmp::Ordering;
use thiserror::Error;
use crate::border::BorderType;
use crate::color::{CanvasColor, NamedColor, TermColor, canvas_color_from_term};
use crate::graphics::{GraphicsArea, RowBuffer, RowCell};
use crate::math::{extend_limits, format_axis_value, same_value, usize_to_f64};
use crate::plot::{DecorationPosition, Plot};
const MIN_WIDTH: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Summary {
min: f64,
q1: f64,
median: f64,
q3: f64,
max: f64,
}
impl Summary {
fn from_sorted(values: &[f64]) -> Self {
Self {
min: percentile(values, 0.0),
q1: percentile(values, 25.0),
median: percentile(values, 50.0),
q3: percentile(values, 75.0),
max: percentile(values, 100.0),
}
}
const fn as_array(self) -> [f64; 5] {
[self.min, self.q1, self.median, self.q3, self.max]
}
}
#[derive(Debug, Clone)]
pub struct BoxplotGraphics {
summaries: Vec<Summary>,
width_chars: usize,
color: CanvasColor,
min_x: f64,
max_x: f64,
}
impl BoxplotGraphics {
fn new(
summaries: Vec<Summary>,
width_chars: usize,
color: CanvasColor,
mut min_x: f64,
mut max_x: f64,
) -> Self {
if same_value(min_x, max_x) {
min_x -= 1.0;
max_x += 1.0;
}
Self {
summaries,
width_chars: width_chars.max(MIN_WIDTH),
color,
min_x,
max_x,
}
}
fn min_x(&self) -> f64 {
self.min_x
}
fn max_x(&self) -> f64 {
self.max_x
}
fn add_series(&mut self, summary: Summary) {
self.min_x = self.min_x.min(summary.min);
self.max_x = self.max_x.max(summary.max);
if same_value(self.min_x, self.max_x) {
self.min_x -= 1.0;
self.max_x += 1.0;
}
self.summaries.push(summary);
}
fn transform_to_column(&self, value: f64) -> usize {
let width_f64 = usize_to_f64(self.width_chars);
let scaled =
((value - self.min_x) / (self.max_x - self.min_x) * width_f64).clamp(1.0, width_f64);
round_half_even(scaled).clamp(1, self.width_chars)
}
}
impl GraphicsArea for BoxplotGraphics {
fn nrows(&self) -> usize {
3 * self.summaries.len()
}
fn ncols(&self) -> usize {
self.width_chars
}
fn render_row(&self, row: usize, out: &mut RowBuffer) {
out.clear();
out.resize(
self.width_chars,
RowCell {
glyph: ' ',
color: self.color,
},
);
let Some(summary) = self.summaries.get(row / 3) else {
return;
};
let row_in_series = row % 3;
let (
min_char,
line_char,
left_box_char,
line_box_char,
median_char,
right_box_char,
max_char,
) = match row_in_series {
0 => ('╷', ' ', '┌', '─', '┬', '┐', '╷'),
1 => ('├', '─', '┤', ' ', '│', '├', '┤'),
_ => ('╵', ' ', '└', '─', '┴', '┘', '╵'),
};
let transformed = summary
.as_array()
.map(|value| self.transform_to_column(value));
let min_col = transformed[0] - 1;
let q1_col = transformed[1] - 1;
let median_col = transformed[2] - 1;
let q3_col = transformed[3] - 1;
let max_col = transformed[4] - 1;
out[min_col].glyph = min_char;
out[q1_col].glyph = left_box_char;
out[median_col].glyph = median_char;
out[q3_col].glyph = right_box_char;
out[max_col].glyph = max_char;
for cell in out
.iter_mut()
.take(transformed[1].saturating_sub(1))
.skip(transformed[0])
{
cell.glyph = line_char;
}
for cell in out
.iter_mut()
.take(transformed[2].saturating_sub(1))
.skip(transformed[1])
{
cell.glyph = line_box_char;
}
for cell in out
.iter_mut()
.take(transformed[3].saturating_sub(1))
.skip(transformed[2])
{
cell.glyph = line_box_char;
}
for cell in out
.iter_mut()
.take(transformed[4].saturating_sub(1))
.skip(transformed[3])
{
cell.glyph = line_char;
}
}
}
#[derive(Debug, Error, PartialEq)]
#[non_exhaustive]
pub enum BoxplotError {
#[error("labels and series must be the same length")]
LengthMismatch,
#[error("boxplot requires at least one series, and each series must be non-empty")]
EmptySeries,
#[error("Can't append empty array to boxplot")]
EmptyAppend,
#[error("xlim must contain finite values with min <= max")]
InvalidXLimits,
#[error("invalid numeric value: {value}")]
InvalidNumericValue { value: String },
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct BoxplotOptions {
pub title: Option<String>,
pub xlabel: Option<String>,
pub ylabel: Option<String>,
pub border: BorderType,
pub margin: u16,
pub padding: u16,
pub labels: bool,
pub color: TermColor,
pub width: usize,
pub xlim: (f64, f64),
}
impl Default for BoxplotOptions {
fn default() -> Self {
Self {
title: None,
xlabel: None,
ylabel: None,
border: BorderType::Corners,
margin: Plot::<BoxplotGraphics>::DEFAULT_MARGIN,
padding: Plot::<BoxplotGraphics>::DEFAULT_PADDING,
labels: true,
color: TermColor::Named(NamedColor::Green),
width: 40,
xlim: (0.0, 0.0),
}
}
}
pub fn boxplot<L, S, V>(
labels: &[L],
series: &[S],
options: BoxplotOptions,
) -> Result<Plot<BoxplotGraphics>, BoxplotError>
where
L: ToString,
S: AsRef<[V]>,
V: ToString,
{
if series.is_empty() {
return Err(BoxplotError::EmptySeries);
}
if !labels.is_empty() && labels.len() != series.len() {
return Err(BoxplotError::LengthMismatch);
}
if !valid_limits(options.xlim) {
return Err(BoxplotError::InvalidXLimits);
}
let mut summaries = Vec::with_capacity(series.len());
for values in series {
let parsed = parse_values(values.as_ref())?;
if parsed.is_empty() {
return Err(BoxplotError::EmptySeries);
}
summaries.push(Summary::from_sorted(&parsed));
}
let (min_x, max_x) = extend_limits(
&summaries
.iter()
.flat_map(|summary| [summary.min, summary.max])
.collect::<Vec<_>>(),
options.xlim,
);
let graphics = BoxplotGraphics::new(
summaries,
options.width,
canvas_color_from_term(options.color),
min_x,
max_x,
);
let mut plot = Plot::new(graphics);
plot.title = options.title;
plot.xlabel = options.xlabel;
plot.ylabel = options.ylabel;
plot.border = options.border;
plot.margin = options.margin;
plot.padding = options.padding;
plot.show_labels = options.labels;
let names: Vec<String> = if labels.is_empty() {
vec![String::new(); series.len()]
} else {
labels.iter().map(ToString::to_string).collect()
};
annotate_x_axis(&mut plot);
for (index, name) in names.into_iter().enumerate() {
if !name.is_empty() {
plot.annotate_left(index * 3 + 1, name, None);
}
}
Ok(plot)
}
pub fn boxplot_add<V>(
plot: &mut Plot<BoxplotGraphics>,
label: &str,
data: &[V],
) -> Result<(), BoxplotError>
where
V: ToString,
{
if data.is_empty() {
return Err(BoxplotError::EmptyAppend);
}
let parsed = parse_values(data)?;
if parsed.is_empty() {
return Err(BoxplotError::EmptyAppend);
}
let summary = Summary::from_sorted(&parsed);
plot.graphics_mut().add_series(summary);
let row = (plot.graphics().nrows() / 3).saturating_sub(1) * 3 + 1;
let name = label.to_owned();
if !name.is_empty() {
plot.annotate_left(row, name, None);
}
annotate_x_axis(plot);
Ok(())
}
fn parse_values<V: ToString>(values: &[V]) -> Result<Vec<f64>, BoxplotError> {
let mut out = Vec::with_capacity(values.len());
for value in values {
let text = value.to_string();
let numeric = text
.parse::<f64>()
.map_err(|_| BoxplotError::InvalidNumericValue {
value: text.clone(),
})?;
if !numeric.is_finite() {
return Err(BoxplotError::InvalidNumericValue { value: text });
}
out.push(numeric);
}
out.sort_by(f64::total_cmp);
Ok(out)
}
fn percentile(sorted: &[f64], percentile: f64) -> f64 {
if sorted.len() == 1 {
return sorted[0];
}
let max_index = usize_to_f64(sorted.len().saturating_sub(1));
let rank = (percentile / 100.0) * max_index;
let mut lower_index = 0usize;
while lower_index + 1 < sorted.len() && usize_to_f64(lower_index + 1) <= rank {
lower_index += 1;
}
let upper_index = if same_value(usize_to_f64(lower_index), rank) {
lower_index
} else {
(lower_index + 1).min(sorted.len().saturating_sub(1))
};
if lower_index == upper_index {
return sorted[lower_index];
}
let fraction = rank - usize_to_f64(lower_index);
let lower = sorted[lower_index];
let upper = sorted[upper_index];
lower + ((upper - lower) * fraction)
}
fn annotate_x_axis(plot: &mut Plot<BoxplotGraphics>) {
let min_x = plot.graphics().min_x();
let max_x = plot.graphics().max_x();
let mid_value = f64::midpoint(min_x, max_x);
plot.set_decoration(DecorationPosition::Bl, format_axis_value(min_x));
plot.set_decoration(DecorationPosition::B, format_axis_value(mid_value));
plot.set_decoration(DecorationPosition::Br, format_axis_value(max_x));
}
fn valid_limits(limits: (f64, f64)) -> bool {
limits.0.is_finite() && limits.1.is_finite() && limits.0 <= limits.1
}
fn round_half_even(value: f64) -> usize {
let floor = value.floor();
let fraction = value - floor;
let rounded = if fraction < 0.5 {
floor
} else if fraction > 0.5 {
floor + 1.0
} else {
let is_even = (floor / 2.0).fract().total_cmp(&0.0) == Ordering::Equal;
if is_even { floor } else { floor + 1.0 }
};
format!("{rounded:.0}").parse::<usize>().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::{BoxplotError, BoxplotOptions, boxplot, boxplot_add};
use crate::color::{NamedColor, TermColor};
use crate::test_util::{assert_fixture_eq, render_plot_text};
#[test]
fn default_fixture() {
let plot = boxplot::<&str, &[f64], f64>(
&[],
&[&[1.0, 2.0, 3.0, 4.0, 5.0]],
BoxplotOptions::default(),
)
.expect("boxplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/boxplot/default.txt",
);
}
#[test]
fn default_name_fixture() {
let plot = boxplot(
&["series1"],
&[&[1.0, 2.0, 3.0, 4.0, 5.0]],
BoxplotOptions::default(),
)
.expect("boxplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/boxplot/default_name.txt",
);
}
#[test]
fn default_parameters_fixtures() {
let plot = boxplot(
&["series1"],
&[&[1.0, 2.0, 3.0, 4.0, 5.0]],
BoxplotOptions {
title: Some(String::from("Test")),
xlim: (-1.0, 8.0),
color: TermColor::Named(NamedColor::Blue),
width: 50,
border: crate::border::BorderType::Solid,
xlabel: Some(String::from("foo")),
..BoxplotOptions::default()
},
)
.expect("boxplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/boxplot/default_parameters.txt",
);
assert_fixture_eq(
&render_plot_text(&plot, false),
"tests/fixtures/boxplot/default_parameters_nocolor.txt",
);
}
#[test]
fn scaling_fixtures() {
let max_values = [5.0, 6.0, 10.0, 20.0, 40.0];
for (index, max_x) in max_values.into_iter().enumerate() {
let plot = boxplot::<&str, &[f64], f64>(
&[],
&[&[1.0, 2.0, 3.0, 4.0, 5.0]],
BoxplotOptions {
xlim: (0.0, max_x),
..BoxplotOptions::default()
},
)
.expect("boxplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
format!("tests/fixtures/boxplot/scale{}.txt", index + 1),
);
}
}
#[test]
fn multi_series_and_append_fixtures() {
let mut plot = boxplot(
&["one", "two"],
&[
&[1.0, 2.0, 3.0, 4.0, 5.0][..],
&[2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0][..],
],
BoxplotOptions {
title: Some(String::from("Multi-series")),
xlabel: Some(String::from("foo")),
color: TermColor::Named(NamedColor::Yellow),
..BoxplotOptions::default()
},
)
.expect("boxplot should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/boxplot/multi1.txt",
);
boxplot_add(&mut plot, "one more", &[-1.0, 2.0, 3.0, 4.0, 11.0])
.expect("append should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/boxplot/multi2.txt",
);
boxplot_add(&mut plot, "last one", &[4.0, 2.0, 2.5, 4.0, 14.0])
.expect("append should succeed");
assert_fixture_eq(
&render_plot_text(&plot, true),
"tests/fixtures/boxplot/multi3.txt",
);
}
#[test]
fn validates_inputs() {
let empty = boxplot::<&str, &[f64], f64>(&[], &[], BoxplotOptions::default());
assert!(matches!(empty, Err(BoxplotError::EmptySeries)));
let partially_empty = boxplot(
&["series1", "series2"],
&[&[1.0, 2.0][..], &[][..]],
BoxplotOptions::default(),
);
assert!(matches!(partially_empty, Err(BoxplotError::EmptySeries)));
let mismatch = boxplot(
&["series1", "series2"],
&[&[1.0, 2.0, 3.0]],
BoxplotOptions::default(),
);
assert!(matches!(mismatch, Err(BoxplotError::LengthMismatch)));
let invalid_xlim = boxplot::<&str, &[f64], f64>(
&[],
&[&[1.0, 2.0]],
BoxplotOptions {
xlim: (5.0, 1.0),
..BoxplotOptions::default()
},
);
assert!(matches!(invalid_xlim, Err(BoxplotError::InvalidXLimits)));
let non_finite_xlim = boxplot::<&str, &[f64], f64>(
&[],
&[&[1.0, 2.0]],
BoxplotOptions {
xlim: (0.0, f64::INFINITY),
..BoxplotOptions::default()
},
);
assert!(matches!(non_finite_xlim, Err(BoxplotError::InvalidXLimits)));
let invalid_numeric =
boxplot::<&str, &[&str], &str>(&[], &[&["abc"]], BoxplotOptions::default());
assert!(matches!(
invalid_numeric,
Err(BoxplotError::InvalidNumericValue { .. })
));
let mut plot =
boxplot::<&str, &[f64], f64>(&[], &[&[1.0, 2.0, 3.0]], BoxplotOptions::default())
.expect("boxplot should succeed");
let append_error = boxplot_add(&mut plot, "series", &[] as &[f64]);
assert!(matches!(append_error, Err(BoxplotError::EmptyAppend)));
let append_invalid_numeric = boxplot_add(&mut plot, "bad", &["NaN"]);
assert!(matches!(
append_invalid_numeric,
Err(BoxplotError::InvalidNumericValue { .. })
));
}
#[test]
fn shared_renderer_plain_output_is_text_only() {
let plot = boxplot::<&str, &[f64], f64>(
&[],
&[&[1.0, 2.0, 3.0, 4.0, 5.0]],
BoxplotOptions::default(),
)
.expect("boxplot should succeed");
let plain = render_plot_text(&plot, false);
let colored = render_plot_text(&plot, true);
let stripped = colored
.replace("\x1b[90m", "")
.replace("\x1b[39m", "")
.replace("\x1b[32m", "")
.replace("\x1b[37m", "")
.replace("\x1b[0m", "")
.replace("\x1b[1m", "")
.replace("\x1b[22m", "");
assert_eq!(plain, stripped);
assert!(!plain.contains("\x1b["));
}
}