Skip to main content

facett_plotters/
boxplot.rs

1//! **Box plot** — quartiles + whiskers + outliers, one box per named group.
2//! The data→geometry math ([`Quartiles::from_sorted`] / [`Quartiles::compute`])
3//! is a pure function with its own tests; [`BoxPlotView`] is the themeable
4//! [`Facet`] that paints it and emits the underlying data as CSV/JSON.
5
6use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
7use facett_core::clip::{ClipKind, ClipPayload, CopySource};
8use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
9
10/// Five-number summary + the 1.5·IQR whisker fences and the points beyond them.
11#[derive(Clone, Debug, PartialEq)]
12pub struct Quartiles {
13    pub min: f64,
14    pub q1: f64,
15    pub median: f64,
16    pub q3: f64,
17    pub max: f64,
18    /// Lower whisker end: smallest datum ≥ `q1 - 1.5·IQR`.
19    pub lower_fence: f64,
20    /// Upper whisker end: largest datum ≤ `q3 + 1.5·IQR`.
21    pub upper_fence: f64,
22    /// Points outside the fences.
23    pub outliers: Vec<f64>,
24}
25
26impl Quartiles {
27    /// Compute from an arbitrary (unsorted) sample. Empty input → `None`.
28    pub fn compute(values: &[f64]) -> Option<Self> {
29        if values.is_empty() {
30            return None;
31        }
32        let mut v: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
33        if v.is_empty() {
34            return None;
35        }
36        v.sort_by(|a, b| a.partial_cmp(b).unwrap());
37        Some(Self::from_sorted(&v))
38    }
39
40    /// Compute from an already-sorted, finite, non-empty slice. Uses linear
41    /// interpolation between order statistics (the "type-7"/`numpy` default), so
42    /// it matches what analysts expect from Plotly/pandas.
43    pub fn from_sorted(v: &[f64]) -> Self {
44        debug_assert!(!v.is_empty());
45        let pct = |p: f64| percentile_sorted(v, p);
46        let q1 = pct(0.25);
47        let median = pct(0.5);
48        let q3 = pct(0.75);
49        let iqr = q3 - q1;
50        let lo = q1 - 1.5 * iqr;
51        let hi = q3 + 1.5 * iqr;
52        // Whisker ends snap to the most extreme data points still inside the fence.
53        let lower_fence = *v.iter().find(|&&x| x >= lo).unwrap_or(&v[0]);
54        let upper_fence = *v.iter().rev().find(|&&x| x <= hi).unwrap_or(v.last().unwrap());
55        let outliers: Vec<f64> = v.iter().copied().filter(|&x| x < lo || x > hi).collect();
56        Self {
57            min: v[0],
58            q1,
59            median,
60            q3,
61            max: *v.last().unwrap(),
62            lower_fence,
63            upper_fence,
64            outliers,
65        }
66    }
67}
68
69/// Linear-interpolated percentile of a sorted slice. `p ∈ 0..=1`.
70pub fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
71    let n = sorted.len();
72    if n == 1 {
73        return sorted[0];
74    }
75    let rank = p.clamp(0.0, 1.0) * (n as f64 - 1.0);
76    let lo = rank.floor() as usize;
77    let hi = rank.ceil() as usize;
78    let frac = rank - lo as f64;
79    sorted[lo] + (sorted[hi] - sorted[lo]) * frac
80}
81
82/// A box plot: named groups of raw samples.
83pub struct BoxPlotView {
84    pub title: String,
85    /// `(group label, raw values)`.
86    pub groups: Vec<(String, Vec<f64>)>,
87}
88
89impl BoxPlotView {
90    pub fn new(title: impl Into<String>, groups: Vec<(String, Vec<f64>)>) -> Self {
91        Self { title: title.into(), groups }
92    }
93
94    /// Per-group quartiles (skipping empty groups), paired with the label.
95    pub fn summaries(&self) -> Vec<(String, Quartiles)> {
96        self.groups
97            .iter()
98            .filter_map(|(name, vals)| Quartiles::compute(vals).map(|q| (name.clone(), q)))
99            .collect()
100    }
101
102    /// Global value range across every group (for the shared y-axis).
103    fn value_range(&self) -> Option<(f64, f64)> {
104        let mut r: Option<(f64, f64)> = None;
105        for (_, vals) in &self.groups {
106            for &x in vals {
107                if x.is_finite() {
108                    r = Some(match r {
109                        None => (x, x),
110                        Some((lo, hi)) => (lo.min(x), hi.max(x)),
111                    });
112                }
113            }
114        }
115        r
116    }
117
118    /// CSV of the five-number summary per group (the `copy()` payload).
119    fn to_csv(&self) -> String {
120        let mut s = String::from("group,min,q1,median,q3,max,outliers\n");
121        for (name, q) in self.summaries() {
122            s.push_str(&format!(
123                "{},{},{},{},{},{},{}\n",
124                name, q.min, q.q1, q.median, q.q3, q.max, q.outliers.len()
125            ));
126        }
127        s
128    }
129}
130
131// ── typed copy (§16) — the chart dataset as CSV Text ──────────────────
132impl CopySource for BoxPlotView {
133    fn copy_kinds(&self) -> &[ClipKind] {
134        &[ClipKind::Text]
135    }
136    fn copy_payload(&self) -> Option<ClipPayload> {
137        let csv = self.to_csv();
138        (!csv.is_empty()).then_some(ClipPayload::Text(csv))
139    }
140}
141
142impl Facet for BoxPlotView {
143    fn title(&self) -> &str {
144        &self.title
145    }
146    fn caps(&self) -> FacetCaps {
147        FacetCaps::NONE.themeable().copyable().resizable()
148    }
149    fn copy(&mut self) -> Option<String> {
150        Some(self.to_csv())
151    }
152    fn ui(&mut self, ui: &mut Ui) {
153        let th = theme(ui);
154        let summaries = self.summaries();
155        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
156        let painter = ui.painter_at(rect);
157        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
158        let Some((vmin, vmax)) = self.value_range() else {
159            // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
160            #[cfg(feature = "testmatrix")]
161            facett_core::testmatrix::emit(
162                "facett-plotters::BoxPlotView::ui",
163                "ui_render",
164                summaries.is_empty(),
165                &format!("drew=empty_hint groups={} summaries={}", self.groups.len(), summaries.len()),
166            );
167            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
168            return;
169        };
170        if summaries.is_empty() {
171            // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
172            #[cfg(feature = "testmatrix")]
173            facett_core::testmatrix::emit(
174                "facett-plotters::BoxPlotView::ui",
175                "ui_render",
176                summaries.is_empty(),
177                &format!("drew=empty_hint groups={} summaries={}", self.groups.len(), summaries.len()),
178            );
179            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
180            return;
181        }
182        let plot = rect.shrink2(vec2(28.0, 24.0));
183        let span = (vmax - vmin).max(1e-9);
184        let y_of = |v: f64| plot.bottom() - ((v - vmin) / span) as f32 * plot.height();
185
186        let n = summaries.len();
187        let slot = plot.width() / n as f32;
188        let box_w = (slot * 0.5).min(60.0);
189        let hover = resp.hover_pos();
190        let mut tip: Option<(Pos2, String)> = None;
191        let base = ui.id().with("boxplot");
192
193        for (i, (name, q)) in summaries.iter().enumerate() {
194            let cx = plot.left() + slot * (i as f32 + 0.5);
195            let color = facett_core::hash_color(name);
196            let stroke = Stroke::new(1.5, th.accent);
197
198            // Whiskers.
199            painter.line_segment([Pos2::new(cx, y_of(q.lower_fence)), Pos2::new(cx, y_of(q.q1))], Stroke::new(1.0, th.text_dim));
200            painter.line_segment([Pos2::new(cx, y_of(q.q3)), Pos2::new(cx, y_of(q.upper_fence))], Stroke::new(1.0, th.text_dim));
201            // Whisker caps.
202            let cap = box_w * 0.3;
203            for &v in &[q.lower_fence, q.upper_fence] {
204                painter.line_segment([Pos2::new(cx - cap, y_of(v)), Pos2::new(cx + cap, y_of(v))], Stroke::new(1.0, th.text_dim));
205            }
206            // The box (q1..q3).
207            let box_rect = Rect::from_min_max(Pos2::new(cx - box_w * 0.5, y_of(q.q3)), Pos2::new(cx + box_w * 0.5, y_of(q.q1)));
208            painter.rect_filled(box_rect, egui::CornerRadius::same(2), color.gamma_multiply(0.55));
209            painter.rect_stroke(box_rect, egui::CornerRadius::same(2), stroke, egui::StrokeKind::Middle);
210            // AccessKit node over the box body (FC-4): role=Image, label = the
211            // group name, numeric_value = the median (the box's headline stat).
212            // Keyed on the group name (stable across reorder, FC-5).
213            a11y_node(
214                ui,
215                base,
216                name,
217                Sense::hover(),
218                box_rect,
219                Semantics::image(name.clone()).value(q.median),
220            );
221            // Median line.
222            painter.line_segment(
223                [Pos2::new(box_rect.left(), y_of(q.median)), Pos2::new(box_rect.right(), y_of(q.median))],
224                Stroke::new(2.0, th.glow),
225            );
226            // Outliers.
227            for &o in &q.outliers {
228                painter.circle_stroke(Pos2::new(cx, y_of(o)), 2.5, Stroke::new(1.0, th.point));
229            }
230            // Group label.
231            painter.text(Pos2::new(cx, plot.bottom() + 12.0), Align2::CENTER_CENTER, name, FontId::proportional(11.0), th.text);
232
233            // Hover readout when the cursor is over this slot.
234            if let Some(hp) = hover {
235                if hp.x >= cx - slot * 0.5 && hp.x < cx + slot * 0.5 && plot.y_range().contains(hp.y) {
236                    tip = Some((
237                        hp,
238                        format!(
239                            "{name}\nmax {:.3}\nq3  {:.3}\nmed {:.3}\nq1  {:.3}\nmin {:.3}\noutliers {}",
240                            q.max, q.q3, q.median, q.q1, q.min, q.outliers.len()
241                        ),
242                    ));
243                }
244            }
245        }
246
247        if let Some((at, text)) = tip {
248            paint_tooltip(&painter, &th, at, &text);
249        }
250        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
251        #[cfg(feature = "testmatrix")]
252        facett_core::testmatrix::emit(
253            "facett-plotters::BoxPlotView::ui",
254            "ui_render",
255            !summaries.is_empty() && summaries.len() <= self.groups.len(),
256            &format!("groups={} boxes={}", self.groups.len(), summaries.len()),
257        );
258    }
259    fn state_json(&self) -> serde_json::Value {
260        serde_json::json!({
261            "kind": "boxplot",
262            "groups": self.summaries().iter().map(|(n, q)| serde_json::json!({
263                "name": n, "min": q.min, "q1": q.q1, "median": q.median,
264                "q3": q.q3, "max": q.max, "outliers": q.outliers.len(),
265            })).collect::<Vec<_>>(),
266        })
267    }
268}
269
270/// Shared themed tooltip box used by the chart facets.
271pub(crate) fn paint_tooltip(painter: &egui::Painter, th: &facett_core::Theme, at: Pos2, text: &str) {
272    let font = FontId::proportional(11.0);
273    let galley = painter.layout(text.to_string(), font, th.text, 240.0);
274    let pad = vec2(6.0, 4.0);
275    let pos = at + vec2(12.0, 12.0);
276    let bg = Rect::from_min_size(pos, galley.size() + pad * 2.0);
277    painter.rect_filled(bg, egui::CornerRadius::same(3), th.panel_bg);
278    painter.rect_stroke(bg, egui::CornerRadius::same(3), Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Middle);
279    painter.galley(pos + pad, galley, Color32::WHITE);
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn typed_copy_is_the_dataset_csv() {
288        use facett_core::clip::{ClipKind, CopySource};
289        let v = BoxPlotView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0])]);
290        let p = v.copy_payload().expect("groups copy");
291        assert_eq!(p.kind(), ClipKind::Text);
292        assert_eq!(p.as_text(), v.to_csv());
293    }
294
295    #[test]
296    fn percentile_matches_numpy_type7() {
297        // numpy.percentile([1..=10], [25,50,75]) = 3.25, 5.5, 7.75
298        let v: Vec<f64> = (1..=10).map(|x| x as f64).collect();
299        assert!((percentile_sorted(&v, 0.25) - 3.25).abs() < 1e-9);
300        assert!((percentile_sorted(&v, 0.50) - 5.5).abs() < 1e-9);
301        assert!((percentile_sorted(&v, 0.75) - 7.75).abs() < 1e-9);
302    }
303
304    #[test]
305    fn quartiles_basic_no_outliers() {
306        let q = Quartiles::compute(&[1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
307        assert_eq!(q.min, 1.0);
308        assert_eq!(q.max, 5.0);
309        assert_eq!(q.median, 3.0);
310        assert_eq!(q.q1, 2.0);
311        assert_eq!(q.q3, 4.0);
312        assert!(q.outliers.is_empty());
313        // fences cover the whole range when there are no outliers
314        assert_eq!(q.lower_fence, 1.0);
315        assert_eq!(q.upper_fence, 5.0);
316    }
317
318    #[test]
319    fn quartiles_detects_high_outlier() {
320        let mut data: Vec<f64> = (1..=10).map(|x| x as f64).collect();
321        data.push(100.0); // far above q3 + 1.5 IQR
322        let q = Quartiles::compute(&data).unwrap();
323        assert!(q.outliers.contains(&100.0), "100 must be flagged as an outlier");
324        assert!(q.upper_fence <= 10.0, "upper whisker snaps to in-fence data");
325    }
326
327    #[test]
328    fn quartiles_empty_and_nonfinite() {
329        assert!(Quartiles::compute(&[]).is_none());
330        assert!(Quartiles::compute(&[f64::NAN, f64::INFINITY]).is_none());
331    }
332
333    #[test]
334    fn single_value() {
335        let q = Quartiles::compute(&[7.0]).unwrap();
336        assert_eq!(q.median, 7.0);
337        assert_eq!(q.q1, 7.0);
338        assert_eq!(q.q3, 7.0);
339        assert!(q.outliers.is_empty());
340    }
341
342    #[test]
343    fn csv_has_header_and_row_per_group() {
344        let v = BoxPlotView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0]), ("b".into(), vec![4.0, 5.0, 6.0])]);
345        let csv = v.to_csv();
346        assert!(csv.starts_with("group,min,q1,median,q3,max,outliers\n"));
347        assert_eq!(csv.lines().count(), 3); // header + 2 groups
348    }
349}