Skip to main content

plotters_statistical/series/
box_plot.rs

1//! Box-and-whisker plot series.
2//!
3//! [`BoxPlot`] is a single composite element (rendered like `plotters`' own
4//! `CandleStick`); [`BoxPlotSeries`] lays several boxes out side by side. Both
5//! plug straight into [`draw_series`](plotters::chart::ChartContext::draw_series).
6
7use plotters::element::{Drawable, PointCollection};
8use plotters::style::{RGBColor, ShapeStyle};
9use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind};
10
11use crate::stats::{quartiles, Quartiles, StatsError};
12use crate::style::{stroke_style, translucent_fill};
13
14const DEFAULT_BOX_FILL: RGBColor = RGBColor(0, 114, 178); // Okabe–Ito blue
15const NEAR_BLACK: RGBColor = RGBColor(30, 30, 30);
16const OUTLIER_GRAY: RGBColor = RGBColor(90, 90, 90);
17
18/// Visual styling for a box plot. Every field is overridable; the defaults are
19/// a shared starting point (see [`crate::style`]).
20#[derive(Debug, Clone)]
21pub struct BoxStyle {
22    /// Fill of the interquartile box.
23    pub box_fill: ShapeStyle,
24    /// Border of the interquartile box.
25    pub box_border: ShapeStyle,
26    /// Whisker and cap line style.
27    pub whisker: ShapeStyle,
28    /// Median line style.
29    pub median: ShapeStyle,
30    /// Outlier marker style.
31    pub outlier: ShapeStyle,
32    /// Radius, in pixels, of outlier markers.
33    pub outlier_radius: u32,
34    /// Whisker cap length as a fraction of the box width (`0.0`–`1.0`).
35    pub cap_ratio: f64,
36}
37
38impl Default for BoxStyle {
39    fn default() -> Self {
40        Self {
41            box_fill: translucent_fill(&DEFAULT_BOX_FILL, 0.45),
42            box_border: stroke_style(&NEAR_BLACK, 1),
43            whisker: stroke_style(&NEAR_BLACK, 1),
44            median: stroke_style(&NEAR_BLACK, 2),
45            outlier: translucent_fill(&OUTLIER_GRAY, 0.8),
46            outlier_radius: 3,
47            cap_ratio: 0.6,
48        }
49    }
50}
51
52/// A single box-and-whisker, positioned at one coordinate on the category axis.
53///
54/// Generic over the full chart coordinate `C` so the same type serves both
55/// orientations: a **vertical** box lives on a `(X, f64)` chart, a
56/// **horizontal** box on a `(f64, Y)` chart. Use [`BoxPlot::vertical`] /
57/// [`BoxPlot::horizontal`] to construct one; the width is measured in pixels,
58/// so it is independent of the axis scale.
59///
60/// ```no_run
61/// use plotters::prelude::*;
62/// use plotters_statistical::BoxPlot;
63///
64/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
65/// let root = SVGBackend::new("one_box.svg", (400, 300)).into_drawing_area();
66/// let mut chart = ChartBuilder::on(&root)
67///     .build_cartesian_2d(0.5f64..1.5f64, 0f64..10f64)?;
68/// let bx = BoxPlot::vertical(1.0f64, &[1.0, 2.0, 3.0, 4.0, 9.0])?;
69/// chart.draw_series(std::iter::once(bx))?;
70/// # Ok(()) }
71/// ```
72#[derive(Debug, Clone)]
73pub struct BoxPlot<C> {
74    // Layout: index 0..5 are [upper_whisker, q3, median, q1, lower_whisker];
75    // the remainder are outlier points. All share the category coordinate.
76    points: Vec<C>,
77    width: u32,
78    horizontal: bool,
79    style: BoxStyle,
80}
81
82const N_BOX_POINTS: usize = 5;
83const DEFAULT_WIDTH: u32 = 24;
84
85impl<X: Clone> BoxPlot<(X, f64)> {
86    /// A vertical box at category position `x`, computed from a raw `data`
87    /// sample. Errors if the sample has no finite values.
88    pub fn vertical(x: X, data: &[f64]) -> Result<Self, StatsError> {
89        Ok(Self::vertical_from_quartiles(x, &quartiles(data)?))
90    }
91
92    /// A vertical box from already-computed [`Quartiles`] — useful when the
93    /// summary was produced elsewhere (e.g. streamed) and the raw sample is no
94    /// longer held.
95    pub fn vertical_from_quartiles(x: X, q: &Quartiles) -> Self {
96        let mut points = vec![
97            (x.clone(), q.upper_whisker),
98            (x.clone(), q.q3),
99            (x.clone(), q.median),
100            (x.clone(), q.q1),
101            (x.clone(), q.lower_whisker),
102        ];
103        points.extend(q.outliers.iter().map(|&o| (x.clone(), o)));
104        Self {
105            points,
106            width: DEFAULT_WIDTH,
107            horizontal: false,
108            style: BoxStyle::default(),
109        }
110    }
111}
112
113impl<Y: Clone> BoxPlot<(f64, Y)> {
114    /// A horizontal box at category position `y`, computed from a raw `data`
115    /// sample. Errors if the sample has no finite values.
116    pub fn horizontal(y: Y, data: &[f64]) -> Result<Self, StatsError> {
117        Ok(Self::horizontal_from_quartiles(y, &quartiles(data)?))
118    }
119
120    /// A horizontal box from already-computed [`Quartiles`].
121    pub fn horizontal_from_quartiles(y: Y, q: &Quartiles) -> Self {
122        let mut points = vec![
123            (q.upper_whisker, y.clone()),
124            (q.q3, y.clone()),
125            (q.median, y.clone()),
126            (q.q1, y.clone()),
127            (q.lower_whisker, y.clone()),
128        ];
129        points.extend(q.outliers.iter().map(|&o| (o, y.clone())));
130        Self {
131            points,
132            width: DEFAULT_WIDTH,
133            horizontal: true,
134            style: BoxStyle::default(),
135        }
136    }
137}
138
139impl<C> BoxPlot<C> {
140    /// Set the box width in **pixels** (default 24).
141    pub fn width(mut self, width: u32) -> Self {
142        self.width = width;
143        self
144    }
145
146    /// Replace the entire style block.
147    pub fn style(mut self, style: BoxStyle) -> Self {
148        self.style = style;
149        self
150    }
151
152    /// Mutate the style in place (for tweaking one property).
153    pub fn with_style(mut self, f: impl FnOnce(&mut BoxStyle)) -> Self {
154        f(&mut self.style);
155        self
156    }
157}
158
159impl<'a, C: 'a> PointCollection<'a, C> for &'a BoxPlot<C> {
160    type Point = &'a C;
161    type IntoIter = &'a [C];
162    fn point_iter(self) -> &'a [C] {
163        &self.points
164    }
165}
166
167impl<C, DB: DrawingBackend> Drawable<DB> for BoxPlot<C> {
168    fn draw<I: Iterator<Item = BackendCoord>>(
169        &self,
170        points: I,
171        backend: &mut DB,
172        _parent_dim: (u32, u32),
173    ) -> Result<(), DrawingErrorKind<DB::ErrorType>> {
174        let pix: Vec<BackendCoord> = points.collect();
175        if pix.len() < N_BOX_POINTS {
176            return Ok(());
177        }
178        let (upper_w, q3, median, q1, lower_w) = (pix[0], pix[1], pix[2], pix[3], pix[4]);
179        let half = (self.width / 2) as i32;
180        let cap = ((self.width as f64 * self.style.cap_ratio) / 2.0).round() as i32;
181        let s = &self.style;
182
183        if self.horizontal {
184            let cy = q1.1; // all box points share the category (y) pixel
185            let (bx1, bx2) = (q1.0.min(q3.0), q1.0.max(q3.0));
186            // whiskers along x, with vertical caps
187            backend.draw_line((lower_w.0, cy), (bx1, cy), &s.whisker)?;
188            backend.draw_line((bx2, cy), (upper_w.0, cy), &s.whisker)?;
189            backend.draw_line((lower_w.0, cy - cap), (lower_w.0, cy + cap), &s.whisker)?;
190            backend.draw_line((upper_w.0, cy - cap), (upper_w.0, cy + cap), &s.whisker)?;
191            // box
192            backend.draw_rect((bx1, cy - half), (bx2, cy + half), &s.box_fill, true)?;
193            backend.draw_rect((bx1, cy - half), (bx2, cy + half), &s.box_border, false)?;
194            // median
195            backend.draw_line((median.0, cy - half), (median.0, cy + half), &s.median)?;
196        } else {
197            let cx = q1.0; // all box points share the category (x) pixel
198            let (by1, by2) = (q3.1.min(q1.1), q3.1.max(q1.1));
199            // whiskers along y, with horizontal caps
200            backend.draw_line((cx, lower_w.1), (cx, by2), &s.whisker)?;
201            backend.draw_line((cx, by1), (cx, upper_w.1), &s.whisker)?;
202            backend.draw_line((cx - cap, lower_w.1), (cx + cap, lower_w.1), &s.whisker)?;
203            backend.draw_line((cx - cap, upper_w.1), (cx + cap, upper_w.1), &s.whisker)?;
204            // box
205            backend.draw_rect((cx - half, by1), (cx + half, by2), &s.box_fill, true)?;
206            backend.draw_rect((cx - half, by1), (cx + half, by2), &s.box_border, false)?;
207            // median
208            backend.draw_line((cx - half, median.1), (cx + half, median.1), &s.median)?;
209        }
210
211        // Outliers (any points beyond the five box points).
212        for o in &pix[N_BOX_POINTS..] {
213            backend.draw_circle(*o, s.outlier_radius, &s.outlier, s.outlier.filled)?;
214        }
215        Ok(())
216    }
217}
218
219/// A group of boxes laid out side by side — one per named sample — since real
220/// use cases (one box per class/feature) always need several boxes on one
221/// chart, not a single isolated box.
222///
223/// Implements [`IntoIterator`], so it drops straight into `draw_series`.
224#[derive(Debug, Clone)]
225pub struct BoxPlotSeries<C> {
226    boxes: Vec<BoxPlot<C>>,
227}
228
229impl<X: Clone> BoxPlotSeries<(X, f64)> {
230    /// Build a vertical multi-box series from `(position, sample)` pairs. Errors
231    /// if any sample has no finite values.
232    pub fn from_samples<I, S>(groups: I) -> Result<Self, StatsError>
233    where
234        I: IntoIterator<Item = (X, S)>,
235        S: AsRef<[f64]>,
236    {
237        let boxes = groups
238            .into_iter()
239            .map(|(x, s)| BoxPlot::vertical(x, s.as_ref()))
240            .collect::<Result<Vec<_>, _>>()?;
241        Ok(Self { boxes })
242    }
243}
244
245impl<Y: Clone> BoxPlotSeries<(f64, Y)> {
246    /// Build a horizontal multi-box series from `(position, sample)` pairs.
247    pub fn horizontal_from_samples<I, S>(groups: I) -> Result<Self, StatsError>
248    where
249        I: IntoIterator<Item = (Y, S)>,
250        S: AsRef<[f64]>,
251    {
252        let boxes = groups
253            .into_iter()
254            .map(|(y, s)| BoxPlot::horizontal(y, s.as_ref()))
255            .collect::<Result<Vec<_>, _>>()?;
256        Ok(Self { boxes })
257    }
258}
259
260impl<C> BoxPlotSeries<C> {
261    /// Apply a common width (pixels) to every box in the group.
262    pub fn width(mut self, width: u32) -> Self {
263        self.boxes = self.boxes.into_iter().map(|b| b.width(width)).collect();
264        self
265    }
266
267    /// Apply a common style to every box in the group.
268    pub fn style(mut self, style: BoxStyle) -> Self {
269        self.boxes = self
270            .boxes
271            .into_iter()
272            .map(|b| b.style(style.clone()))
273            .collect();
274        self
275    }
276}
277
278impl<C> IntoIterator for BoxPlotSeries<C> {
279    type Item = BoxPlot<C>;
280    type IntoIter = std::vec::IntoIter<BoxPlot<C>>;
281    fn into_iter(self) -> Self::IntoIter {
282        self.boxes.into_iter()
283    }
284}