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::{Elm, FacetCaps, Semantics, a11y_node, theme};
9use serde::{Deserialize, Serialize};
10
11/// Five-number summary + the 1.5·IQR whisker fences and the points beyond them.
12#[derive(Clone, Debug, PartialEq)]
13pub struct Quartiles {
14    pub min: f64,
15    pub q1: f64,
16    pub median: f64,
17    pub q3: f64,
18    pub max: f64,
19    /// Lower whisker end: smallest datum ≥ `q1 - 1.5·IQR`.
20    pub lower_fence: f64,
21    /// Upper whisker end: largest datum ≤ `q3 + 1.5·IQR`.
22    pub upper_fence: f64,
23    /// Points outside the fences.
24    pub outliers: Vec<f64>,
25}
26
27impl Quartiles {
28    /// Compute from an arbitrary (unsorted) sample. Empty input → `None`.
29    pub fn compute(values: &[f64]) -> Option<Self> {
30        if values.is_empty() {
31            return None;
32        }
33        let mut v: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
34        if v.is_empty() {
35            return None;
36        }
37        v.sort_by(|a, b| a.partial_cmp(b).unwrap());
38        Some(Self::from_sorted(&v))
39    }
40
41    /// Compute from an already-sorted, finite, non-empty slice. Uses linear
42    /// interpolation between order statistics (the "type-7"/`numpy` default), so
43    /// it matches what analysts expect from Plotly/pandas.
44    pub fn from_sorted(v: &[f64]) -> Self {
45        debug_assert!(!v.is_empty());
46        let pct = |p: f64| percentile_sorted(v, p);
47        let q1 = pct(0.25);
48        let median = pct(0.5);
49        let q3 = pct(0.75);
50        let iqr = q3 - q1;
51        let lo = q1 - 1.5 * iqr;
52        let hi = q3 + 1.5 * iqr;
53        // Whisker ends snap to the most extreme data points still inside the fence.
54        let lower_fence = *v.iter().find(|&&x| x >= lo).unwrap_or(&v[0]);
55        let upper_fence = *v.iter().rev().find(|&&x| x <= hi).unwrap_or(v.last().unwrap());
56        let outliers: Vec<f64> = v.iter().copied().filter(|&x| x < lo || x > hi).collect();
57        Self {
58            min: v[0],
59            q1,
60            median,
61            q3,
62            max: *v.last().unwrap(),
63            lower_fence,
64            upper_fence,
65            outliers,
66        }
67    }
68}
69
70/// Linear-interpolated percentile of a sorted slice. `p ∈ 0..=1`.
71pub fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
72    let n = sorted.len();
73    if n == 1 {
74        return sorted[0];
75    }
76    let rank = p.clamp(0.0, 1.0) * (n as f64 - 1.0);
77    let lo = rank.floor() as usize;
78    let hi = rank.ceil() as usize;
79    let frac = rank - lo as f64;
80    sorted[lo] + (sorted[hi] - sorted[lo]) * frac
81}
82
83/// Persistent, observable interaction state (FC-3). The box plot's only mutable
84/// state is which group box the pointer last hovered — an index into
85/// [`BoxPlotView::summaries`]. Serialized so it round-trips headlessly.
86#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
87pub struct BoxPlotState {
88    /// Index of the hovered box (into `summaries()`), or `None`.
89    pub hover: Option<usize>,
90}
91
92/// FC-2 input surface. The box plot accepts one kind of input: the hovered box
93/// changing (hit-tested *purely* in [`BoxPlotView::render`], applied in
94/// [`BoxPlotView::update`]).
95#[derive(Clone, Debug, PartialEq)]
96pub enum BoxPlotMsg {
97    /// Set (or clear, with `None`) the hovered box index.
98    Hover(Option<usize>),
99}
100
101/// FC-8: the box plot does no I/O — an uninhabited effect.
102#[derive(Clone, Debug)]
103pub enum BoxPlotEffect {}
104
105/// A box plot: named groups of raw samples.
106pub struct BoxPlotView {
107    pub title: String,
108    /// `(group label, raw values)`.
109    pub groups: Vec<(String, Vec<f64>)>,
110    /// Observable interaction state (FC-3).
111    pub state: BoxPlotState,
112}
113
114impl BoxPlotView {
115    pub fn new(title: impl Into<String>, groups: Vec<(String, Vec<f64>)>) -> Self {
116        Self { title: title.into(), groups, state: BoxPlotState::default() }
117    }
118
119    /// FC-3: read the complete observable interaction state.
120    pub fn state(&self) -> &BoxPlotState {
121        &self.state
122    }
123
124    /// FC-2/FC-8: the single mutation path. Apply one [`BoxPlotMsg`]; no effects.
125    pub fn update(&mut self, msg: BoxPlotMsg) -> Vec<BoxPlotEffect> {
126        match msg {
127            BoxPlotMsg::Hover(h) => self.state.hover = h,
128        }
129        Vec::new()
130    }
131
132    /// Per-group quartiles (skipping empty groups), paired with the label.
133    pub fn summaries(&self) -> Vec<(String, Quartiles)> {
134        self.groups
135            .iter()
136            .filter_map(|(name, vals)| Quartiles::compute(vals).map(|q| (name.clone(), q)))
137            .collect()
138    }
139
140    /// Global value range across every group (for the shared y-axis).
141    fn value_range(&self) -> Option<(f64, f64)> {
142        let mut r: Option<(f64, f64)> = None;
143        for (_, vals) in &self.groups {
144            for &x in vals {
145                if x.is_finite() {
146                    r = Some(match r {
147                        None => (x, x),
148                        Some((lo, hi)) => (lo.min(x), hi.max(x)),
149                    });
150                }
151            }
152        }
153        r
154    }
155
156    /// CSV of the five-number summary per group (the `copy()` payload).
157    fn to_csv(&self) -> String {
158        let mut s = String::from("group,min,q1,median,q3,max,outliers\n");
159        for (name, q) in self.summaries() {
160            s.push_str(&format!(
161                "{},{},{},{},{},{},{}\n",
162                name, q.min, q.q1, q.median, q.q3, q.max, q.outliers.len()
163            ));
164        }
165        s
166    }
167}
168
169// ── typed copy (§16) — the chart dataset as CSV Text ──────────────────
170impl CopySource for BoxPlotView {
171    fn copy_kinds(&self) -> &[ClipKind] {
172        &[ClipKind::Text]
173    }
174    fn copy_payload(&self) -> Option<ClipPayload> {
175        let csv = self.to_csv();
176        (!csv.is_empty()).then_some(ClipPayload::Text(csv))
177    }
178}
179
180impl BoxPlotView {
181    /// FC-9 pure view: paints the boxes + hover tooltip (from the *stored* hover)
182    /// and **returns** the hovered-box change as a `Msg` — it never mutates `self`.
183    fn render(&self, ui: &mut Ui) -> Vec<BoxPlotMsg> {
184        let th = theme(ui);
185        let summaries = self.summaries();
186        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
187        let painter = ui.painter_at(rect);
188        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
189        let Some((vmin, vmax)) = self.value_range() else {
190            // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
191            #[cfg(feature = "testmatrix")]
192            facett_core::testmatrix::emit(
193                "facett-plotters::BoxPlotView::ui",
194                "ui_render",
195                summaries.is_empty(),
196                &format!("drew=empty_hint groups={} summaries={}", self.groups.len(), summaries.len()),
197            );
198            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
199            return hover_msg(self.state.hover, None);
200        };
201        if summaries.is_empty() {
202            // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
203            #[cfg(feature = "testmatrix")]
204            facett_core::testmatrix::emit(
205                "facett-plotters::BoxPlotView::ui",
206                "ui_render",
207                summaries.is_empty(),
208                &format!("drew=empty_hint groups={} summaries={}", self.groups.len(), summaries.len()),
209            );
210            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
211            return hover_msg(self.state.hover, None);
212        }
213        let plot = rect.shrink2(vec2(28.0, 24.0));
214        let span = (vmax - vmin).max(1e-9);
215        let y_of = |v: f64| plot.bottom() - ((v - vmin) / span) as f32 * plot.height();
216
217        let n = summaries.len();
218        let slot = plot.width() / n as f32;
219        let box_w = (slot * 0.5).min(60.0);
220        let hover = resp.hover_pos();
221        let mut new_hover: Option<usize> = None;
222        let base = ui.id().with("boxplot");
223
224        for (i, (name, q)) in summaries.iter().enumerate() {
225            let cx = plot.left() + slot * (i as f32 + 0.5);
226            let color = facett_core::hash_color(name);
227            let stroke = Stroke::new(1.5_f32, th.accent);
228
229            // Whiskers.
230            painter.line_segment([Pos2::new(cx, y_of(q.lower_fence)), Pos2::new(cx, y_of(q.q1))], Stroke::new(1.0_f32, th.text_dim));
231            painter.line_segment([Pos2::new(cx, y_of(q.q3)), Pos2::new(cx, y_of(q.upper_fence))], Stroke::new(1.0_f32, th.text_dim));
232            // Whisker caps.
233            let cap = box_w * 0.3;
234            for &v in &[q.lower_fence, q.upper_fence] {
235                painter.line_segment([Pos2::new(cx - cap, y_of(v)), Pos2::new(cx + cap, y_of(v))], Stroke::new(1.0_f32, th.text_dim));
236            }
237            // The box (q1..q3).
238            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)));
239            painter.rect_filled(box_rect, egui::CornerRadius::same(2), color.gamma_multiply(0.55));
240            painter.rect_stroke(box_rect, egui::CornerRadius::same(2), stroke, egui::StrokeKind::Middle);
241            // AccessKit node over the box body (FC-4): role=Image, label = the
242            // group name, numeric_value = the median (the box's headline stat).
243            // Keyed on the group name (stable across reorder, FC-5).
244            a11y_node(
245                ui,
246                base,
247                name,
248                Sense::hover(),
249                box_rect,
250                Semantics::image(name.clone()).value(q.median),
251            );
252            // Median line.
253            painter.line_segment(
254                [Pos2::new(box_rect.left(), y_of(q.median)), Pos2::new(box_rect.right(), y_of(q.median))],
255                Stroke::new(2.0_f32, th.glow),
256            );
257            // Outliers.
258            for &o in &q.outliers {
259                painter.circle_stroke(Pos2::new(cx, y_of(o)), 2.5, Stroke::new(1.0_f32, th.point));
260            }
261            // Group label.
262            painter.text(Pos2::new(cx, plot.bottom() + 12.0), Align2::CENTER_CENTER, name, FontId::proportional(11.0), th.text);
263
264            // Pure hit-test: which slot is the cursor over this frame.
265            if let Some(hp) = hover
266                && hp.x >= cx - slot * 0.5 && hp.x < cx + slot * 0.5 && plot.y_range().contains(hp.y)
267            {
268                new_hover = Some(i);
269            }
270        }
271
272        // Tooltip is painted from the *persisted* hover (set by `update` last frame),
273        // keeping `render` a pure function of `&self`.
274        if let Some(hp) = hover && let Some((name, q)) = self.state.hover.and_then(|i| summaries.get(i)) {
275            paint_tooltip(
276                &painter,
277                &th,
278                hp,
279                &format!(
280                    "{name}\nmax {:.3}\nq3  {:.3}\nmed {:.3}\nq1  {:.3}\nmin {:.3}\noutliers {}",
281                    q.max, q.q3, q.median, q.q1, q.min, q.outliers.len()
282                ),
283            );
284        }
285        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
286        #[cfg(feature = "testmatrix")]
287        facett_core::testmatrix::emit(
288            "facett-plotters::BoxPlotView::ui",
289            "ui_render",
290            !summaries.is_empty() && summaries.len() <= self.groups.len(),
291            &format!("groups={} boxes={}", self.groups.len(), summaries.len()),
292        );
293        hover_msg(self.state.hover, new_hover)
294    }
295}
296
297/// Emit a `Hover` message only when the hovered box actually changed (keeps the
298/// FC-9 loop from re-applying an identical hover every frame).
299fn hover_msg(current: Option<usize>, next: Option<usize>) -> Vec<BoxPlotMsg> {
300    if current != next { vec![BoxPlotMsg::Hover(next)] } else { Vec::new() }
301}
302
303impl Elm for BoxPlotView {
304    type Model = BoxPlotState;
305    type Msg = BoxPlotMsg;
306    type Effect = BoxPlotEffect;
307
308    fn title(&self) -> &str {
309        &self.title
310    }
311    fn state(&self) -> &BoxPlotState {
312        &self.state
313    }
314    fn update(&mut self, msg: BoxPlotMsg) -> Vec<BoxPlotEffect> {
315        BoxPlotView::update(self, msg)
316    }
317    fn view(&self, ui: &mut Ui) -> Vec<BoxPlotMsg> {
318        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
319        self.render(ui)
320    }
321}
322
323// Form 3 (`custom_state_json`): this view publishes a **richer** `state_json`
324// (the derived five-number summary per group) than a plain `serde(state())`, so
325// the macro's default is suppressed and the exact existing JSON is preserved.
326facett_core::impl_facet_via_elm!(BoxPlotView, custom_state_json, {
327    fn caps(&self) -> FacetCaps {
328        FacetCaps::NONE.themeable().copyable().resizable()
329    }
330    fn copy(&mut self) -> Option<String> {
331        Some(self.to_csv())
332    }
333    fn state_json(&self) -> serde_json::Value {
334        serde_json::json!({
335            "kind": "boxplot",
336            "groups": self.summaries().iter().map(|(n, q)| serde_json::json!({
337                "name": n, "min": q.min, "q1": q.q1, "median": q.median,
338                "q3": q.q3, "max": q.max, "outliers": q.outliers.len(),
339            })).collect::<Vec<_>>(),
340        })
341    }
342});
343
344/// Shared themed tooltip box used by the chart facets.
345pub(crate) fn paint_tooltip(painter: &egui::Painter, th: &facett_core::Theme, at: Pos2, text: &str) {
346    let font = FontId::proportional(11.0);
347    let galley = painter.layout(text.to_string(), font, th.text, 240.0);
348    let pad = vec2(6.0, 4.0);
349    let pos = at + vec2(12.0, 12.0);
350    let bg = Rect::from_min_size(pos, galley.size() + pad * 2.0);
351    painter.rect_filled(bg, egui::CornerRadius::same(3), th.panel_bg);
352    painter.rect_stroke(bg, egui::CornerRadius::same(3), Stroke::new(1.0_f32, th.panel_stroke), egui::StrokeKind::Middle);
353    painter.galley(pos + pad, galley, Color32::WHITE);
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn typed_copy_is_the_dataset_csv() {
362        use facett_core::clip::{ClipKind, CopySource};
363        let v = BoxPlotView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0])]);
364        let p = v.copy_payload().expect("groups copy");
365        assert_eq!(p.kind(), ClipKind::Text);
366        assert_eq!(p.as_text(), v.to_csv());
367    }
368
369    #[test]
370    fn percentile_matches_numpy_type7() {
371        // numpy.percentile([1..=10], [25,50,75]) = 3.25, 5.5, 7.75
372        let v: Vec<f64> = (1..=10).map(|x| x as f64).collect();
373        assert!((percentile_sorted(&v, 0.25) - 3.25).abs() < 1e-9);
374        assert!((percentile_sorted(&v, 0.50) - 5.5).abs() < 1e-9);
375        assert!((percentile_sorted(&v, 0.75) - 7.75).abs() < 1e-9);
376    }
377
378    #[test]
379    fn quartiles_basic_no_outliers() {
380        let q = Quartiles::compute(&[1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
381        assert_eq!(q.min, 1.0);
382        assert_eq!(q.max, 5.0);
383        assert_eq!(q.median, 3.0);
384        assert_eq!(q.q1, 2.0);
385        assert_eq!(q.q3, 4.0);
386        assert!(q.outliers.is_empty());
387        // fences cover the whole range when there are no outliers
388        assert_eq!(q.lower_fence, 1.0);
389        assert_eq!(q.upper_fence, 5.0);
390    }
391
392    #[test]
393    fn quartiles_detects_high_outlier() {
394        let mut data: Vec<f64> = (1..=10).map(|x| x as f64).collect();
395        data.push(100.0); // far above q3 + 1.5 IQR
396        let q = Quartiles::compute(&data).unwrap();
397        assert!(q.outliers.contains(&100.0), "100 must be flagged as an outlier");
398        assert!(q.upper_fence <= 10.0, "upper whisker snaps to in-fence data");
399    }
400
401    #[test]
402    fn quartiles_empty_and_nonfinite() {
403        assert!(Quartiles::compute(&[]).is_none());
404        assert!(Quartiles::compute(&[f64::NAN, f64::INFINITY]).is_none());
405    }
406
407    #[test]
408    fn single_value() {
409        let q = Quartiles::compute(&[7.0]).unwrap();
410        assert_eq!(q.median, 7.0);
411        assert_eq!(q.q1, 7.0);
412        assert_eq!(q.q3, 7.0);
413        assert!(q.outliers.is_empty());
414    }
415
416    #[test]
417    fn csv_has_header_and_row_per_group() {
418        let v = BoxPlotView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0]), ("b".into(), vec![4.0, 5.0, 6.0])]);
419        let csv = v.to_csv();
420        assert!(csv.starts_with("group,min,q1,median,q3,max,outliers\n"));
421        assert_eq!(csv.lines().count(), 3); // header + 2 groups
422    }
423
424    // ── Elm (FC-2/FC-3) harness properties ────────────────────────────────
425    #[test]
426    fn hover_transitions_are_observable() {
427        let mut v = BoxPlotView::new(
428            "t",
429            vec![("a".into(), vec![1.0, 2.0, 3.0]), ("b".into(), vec![4.0, 5.0, 6.0])],
430        );
431        assert_eq!(v.state().hover, None);
432        let snap = facett_core::harness::snapshot(&mut v, [BoxPlotMsg::Hover(Some(1))]);
433        assert_eq!(snap.hover, Some(1), "hovering a box is observable in the snapshot");
434        let snap = facett_core::harness::snapshot(&mut v, [BoxPlotMsg::Hover(None)]);
435        assert_eq!(snap.hover, None, "leaving clears the hover");
436    }
437
438    #[test]
439    fn drive_produces_no_effects() {
440        let mut v = BoxPlotView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0])]);
441        let effects = facett_core::harness::drive(&mut v, [BoxPlotMsg::Hover(Some(0)), BoxPlotMsg::Hover(None)]);
442        assert!(effects.is_empty(), "the box plot does no I/O");
443    }
444
445    #[test]
446    fn state_serde_round_trips() {
447        let st = BoxPlotState { hover: Some(2) };
448        let back: BoxPlotState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
449        assert_eq!(st, back);
450    }
451}