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