1use 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#[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 pub lower_fence: f64,
21 pub upper_fence: f64,
23 pub outliers: Vec<f64>,
25}
26
27impl Quartiles {
28 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 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 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
70pub 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
87pub struct BoxPlotState {
88 pub hover: Option<usize>,
90}
91
92#[derive(Clone, Debug, PartialEq)]
96pub enum BoxPlotMsg {
97 Hover(Option<usize>),
99}
100
101#[derive(Clone, Debug)]
103pub enum BoxPlotEffect {}
104
105pub struct BoxPlotView {
107 pub title: String,
108 pub groups: Vec<(String, Vec<f64>)>,
110 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 pub fn state(&self) -> &BoxPlotState {
121 &self.state
122 }
123
124 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 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 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 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
169impl 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 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 #[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 #[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 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 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 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 a11y_node(
245 ui,
246 base,
247 name,
248 Sense::hover(),
249 box_rect,
250 Semantics::image(name.clone()).value(q.median),
251 );
252 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 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 painter.text(Pos2::new(cx, plot.bottom() + 12.0), Align2::CENTER_CENTER, name, FontId::proportional(11.0), th.text);
263
264 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 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 #[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
297fn 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); self.render(ui)
320 }
321}
322
323facett_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
344pub(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 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 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); 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); }
423
424 #[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}