1use 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#[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 pub lower_fence: f64,
20 pub upper_fence: f64,
22 pub outliers: Vec<f64>,
24}
25
26impl Quartiles {
27 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 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 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
69pub 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
82pub struct BoxPlotView {
84 pub title: String,
85 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 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 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 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
131impl 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 #[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 #[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 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 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 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 a11y_node(
214 ui,
215 base,
216 name,
217 Sense::hover(),
218 box_rect,
219 Semantics::image(name.clone()).value(q.median),
220 );
221 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 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 painter.text(Pos2::new(cx, plot.bottom() + 12.0), Align2::CENTER_CENTER, name, FontId::proportional(11.0), th.text);
232
233 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 #[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
270pub(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 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 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); 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); }
349}