Skip to main content

esoc_chart/chart/
boxplot.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Box plot series with quartile/whisker computation.
3
4use esoc_gfx::canvas::Canvas;
5use esoc_gfx::element::{DrawElement, Element};
6use esoc_gfx::geom::Rect;
7use esoc_gfx::layer::Layer;
8use esoc_gfx::style::{Fill, Stroke};
9use esoc_gfx::transform::CoordinateTransform;
10
11use crate::series::{DataBounds, SeriesRenderer};
12use crate::theme::Theme;
13
14/// A box plot series showing distribution statistics.
15#[derive(Clone, Debug)]
16pub struct BoxPlotSeries {
17    /// Datasets to plot (one box per dataset).
18    pub datasets: Vec<Vec<f64>>,
19    /// Optional series label.
20    pub label: Option<String>,
21    /// Optional category labels.
22    pub labels: Option<Vec<String>>,
23}
24
25/// Computed statistics for a single box.
26#[derive(Clone, Debug)]
27#[allow(dead_code)]
28struct BoxStats {
29    min: f64,
30    q1: f64,
31    median: f64,
32    q3: f64,
33    max: f64,
34    whisker_lo: f64,
35    whisker_hi: f64,
36}
37
38impl BoxPlotSeries {
39    /// Create a new box plot series.
40    pub fn new(datasets: Vec<Vec<f64>>) -> Self {
41        Self {
42            datasets,
43            label: None,
44            labels: None,
45        }
46    }
47
48    fn compute_stats(data: &[f64]) -> Option<BoxStats> {
49        if data.is_empty() {
50            return None;
51        }
52        let mut sorted = data.to_vec();
53        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
54
55        let min = sorted[0];
56        let max = sorted[sorted.len() - 1];
57        let q1 = percentile(&sorted, 25.0);
58        let median = percentile(&sorted, 50.0);
59        let q3 = percentile(&sorted, 75.0);
60        let iqr = q3 - q1;
61
62        // Whiskers at 1.5 * IQR or data extent
63        let whisker_lo = sorted
64            .iter()
65            .copied()
66            .find(|&v| v >= q1 - 1.5 * iqr)
67            .unwrap_or(min);
68        let whisker_hi = sorted
69            .iter()
70            .rev()
71            .copied()
72            .find(|&v| v <= q3 + 1.5 * iqr)
73            .unwrap_or(max);
74
75        Some(BoxStats {
76            min,
77            q1,
78            median,
79            q3,
80            max,
81            whisker_lo,
82            whisker_hi,
83        })
84    }
85}
86
87impl SeriesRenderer for BoxPlotSeries {
88    fn data_bounds(&self) -> DataBounds {
89        let n = self.datasets.len();
90        let y_min = self
91            .datasets
92            .iter()
93            .flat_map(|d| d.iter().copied())
94            .fold(f64::INFINITY, f64::min);
95        let y_max = self
96            .datasets
97            .iter()
98            .flat_map(|d| d.iter().copied())
99            .fold(f64::NEG_INFINITY, f64::max);
100
101        DataBounds::new(-0.5, n as f64 - 0.5, y_min, y_max)
102    }
103
104    fn render(
105        &self,
106        canvas: &mut Canvas,
107        transform: &CoordinateTransform,
108        theme: &Theme,
109        series_index: usize,
110    ) {
111        let box_width = 0.6;
112        let color = theme.palette.get(series_index);
113
114        for (i, dataset) in self.datasets.iter().enumerate() {
115            let Some(stats) = Self::compute_stats(dataset) else {
116                continue;
117            };
118
119            let x = i as f64;
120            let half_w = box_width / 2.0;
121
122            // Box (Q1 to Q3)
123            let p_tl = transform.to_pixel(x - half_w, stats.q3);
124            let p_br = transform.to_pixel(x + half_w, stats.q1);
125            let rx = p_tl.x.min(p_br.x);
126            let ry = p_tl.y.min(p_br.y);
127            let rw = (p_br.x - p_tl.x).abs();
128            let rh = (p_br.y - p_tl.y).abs();
129            canvas.add(DrawElement::new(
130                Element::Rect {
131                    rect: Rect::new(rx, ry, rw, rh),
132                    fill: Fill::Solid(color.with_alpha(0.3)),
133                    stroke: Some(Stroke::solid(color, 1.5)),
134                    rx: 0.0,
135                },
136                Layer::Data,
137            ));
138
139            // Median line
140            let p_ml = transform.to_pixel(x - half_w, stats.median);
141            let p_mr = transform.to_pixel(x + half_w, stats.median);
142            canvas.add(DrawElement::line(
143                p_ml.x,
144                p_ml.y,
145                p_mr.x,
146                p_mr.y,
147                Stroke::solid(color, 2.0),
148                Layer::Data,
149            ));
150
151            // Whiskers
152            let p_wl_top = transform.to_pixel(x, stats.whisker_hi);
153            let p_wl_q3 = transform.to_pixel(x, stats.q3);
154            canvas.add(DrawElement::line(
155                p_wl_top.x,
156                p_wl_top.y,
157                p_wl_q3.x,
158                p_wl_q3.y,
159                Stroke::solid(color, 1.0),
160                Layer::Data,
161            ));
162
163            let p_wl_bot = transform.to_pixel(x, stats.whisker_lo);
164            let p_wl_q1 = transform.to_pixel(x, stats.q1);
165            canvas.add(DrawElement::line(
166                p_wl_bot.x,
167                p_wl_bot.y,
168                p_wl_q1.x,
169                p_wl_q1.y,
170                Stroke::solid(color, 1.0),
171                Layer::Data,
172            ));
173
174            // Whisker caps
175            let cap_w = half_w * 0.5;
176            let p_cap_hi_l = transform.to_pixel(x - cap_w, stats.whisker_hi);
177            let p_cap_hi_r = transform.to_pixel(x + cap_w, stats.whisker_hi);
178            canvas.add(DrawElement::line(
179                p_cap_hi_l.x,
180                p_cap_hi_l.y,
181                p_cap_hi_r.x,
182                p_cap_hi_r.y,
183                Stroke::solid(color, 1.0),
184                Layer::Data,
185            ));
186
187            let p_cap_lo_l = transform.to_pixel(x - cap_w, stats.whisker_lo);
188            let p_cap_lo_r = transform.to_pixel(x + cap_w, stats.whisker_lo);
189            canvas.add(DrawElement::line(
190                p_cap_lo_l.x,
191                p_cap_lo_l.y,
192                p_cap_lo_r.x,
193                p_cap_lo_r.y,
194                Stroke::solid(color, 1.0),
195                Layer::Data,
196            ));
197        }
198    }
199
200    fn label(&self) -> Option<&str> {
201        self.label.as_deref()
202    }
203}
204
205fn percentile(sorted: &[f64], p: f64) -> f64 {
206    if sorted.is_empty() {
207        return 0.0;
208    }
209    let idx = (p / 100.0 * (sorted.len() - 1) as f64).clamp(0.0, (sorted.len() - 1) as f64);
210    let lo = idx.floor() as usize;
211    let hi = idx.ceil() as usize;
212    let frac = idx - lo as f64;
213    sorted[lo] * (1.0 - frac) + sorted[hi] * frac
214}