use crate::artist::{Collection, Line2D, MarkerStyle, Patch};
use crate::core::color::Rgba;
use crate::figure::Axes;
const BOX_WIDTH: f64 = 0.5;
const WHISKER_IQR_MULTIPLE: f64 = 1.5;
const BOX_FACE: Rgba = Rgba::WHITE;
const FLIER_SIZE: f64 = 6.0;
fn percentile(sorted: &[f64], q: f64) -> f64 {
debug_assert!(!sorted.is_empty(), "percentile requires non-empty data");
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let rank = (n - 1) as f64 * q / 100.0;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
let frac = rank - lo as f64;
sorted[lo] + (sorted[hi] - sorted[lo]) * frac
}
struct BoxStats {
q1: f64,
median: f64,
q3: f64,
whisker_lo: f64,
whisker_hi: f64,
fliers: Vec<f64>,
}
fn box_stats(data: &[f64]) -> BoxStats {
let mut sorted: Vec<f64> = data.iter().copied().filter(|v| v.is_finite()).collect();
sorted.sort_by(|a, b| a.partial_cmp(b).expect("filtered finite values"));
let q1 = percentile(&sorted, 25.0);
let median = percentile(&sorted, 50.0);
let q3 = percentile(&sorted, 75.0);
let iqr = q3 - q1;
let lo_fence = q1 - WHISKER_IQR_MULTIPLE * iqr;
let hi_fence = q3 + WHISKER_IQR_MULTIPLE * iqr;
let whisker_lo = sorted
.iter()
.copied()
.find(|&v| v >= lo_fence)
.unwrap_or(q1);
let whisker_hi = sorted
.iter()
.rev()
.copied()
.find(|&v| v <= hi_fence)
.unwrap_or(q3);
let fliers: Vec<f64> = sorted
.iter()
.copied()
.filter(|&v| v < whisker_lo || v > whisker_hi)
.collect();
BoxStats {
q1,
median,
q3,
whisker_lo,
whisker_hi,
fliers,
}
}
impl Axes {
pub fn boxplot(&mut self, data: &[&[f64]]) {
let half = BOX_WIDTH / 2.0;
let cap = BOX_WIDTH / 4.0;
for (i, dataset) in data.iter().enumerate() {
if dataset.iter().all(|v| !v.is_finite()) {
continue;
}
let pos = (i + 1) as f64;
let stats = box_stats(dataset);
self.add_patch(
Patch::rectangle(pos - half, stats.q1, BOX_WIDTH, stats.q3 - stats.q1)
.facecolor(Some(BOX_FACE))
.edgecolor(Some(Rgba::BLACK)),
);
self.add_line(
Line2D::new(
vec![pos - half, pos + half],
vec![stats.median, stats.median],
)
.with_color(Rgba::BLACK),
);
self.add_line(
Line2D::new(vec![pos, pos], vec![stats.q1, stats.whisker_lo])
.with_color(Rgba::BLACK),
);
self.add_line(
Line2D::new(
vec![pos - cap, pos + cap],
vec![stats.whisker_lo, stats.whisker_lo],
)
.with_color(Rgba::BLACK),
);
self.add_line(
Line2D::new(vec![pos, pos], vec![stats.q3, stats.whisker_hi])
.with_color(Rgba::BLACK),
);
self.add_line(
Line2D::new(
vec![pos - cap, pos + cap],
vec![stats.whisker_hi, stats.whisker_hi],
)
.with_color(Rgba::BLACK),
);
if !stats.fliers.is_empty() {
let offsets: Vec<[f64; 2]> = stats.fliers.iter().map(|&v| [pos, v]).collect();
let marker = MarkerStyle::from_char('o')
.expect("'o' is a known marker")
.path()
.clone();
self.collections.push(
Collection::scatter(offsets)
.with_marker(marker)
.with_sizes(vec![FLIER_SIZE])
.with_facecolors(Vec::new())
.with_edgecolors(vec![Rgba::BLACK]),
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Bbox;
fn approx(a: f64, b: f64) {
assert!((a - b).abs() < 1e-9, "expected {b}, got {a}");
}
#[test]
fn percentile_matches_known_quartiles() {
let sorted = [1.0, 2.0, 3.0, 4.0, 5.0];
approx(percentile(&sorted, 0.0), 1.0);
approx(percentile(&sorted, 25.0), 2.0);
approx(percentile(&sorted, 50.0), 3.0);
approx(percentile(&sorted, 75.0), 4.0);
approx(percentile(&sorted, 100.0), 5.0);
}
#[test]
fn percentile_interpolates_between_order_statistics() {
let sorted = [10.0, 20.0, 30.0, 40.0];
approx(percentile(&sorted, 25.0), 17.5);
approx(percentile(&sorted, 50.0), 25.0);
approx(percentile(&sorted, 75.0), 32.5);
}
#[test]
fn boxplot_adds_expected_artists_per_box() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let a = [1.0, 2.0, 3.0, 4.0, 5.0];
let b = [2.0, 3.0, 4.0, 5.0, 6.0];
ax.boxplot(&[&a, &b]);
assert_eq!(ax.patches.len(), 2);
assert_eq!(ax.lines.len(), 2 * 5);
assert!(ax.collections.is_empty());
}
#[test]
fn boxplot_positions_and_limits_span_data() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let a = [1.0, 2.0, 3.0, 4.0, 5.0];
let b = [10.0, 11.0, 12.0, 13.0, 14.0];
ax.boxplot(&[&a, &b]);
let e = ax.data_limits().expect("boxplot contributes data limits");
approx(e.xmin(), 0.75);
approx(e.xmax(), 2.25);
approx(e.ymin(), 1.0);
approx(e.ymax(), 14.0);
}
#[test]
fn boxplot_clear_outlier_produces_a_marker() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 100.0];
ax.boxplot(&[&data]);
assert_eq!(ax.collections.len(), 1);
let e = ax.data_limits().expect("boxplot contributes data limits");
approx(e.ymax(), 100.0);
}
#[test]
fn boxplot_empty_input_draws_nothing() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
ax.boxplot(&[]);
assert!(ax.patches.is_empty());
assert!(ax.lines.is_empty());
assert!(ax.collections.is_empty());
}
}