1use egui::{Align2, 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
11use crate::boxplot::{Quartiles, paint_tooltip};
12
13#[derive(Clone, Debug, PartialEq)]
15pub struct Density {
16 pub grid: Vec<f64>,
17 pub dens: Vec<f64>,
18 pub peak: f64,
20}
21
22pub fn silverman_bandwidth(values: &[f64]) -> f64 {
26 let n = values.len();
27 if n < 2 {
28 return 1.0;
29 }
30 let mean = values.iter().sum::<f64>() / n as f64;
31 let var = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
32 let sd = var.sqrt();
33 let bw = 1.06 * sd * (n as f64).powf(-0.2);
34 if bw > 0.0 { bw } else { 1.0 }
35}
36
37pub fn kde(values: &[f64], bins: usize) -> Option<Density> {
41 let v: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
42 if v.is_empty() {
43 return None;
44 }
45 let bins = bins.max(2);
46 let bw = silverman_bandwidth(&v);
47 let (mut lo, mut hi) = (v[0], v[0]);
48 for &x in &v {
49 lo = lo.min(x);
50 hi = hi.max(x);
51 }
52 lo -= bw;
54 hi += bw;
55 let span = (hi - lo).max(1e-9);
56 let inv = 1.0 / (bw * (std::f64::consts::TAU).sqrt());
57 let n = v.len() as f64;
58 let mut grid = Vec::with_capacity(bins);
59 let mut dens = Vec::with_capacity(bins);
60 let mut peak = 0.0_f64;
61 for i in 0..bins {
62 let x = lo + span * (i as f64) / (bins as f64 - 1.0);
63 let mut d = 0.0;
64 for &xi in &v {
65 let u = (x - xi) / bw;
66 d += (-0.5 * u * u).exp();
67 }
68 d *= inv / n;
69 peak = peak.max(d);
70 grid.push(x);
71 dens.push(d);
72 }
73 Some(Density { grid, dens, peak })
74}
75
76#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
80pub struct ViolinState {
81 pub hover: Option<(usize, f64)>,
83}
84
85#[derive(Clone, Debug, PartialEq)]
88pub enum ViolinMsg {
89 Hover(Option<(usize, f64)>),
91}
92
93#[derive(Clone, Debug)]
95pub enum ViolinEffect {}
96
97pub struct ViolinView {
99 pub title: String,
100 pub groups: Vec<(String, Vec<f64>)>,
101 pub bins: usize,
103 pub state: ViolinState,
105}
106
107impl ViolinView {
108 pub fn new(title: impl Into<String>, groups: Vec<(String, Vec<f64>)>) -> Self {
109 Self { title: title.into(), groups, bins: 64, state: ViolinState::default() }
110 }
111
112 pub fn state(&self) -> &ViolinState {
114 &self.state
115 }
116
117 pub fn update(&mut self, msg: ViolinMsg) -> Vec<ViolinEffect> {
119 match msg {
120 ViolinMsg::Hover(h) => self.state.hover = h,
121 }
122 Vec::new()
123 }
124
125 fn densities(&self) -> Vec<(String, Density, f64)> {
126 self.groups
127 .iter()
128 .filter_map(|(name, vals)| {
129 let d = kde(vals, self.bins)?;
130 let median = Quartiles::compute(vals)?.median;
131 Some((name.clone(), d, median))
132 })
133 .collect()
134 }
135
136 fn value_range(&self, dens: &[(String, Density, f64)]) -> Option<(f64, f64)> {
137 let mut r: Option<(f64, f64)> = None;
138 for (_, d, _) in dens {
139 for &g in &d.grid {
140 r = Some(match r {
141 None => (g, g),
142 Some((lo, hi)) => (lo.min(g), hi.max(g)),
143 });
144 }
145 }
146 r
147 }
148
149 fn to_csv(&self) -> String {
150 let mut s = String::from("group,n,median,peak_density\n");
151 for (name, d, median) in self.densities() {
152 let n = self.groups.iter().find(|(g, _)| g == &name).map(|(_, v)| v.len()).unwrap_or(0);
153 s.push_str(&format!("{name},{n},{median},{}\n", d.peak));
154 }
155 s
156 }
157}
158
159impl CopySource for ViolinView {
161 fn copy_kinds(&self) -> &[ClipKind] {
162 &[ClipKind::Text]
163 }
164 fn copy_payload(&self) -> Option<ClipPayload> {
165 let csv = self.to_csv();
166 (!csv.is_empty()).then_some(ClipPayload::Text(csv))
167 }
168}
169
170impl ViolinView {
171 fn render(&self, ui: &mut Ui) -> Vec<ViolinMsg> {
174 let th = theme(ui);
175 let dens = self.densities();
176 let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
177 let painter = ui.painter_at(rect);
178 painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
179 let Some((vmin, vmax)) = self.value_range(&dens) else {
180 #[cfg(feature = "testmatrix")]
182 facett_core::testmatrix::emit(
183 "facett-plotters::ViolinView::ui",
184 "ui_render",
185 dens.is_empty(),
186 &format!("drew=empty_hint groups={} densities={}", self.groups.len(), dens.len()),
187 );
188 painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
189 return hover_msg(self.state.hover, None);
190 };
191 let plot = rect.shrink2(vec2(28.0, 24.0));
192 let span = (vmax - vmin).max(1e-9);
193 let y_of = |v: f64| plot.bottom() - ((v - vmin) / span) as f32 * plot.height();
194 let n = dens.len().max(1);
195 let slot = plot.width() / n as f32;
196 let half_w = (slot * 0.42).min(80.0);
197 let hover = resp.hover_pos();
198 let mut new_hover: Option<(usize, f64)> = None;
199 let base = ui.id().with("violin");
200
201 for (i, (name, d, median)) in dens.iter().enumerate() {
202 let cx = plot.left() + slot * (i as f32 + 0.5);
203 let color = facett_core::hash_color(name);
204 let peak = d.peak.max(1e-12);
205 let mut poly: Vec<Pos2> = Vec::with_capacity(d.grid.len() * 2);
207 for k in 0..d.grid.len() {
208 let w = (d.dens[k] / peak) as f32 * half_w;
209 poly.push(Pos2::new(cx + w, y_of(d.grid[k])));
210 }
211 for k in (0..d.grid.len()).rev() {
212 let w = (d.dens[k] / peak) as f32 * half_w;
213 poly.push(Pos2::new(cx - w, y_of(d.grid[k])));
214 }
215 painter.add(egui::Shape::convex_polygon(poly.clone(), color.gamma_multiply(0.40), Stroke::new(1.2, th.accent)));
216 let (gmin, gmax) = (
221 d.grid.first().copied().unwrap_or(*median),
222 d.grid.last().copied().unwrap_or(*median),
223 );
224 let body_rect = Rect::from_min_max(
225 Pos2::new(cx - half_w, y_of(gmax)),
226 Pos2::new(cx + half_w, y_of(gmin)),
227 );
228 a11y_node(
229 ui,
230 base,
231 name,
232 Sense::hover(),
233 body_rect,
234 Semantics::image(name.clone()).value(*median),
235 );
236 let mw = {
238 let idx = d.grid.iter().enumerate().min_by(|a, b| {
240 (a.1 - median).abs().partial_cmp(&(b.1 - median).abs()).unwrap()
241 }).map(|(i, _)| i).unwrap_or(0);
242 (d.dens[idx] / peak) as f32 * half_w
243 };
244 painter.line_segment([Pos2::new(cx - mw, y_of(*median)), Pos2::new(cx + mw, y_of(*median))], Stroke::new(2.0, th.glow));
245 painter.text(Pos2::new(cx, plot.bottom() + 12.0), Align2::CENTER_CENTER, name, FontId::proportional(11.0), th.text);
246
247 if let Some(hp) = hover {
249 if hp.x >= cx - slot * 0.5 && hp.x < cx + slot * 0.5 && plot.y_range().contains(hp.y) {
250 let v = vmin + ((plot.bottom() - hp.y) / plot.height()) as f64 * span;
252 new_hover = Some((i, v));
253 }
254 }
255 }
256 if let Some(hp) = hover {
259 if let Some((i, v)) = self.state.hover {
260 if let Some((name, d, median)) = dens.get(i) {
261 paint_tooltip(&painter, &th, hp, &format!("{name}\nvalue {v:.3}\nmedian {median:.3}\npeak {:.4}", d.peak));
262 }
263 }
264 }
265 #[cfg(feature = "testmatrix")]
267 facett_core::testmatrix::emit(
268 "facett-plotters::ViolinView::ui",
269 "ui_render",
270 !dens.is_empty() && dens.len() <= self.groups.len(),
271 &format!("groups={} violins={}", self.groups.len(), dens.len()),
272 );
273 hover_msg(self.state.hover, new_hover)
274 }
275}
276
277fn hover_msg(current: Option<(usize, f64)>, next: Option<(usize, f64)>) -> Vec<ViolinMsg> {
279 if current != next { vec![ViolinMsg::Hover(next)] } else { Vec::new() }
280}
281
282impl Elm for ViolinView {
283 type Model = ViolinState;
284 type Msg = ViolinMsg;
285 type Effect = ViolinEffect;
286
287 fn title(&self) -> &str {
288 &self.title
289 }
290 fn state(&self) -> &ViolinState {
291 &self.state
292 }
293 fn update(&mut self, msg: ViolinMsg) -> Vec<ViolinEffect> {
294 ViolinView::update(self, msg)
295 }
296 fn view(&self, ui: &mut Ui) -> Vec<ViolinMsg> {
297 self.render(ui)
298 }
299}
300
301facett_core::impl_facet_via_elm!(ViolinView, custom_state_json, {
304 fn caps(&self) -> FacetCaps {
305 FacetCaps::NONE.themeable().copyable().resizable()
306 }
307 fn copy(&mut self) -> Option<String> {
308 Some(self.to_csv())
309 }
310 fn state_json(&self) -> serde_json::Value {
311 serde_json::json!({
312 "kind": "violin",
313 "bins": self.bins,
314 "groups": self.densities().iter().map(|(n, d, m)| serde_json::json!({
315 "name": n, "median": m, "peak": d.peak, "grid": d.grid.len(),
316 })).collect::<Vec<_>>(),
317 })
318 }
319});
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn typed_copy_is_the_dataset_csv() {
327 use facett_core::clip::{ClipKind, CopySource};
328 let v = ViolinView::new("v", vec![("a".into(), vec![1.0, 2.0, 3.0])]);
329 let p = v.copy_payload().expect("groups copy");
330 assert_eq!(p.kind(), ClipKind::Text);
331 assert_eq!(p.as_text(), v.to_csv());
332 }
333
334 #[test]
335 fn kde_grid_and_density_lengths_match_bins() {
336 let d = kde(&[1.0, 2.0, 2.0, 3.0, 5.0], 32).unwrap();
337 assert_eq!(d.grid.len(), 32);
338 assert_eq!(d.dens.len(), 32);
339 assert!(d.peak > 0.0);
340 }
341
342 #[test]
343 fn kde_density_is_nonnegative_and_peaks_near_mode() {
344 let mut data = vec![];
346 for _ in 0..20 { data.push(0.0); }
347 for _ in 0..20 { data.push(10.0); }
348 let d = kde(&data, 100).unwrap();
349 assert!(d.dens.iter().all(|&x| x >= 0.0));
350 let (peak_i, _) = d.dens.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap();
352 let peak_x = d.grid[peak_i];
353 assert!(peak_x.abs() < 2.0 || (peak_x - 10.0).abs() < 2.0, "peak at {peak_x} should be near 0 or 10");
354 }
355
356 #[test]
357 fn kde_integrates_to_about_one() {
358 let data: Vec<f64> = (0..200).map(|i| (i as f64) * 0.05).collect();
360 let d = kde(&data, 400).unwrap();
361 let dx = d.grid[1] - d.grid[0];
362 let area: f64 = d.dens.iter().sum::<f64>() * dx;
363 assert!((area - 1.0).abs() < 0.05, "area {area} should be ~1");
364 }
365
366 #[test]
367 fn kde_empty_is_none() {
368 assert!(kde(&[], 16).is_none());
369 assert!(kde(&[f64::NAN], 16).is_none());
370 }
371
372 #[test]
373 fn silverman_positive_even_for_constant_sample() {
374 assert!(silverman_bandwidth(&[5.0, 5.0, 5.0]) > 0.0);
375 assert!(silverman_bandwidth(&[]) > 0.0);
376 }
377
378 #[test]
379 fn csv_header_and_rows() {
380 let v = ViolinView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0])]);
381 let csv = v.to_csv();
382 assert!(csv.starts_with("group,n,median,peak_density\n"));
383 assert_eq!(csv.lines().count(), 2);
384 }
385
386 #[test]
388 fn hover_transitions_are_observable() {
389 let mut v = ViolinView::new(
390 "t",
391 vec![("a".into(), vec![1.0, 2.0, 3.0]), ("b".into(), vec![4.0, 5.0, 6.0])],
392 );
393 assert_eq!(v.state().hover, None);
394 let snap = facett_core::harness::snapshot(&mut v, [ViolinMsg::Hover(Some((1, 4.5)))]);
395 assert_eq!(snap.hover, Some((1, 4.5)));
396 let snap = facett_core::harness::snapshot(&mut v, [ViolinMsg::Hover(None)]);
397 assert_eq!(snap.hover, None);
398 }
399
400 #[test]
401 fn drive_produces_no_effects() {
402 let mut v = ViolinView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0])]);
403 let effects = facett_core::harness::drive(&mut v, [ViolinMsg::Hover(Some((0, 2.0))), ViolinMsg::Hover(None)]);
404 assert!(effects.is_empty());
405 }
406
407 #[test]
408 fn state_serde_round_trips() {
409 let st = ViolinState { hover: Some((2, 7.25)) };
410 let back: ViolinState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
411 assert_eq!(st, back);
412 }
413}