plotters-statistical 0.1.0

Statistical chart primitives (box, violin, ROC, PR, regularization-path, residual) as native plotters series
Documentation
//! Box-and-whisker plot series.
//!
//! [`BoxPlot`] is a single composite element (rendered like `plotters`' own
//! `CandleStick`); [`BoxPlotSeries`] lays several boxes out side by side. Both
//! plug straight into [`draw_series`](plotters::chart::ChartContext::draw_series).

use plotters::element::{Drawable, PointCollection};
use plotters::style::{RGBColor, ShapeStyle};
use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind};

use crate::stats::{quartiles, Quartiles, StatsError};
use crate::style::{stroke_style, translucent_fill};

const DEFAULT_BOX_FILL: RGBColor = RGBColor(0, 114, 178); // Okabe–Ito blue
const NEAR_BLACK: RGBColor = RGBColor(30, 30, 30);
const OUTLIER_GRAY: RGBColor = RGBColor(90, 90, 90);

/// Visual styling for a box plot. Every field is overridable; the defaults are
/// a shared starting point (see [`crate::style`]).
#[derive(Debug, Clone)]
pub struct BoxStyle {
    /// Fill of the interquartile box.
    pub box_fill: ShapeStyle,
    /// Border of the interquartile box.
    pub box_border: ShapeStyle,
    /// Whisker and cap line style.
    pub whisker: ShapeStyle,
    /// Median line style.
    pub median: ShapeStyle,
    /// Outlier marker style.
    pub outlier: ShapeStyle,
    /// Radius, in pixels, of outlier markers.
    pub outlier_radius: u32,
    /// Whisker cap length as a fraction of the box width (`0.0`–`1.0`).
    pub cap_ratio: f64,
}

impl Default for BoxStyle {
    fn default() -> Self {
        Self {
            box_fill: translucent_fill(&DEFAULT_BOX_FILL, 0.45),
            box_border: stroke_style(&NEAR_BLACK, 1),
            whisker: stroke_style(&NEAR_BLACK, 1),
            median: stroke_style(&NEAR_BLACK, 2),
            outlier: translucent_fill(&OUTLIER_GRAY, 0.8),
            outlier_radius: 3,
            cap_ratio: 0.6,
        }
    }
}

/// A single box-and-whisker, positioned at one coordinate on the category axis.
///
/// Generic over the full chart coordinate `C` so the same type serves both
/// orientations: a **vertical** box lives on a `(X, f64)` chart, a
/// **horizontal** box on a `(f64, Y)` chart. Use [`BoxPlot::vertical`] /
/// [`BoxPlot::horizontal`] to construct one; the width is measured in pixels,
/// so it is independent of the axis scale.
///
/// ```no_run
/// use plotters::prelude::*;
/// use plotters_statistical::BoxPlot;
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let root = SVGBackend::new("one_box.svg", (400, 300)).into_drawing_area();
/// let mut chart = ChartBuilder::on(&root)
///     .build_cartesian_2d(0.5f64..1.5f64, 0f64..10f64)?;
/// let bx = BoxPlot::vertical(1.0f64, &[1.0, 2.0, 3.0, 4.0, 9.0])?;
/// chart.draw_series(std::iter::once(bx))?;
/// # Ok(()) }
/// ```
#[derive(Debug, Clone)]
pub struct BoxPlot<C> {
    // Layout: index 0..5 are [upper_whisker, q3, median, q1, lower_whisker];
    // the remainder are outlier points. All share the category coordinate.
    points: Vec<C>,
    width: u32,
    horizontal: bool,
    style: BoxStyle,
}

const N_BOX_POINTS: usize = 5;
const DEFAULT_WIDTH: u32 = 24;

impl<X: Clone> BoxPlot<(X, f64)> {
    /// A vertical box at category position `x`, computed from a raw `data`
    /// sample. Errors if the sample has no finite values.
    pub fn vertical(x: X, data: &[f64]) -> Result<Self, StatsError> {
        Ok(Self::vertical_from_quartiles(x, &quartiles(data)?))
    }

    /// A vertical box from already-computed [`Quartiles`] — useful when the
    /// summary was produced elsewhere (e.g. streamed) and the raw sample is no
    /// longer held.
    pub fn vertical_from_quartiles(x: X, q: &Quartiles) -> Self {
        let mut points = vec![
            (x.clone(), q.upper_whisker),
            (x.clone(), q.q3),
            (x.clone(), q.median),
            (x.clone(), q.q1),
            (x.clone(), q.lower_whisker),
        ];
        points.extend(q.outliers.iter().map(|&o| (x.clone(), o)));
        Self {
            points,
            width: DEFAULT_WIDTH,
            horizontal: false,
            style: BoxStyle::default(),
        }
    }
}

impl<Y: Clone> BoxPlot<(f64, Y)> {
    /// A horizontal box at category position `y`, computed from a raw `data`
    /// sample. Errors if the sample has no finite values.
    pub fn horizontal(y: Y, data: &[f64]) -> Result<Self, StatsError> {
        Ok(Self::horizontal_from_quartiles(y, &quartiles(data)?))
    }

    /// A horizontal box from already-computed [`Quartiles`].
    pub fn horizontal_from_quartiles(y: Y, q: &Quartiles) -> Self {
        let mut points = vec![
            (q.upper_whisker, y.clone()),
            (q.q3, y.clone()),
            (q.median, y.clone()),
            (q.q1, y.clone()),
            (q.lower_whisker, y.clone()),
        ];
        points.extend(q.outliers.iter().map(|&o| (o, y.clone())));
        Self {
            points,
            width: DEFAULT_WIDTH,
            horizontal: true,
            style: BoxStyle::default(),
        }
    }
}

impl<C> BoxPlot<C> {
    /// Set the box width in **pixels** (default 24).
    pub fn width(mut self, width: u32) -> Self {
        self.width = width;
        self
    }

    /// Replace the entire style block.
    pub fn style(mut self, style: BoxStyle) -> Self {
        self.style = style;
        self
    }

    /// Mutate the style in place (for tweaking one property).
    pub fn with_style(mut self, f: impl FnOnce(&mut BoxStyle)) -> Self {
        f(&mut self.style);
        self
    }
}

impl<'a, C: 'a> PointCollection<'a, C> for &'a BoxPlot<C> {
    type Point = &'a C;
    type IntoIter = &'a [C];
    fn point_iter(self) -> &'a [C] {
        &self.points
    }
}

impl<C, DB: DrawingBackend> Drawable<DB> for BoxPlot<C> {
    fn draw<I: Iterator<Item = BackendCoord>>(
        &self,
        points: I,
        backend: &mut DB,
        _parent_dim: (u32, u32),
    ) -> Result<(), DrawingErrorKind<DB::ErrorType>> {
        let pix: Vec<BackendCoord> = points.collect();
        if pix.len() < N_BOX_POINTS {
            return Ok(());
        }
        let (upper_w, q3, median, q1, lower_w) = (pix[0], pix[1], pix[2], pix[3], pix[4]);
        let half = (self.width / 2) as i32;
        let cap = ((self.width as f64 * self.style.cap_ratio) / 2.0).round() as i32;
        let s = &self.style;

        if self.horizontal {
            let cy = q1.1; // all box points share the category (y) pixel
            let (bx1, bx2) = (q1.0.min(q3.0), q1.0.max(q3.0));
            // whiskers along x, with vertical caps
            backend.draw_line((lower_w.0, cy), (bx1, cy), &s.whisker)?;
            backend.draw_line((bx2, cy), (upper_w.0, cy), &s.whisker)?;
            backend.draw_line((lower_w.0, cy - cap), (lower_w.0, cy + cap), &s.whisker)?;
            backend.draw_line((upper_w.0, cy - cap), (upper_w.0, cy + cap), &s.whisker)?;
            // box
            backend.draw_rect((bx1, cy - half), (bx2, cy + half), &s.box_fill, true)?;
            backend.draw_rect((bx1, cy - half), (bx2, cy + half), &s.box_border, false)?;
            // median
            backend.draw_line((median.0, cy - half), (median.0, cy + half), &s.median)?;
        } else {
            let cx = q1.0; // all box points share the category (x) pixel
            let (by1, by2) = (q3.1.min(q1.1), q3.1.max(q1.1));
            // whiskers along y, with horizontal caps
            backend.draw_line((cx, lower_w.1), (cx, by2), &s.whisker)?;
            backend.draw_line((cx, by1), (cx, upper_w.1), &s.whisker)?;
            backend.draw_line((cx - cap, lower_w.1), (cx + cap, lower_w.1), &s.whisker)?;
            backend.draw_line((cx - cap, upper_w.1), (cx + cap, upper_w.1), &s.whisker)?;
            // box
            backend.draw_rect((cx - half, by1), (cx + half, by2), &s.box_fill, true)?;
            backend.draw_rect((cx - half, by1), (cx + half, by2), &s.box_border, false)?;
            // median
            backend.draw_line((cx - half, median.1), (cx + half, median.1), &s.median)?;
        }

        // Outliers (any points beyond the five box points).
        for o in &pix[N_BOX_POINTS..] {
            backend.draw_circle(*o, s.outlier_radius, &s.outlier, s.outlier.filled)?;
        }
        Ok(())
    }
}

/// A group of boxes laid out side by side — one per named sample — since real
/// use cases (one box per class/feature) always need several boxes on one
/// chart, not a single isolated box.
///
/// Implements [`IntoIterator`], so it drops straight into `draw_series`.
#[derive(Debug, Clone)]
pub struct BoxPlotSeries<C> {
    boxes: Vec<BoxPlot<C>>,
}

impl<X: Clone> BoxPlotSeries<(X, f64)> {
    /// Build a vertical multi-box series from `(position, sample)` pairs. Errors
    /// if any sample has no finite values.
    pub fn from_samples<I, S>(groups: I) -> Result<Self, StatsError>
    where
        I: IntoIterator<Item = (X, S)>,
        S: AsRef<[f64]>,
    {
        let boxes = groups
            .into_iter()
            .map(|(x, s)| BoxPlot::vertical(x, s.as_ref()))
            .collect::<Result<Vec<_>, _>>()?;
        Ok(Self { boxes })
    }
}

impl<Y: Clone> BoxPlotSeries<(f64, Y)> {
    /// Build a horizontal multi-box series from `(position, sample)` pairs.
    pub fn horizontal_from_samples<I, S>(groups: I) -> Result<Self, StatsError>
    where
        I: IntoIterator<Item = (Y, S)>,
        S: AsRef<[f64]>,
    {
        let boxes = groups
            .into_iter()
            .map(|(y, s)| BoxPlot::horizontal(y, s.as_ref()))
            .collect::<Result<Vec<_>, _>>()?;
        Ok(Self { boxes })
    }
}

impl<C> BoxPlotSeries<C> {
    /// Apply a common width (pixels) to every box in the group.
    pub fn width(mut self, width: u32) -> Self {
        self.boxes = self.boxes.into_iter().map(|b| b.width(width)).collect();
        self
    }

    /// Apply a common style to every box in the group.
    pub fn style(mut self, style: BoxStyle) -> Self {
        self.boxes = self
            .boxes
            .into_iter()
            .map(|b| b.style(style.clone()))
            .collect();
        self
    }
}

impl<C> IntoIterator for BoxPlotSeries<C> {
    type Item = BoxPlot<C>;
    type IntoIter = std::vec::IntoIter<BoxPlot<C>>;
    fn into_iter(self) -> Self::IntoIter {
        self.boxes.into_iter()
    }
}