1use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
7use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
8
9#[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 pub lower_fence: f64,
19 pub upper_fence: f64,
21 pub outliers: Vec<f64>,
23}
24
25impl Quartiles {
26 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 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 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
68pub 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
81pub struct BoxPlotView {
83 pub title: String,
84 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 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 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 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 let base = ui.id().with("boxplot");
164
165 for (i, (name, q)) in summaries.iter().enumerate() {
166 let cx = plot.left() + slot * (i as f32 + 0.5);
167 let color = facett_core::hash_color(name);
168 let stroke = Stroke::new(1.5, th.accent);
169
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));
172 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));
173 let cap = box_w * 0.3;
175 for &v in &[q.lower_fence, q.upper_fence] {
176 painter.line_segment([Pos2::new(cx - cap, y_of(v)), Pos2::new(cx + cap, y_of(v))], Stroke::new(1.0, th.text_dim));
177 }
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)));
180 painter.rect_filled(box_rect, egui::CornerRadius::same(2), color.gamma_multiply(0.55));
181 painter.rect_stroke(box_rect, egui::CornerRadius::same(2), stroke, egui::StrokeKind::Middle);
182 a11y_node(
186 ui,
187 base,
188 name,
189 Sense::hover(),
190 box_rect,
191 Semantics::image(name.clone()).value(q.median),
192 );
193 painter.line_segment(
195 [Pos2::new(box_rect.left(), y_of(q.median)), Pos2::new(box_rect.right(), y_of(q.median))],
196 Stroke::new(2.0, th.glow),
197 );
198 for &o in &q.outliers {
200 painter.circle_stroke(Pos2::new(cx, y_of(o)), 2.5, Stroke::new(1.0, th.point));
201 }
202 painter.text(Pos2::new(cx, plot.bottom() + 12.0), Align2::CENTER_CENTER, name, FontId::proportional(11.0), th.text);
204
205 if let Some(hp) = hover {
207 if hp.x >= cx - slot * 0.5 && hp.x < cx + slot * 0.5 && plot.y_range().contains(hp.y) {
208 tip = Some((
209 hp,
210 format!(
211 "{name}\nmax {:.3}\nq3 {:.3}\nmed {:.3}\nq1 {:.3}\nmin {:.3}\noutliers {}",
212 q.max, q.q3, q.median, q.q1, q.min, q.outliers.len()
213 ),
214 ));
215 }
216 }
217 }
218
219 if let Some((at, text)) = tip {
220 paint_tooltip(&painter, &th, at, &text);
221 }
222 }
223 fn state_json(&self) -> serde_json::Value {
224 serde_json::json!({
225 "kind": "boxplot",
226 "groups": self.summaries().iter().map(|(n, q)| serde_json::json!({
227 "name": n, "min": q.min, "q1": q.q1, "median": q.median,
228 "q3": q.q3, "max": q.max, "outliers": q.outliers.len(),
229 })).collect::<Vec<_>>(),
230 })
231 }
232}
233
234pub(crate) fn paint_tooltip(painter: &egui::Painter, th: &facett_core::Theme, at: Pos2, text: &str) {
236 let font = FontId::proportional(11.0);
237 let galley = painter.layout(text.to_string(), font, th.text, 240.0);
238 let pad = vec2(6.0, 4.0);
239 let pos = at + vec2(12.0, 12.0);
240 let bg = Rect::from_min_size(pos, galley.size() + pad * 2.0);
241 painter.rect_filled(bg, egui::CornerRadius::same(3), th.panel_bg);
242 painter.rect_stroke(bg, egui::CornerRadius::same(3), Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Middle);
243 painter.galley(pos + pad, galley, Color32::WHITE);
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn percentile_matches_numpy_type7() {
252 let v: Vec<f64> = (1..=10).map(|x| x as f64).collect();
254 assert!((percentile_sorted(&v, 0.25) - 3.25).abs() < 1e-9);
255 assert!((percentile_sorted(&v, 0.50) - 5.5).abs() < 1e-9);
256 assert!((percentile_sorted(&v, 0.75) - 7.75).abs() < 1e-9);
257 }
258
259 #[test]
260 fn quartiles_basic_no_outliers() {
261 let q = Quartiles::compute(&[1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
262 assert_eq!(q.min, 1.0);
263 assert_eq!(q.max, 5.0);
264 assert_eq!(q.median, 3.0);
265 assert_eq!(q.q1, 2.0);
266 assert_eq!(q.q3, 4.0);
267 assert!(q.outliers.is_empty());
268 assert_eq!(q.lower_fence, 1.0);
270 assert_eq!(q.upper_fence, 5.0);
271 }
272
273 #[test]
274 fn quartiles_detects_high_outlier() {
275 let mut data: Vec<f64> = (1..=10).map(|x| x as f64).collect();
276 data.push(100.0); let q = Quartiles::compute(&data).unwrap();
278 assert!(q.outliers.contains(&100.0), "100 must be flagged as an outlier");
279 assert!(q.upper_fence <= 10.0, "upper whisker snaps to in-fence data");
280 }
281
282 #[test]
283 fn quartiles_empty_and_nonfinite() {
284 assert!(Quartiles::compute(&[]).is_none());
285 assert!(Quartiles::compute(&[f64::NAN, f64::INFINITY]).is_none());
286 }
287
288 #[test]
289 fn single_value() {
290 let q = Quartiles::compute(&[7.0]).unwrap();
291 assert_eq!(q.median, 7.0);
292 assert_eq!(q.q1, 7.0);
293 assert_eq!(q.q3, 7.0);
294 assert!(q.outliers.is_empty());
295 }
296
297 #[test]
298 fn csv_has_header_and_row_per_group() {
299 let v = BoxPlotView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0]), ("b".into(), vec![4.0, 5.0, 6.0])]);
300 let csv = v.to_csv();
301 assert!(csv.starts_with("group,min,q1,median,q3,max,outliers\n"));
302 assert_eq!(csv.lines().count(), 3); }
304}