1use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
7use facett_core::clip::{ClipKind, ClipPayload, CopySource};
8use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
9
10use crate::boxplot::{Quartiles, paint_tooltip};
11
12#[derive(Clone, Debug, PartialEq)]
14pub struct Density {
15 pub grid: Vec<f64>,
16 pub dens: Vec<f64>,
17 pub peak: f64,
19}
20
21pub fn silverman_bandwidth(values: &[f64]) -> f64 {
25 let n = values.len();
26 if n < 2 {
27 return 1.0;
28 }
29 let mean = values.iter().sum::<f64>() / n as f64;
30 let var = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
31 let sd = var.sqrt();
32 let bw = 1.06 * sd * (n as f64).powf(-0.2);
33 if bw > 0.0 { bw } else { 1.0 }
34}
35
36pub fn kde(values: &[f64], bins: usize) -> Option<Density> {
40 let v: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
41 if v.is_empty() {
42 return None;
43 }
44 let bins = bins.max(2);
45 let bw = silverman_bandwidth(&v);
46 let (mut lo, mut hi) = (v[0], v[0]);
47 for &x in &v {
48 lo = lo.min(x);
49 hi = hi.max(x);
50 }
51 lo -= bw;
53 hi += bw;
54 let span = (hi - lo).max(1e-9);
55 let inv = 1.0 / (bw * (std::f64::consts::TAU).sqrt());
56 let n = v.len() as f64;
57 let mut grid = Vec::with_capacity(bins);
58 let mut dens = Vec::with_capacity(bins);
59 let mut peak = 0.0_f64;
60 for i in 0..bins {
61 let x = lo + span * (i as f64) / (bins as f64 - 1.0);
62 let mut d = 0.0;
63 for &xi in &v {
64 let u = (x - xi) / bw;
65 d += (-0.5 * u * u).exp();
66 }
67 d *= inv / n;
68 peak = peak.max(d);
69 grid.push(x);
70 dens.push(d);
71 }
72 Some(Density { grid, dens, peak })
73}
74
75pub struct ViolinView {
77 pub title: String,
78 pub groups: Vec<(String, Vec<f64>)>,
79 pub bins: usize,
81}
82
83impl ViolinView {
84 pub fn new(title: impl Into<String>, groups: Vec<(String, Vec<f64>)>) -> Self {
85 Self { title: title.into(), groups, bins: 64 }
86 }
87
88 fn densities(&self) -> Vec<(String, Density, f64)> {
89 self.groups
90 .iter()
91 .filter_map(|(name, vals)| {
92 let d = kde(vals, self.bins)?;
93 let median = Quartiles::compute(vals)?.median;
94 Some((name.clone(), d, median))
95 })
96 .collect()
97 }
98
99 fn value_range(&self, dens: &[(String, Density, f64)]) -> Option<(f64, f64)> {
100 let mut r: Option<(f64, f64)> = None;
101 for (_, d, _) in dens {
102 for &g in &d.grid {
103 r = Some(match r {
104 None => (g, g),
105 Some((lo, hi)) => (lo.min(g), hi.max(g)),
106 });
107 }
108 }
109 r
110 }
111
112 fn to_csv(&self) -> String {
113 let mut s = String::from("group,n,median,peak_density\n");
114 for (name, d, median) in self.densities() {
115 let n = self.groups.iter().find(|(g, _)| g == &name).map(|(_, v)| v.len()).unwrap_or(0);
116 s.push_str(&format!("{name},{n},{median},{}\n", d.peak));
117 }
118 s
119 }
120}
121
122impl CopySource for ViolinView {
124 fn copy_kinds(&self) -> &[ClipKind] {
125 &[ClipKind::Text]
126 }
127 fn copy_payload(&self) -> Option<ClipPayload> {
128 let csv = self.to_csv();
129 (!csv.is_empty()).then_some(ClipPayload::Text(csv))
130 }
131}
132
133impl Facet for ViolinView {
134 fn title(&self) -> &str {
135 &self.title
136 }
137 fn caps(&self) -> FacetCaps {
138 FacetCaps::NONE.themeable().copyable().resizable()
139 }
140 fn copy(&mut self) -> Option<String> {
141 Some(self.to_csv())
142 }
143 fn ui(&mut self, ui: &mut Ui) {
144 let th = theme(ui);
145 let dens = self.densities();
146 let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
147 let painter = ui.painter_at(rect);
148 painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
149 let Some((vmin, vmax)) = self.value_range(&dens) else {
150 #[cfg(feature = "testmatrix")]
152 facett_core::testmatrix::emit(
153 "facett-plotters::ViolinView::ui",
154 "ui_render",
155 dens.is_empty(),
156 &format!("drew=empty_hint groups={} densities={}", self.groups.len(), dens.len()),
157 );
158 painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
159 return;
160 };
161 let plot = rect.shrink2(vec2(28.0, 24.0));
162 let span = (vmax - vmin).max(1e-9);
163 let y_of = |v: f64| plot.bottom() - ((v - vmin) / span) as f32 * plot.height();
164 let n = dens.len().max(1);
165 let slot = plot.width() / n as f32;
166 let half_w = (slot * 0.42).min(80.0);
167 let hover = resp.hover_pos();
168 let mut tip: Option<(Pos2, String)> = None;
169 let base = ui.id().with("violin");
170
171 for (i, (name, d, median)) in dens.iter().enumerate() {
172 let cx = plot.left() + slot * (i as f32 + 0.5);
173 let color = facett_core::hash_color(name);
174 let peak = d.peak.max(1e-12);
175 let mut poly: Vec<Pos2> = Vec::with_capacity(d.grid.len() * 2);
177 for k in 0..d.grid.len() {
178 let w = (d.dens[k] / peak) as f32 * half_w;
179 poly.push(Pos2::new(cx + w, y_of(d.grid[k])));
180 }
181 for k in (0..d.grid.len()).rev() {
182 let w = (d.dens[k] / peak) as f32 * half_w;
183 poly.push(Pos2::new(cx - w, y_of(d.grid[k])));
184 }
185 painter.add(egui::Shape::convex_polygon(poly.clone(), color.gamma_multiply(0.40), Stroke::new(1.2, th.accent)));
186 let (gmin, gmax) = (
191 d.grid.first().copied().unwrap_or(*median),
192 d.grid.last().copied().unwrap_or(*median),
193 );
194 let body_rect = Rect::from_min_max(
195 Pos2::new(cx - half_w, y_of(gmax)),
196 Pos2::new(cx + half_w, y_of(gmin)),
197 );
198 a11y_node(
199 ui,
200 base,
201 name,
202 Sense::hover(),
203 body_rect,
204 Semantics::image(name.clone()).value(*median),
205 );
206 let mw = {
208 let idx = d.grid.iter().enumerate().min_by(|a, b| {
210 (a.1 - median).abs().partial_cmp(&(b.1 - median).abs()).unwrap()
211 }).map(|(i, _)| i).unwrap_or(0);
212 (d.dens[idx] / peak) as f32 * half_w
213 };
214 painter.line_segment([Pos2::new(cx - mw, y_of(*median)), Pos2::new(cx + mw, y_of(*median))], Stroke::new(2.0, th.glow));
215 painter.text(Pos2::new(cx, plot.bottom() + 12.0), Align2::CENTER_CENTER, name, FontId::proportional(11.0), th.text);
216
217 if let Some(hp) = hover {
218 if hp.x >= cx - slot * 0.5 && hp.x < cx + slot * 0.5 && plot.y_range().contains(hp.y) {
219 let v = vmin + ((plot.bottom() - hp.y) / plot.height()) as f64 * span;
221 tip = Some((hp, format!("{name}\nvalue {v:.3}\nmedian {median:.3}\npeak {:.4}", d.peak)));
222 }
223 }
224 }
225 if let Some((at, text)) = tip {
226 paint_tooltip(&painter, &th, at, &text);
227 }
228 #[cfg(feature = "testmatrix")]
230 facett_core::testmatrix::emit(
231 "facett-plotters::ViolinView::ui",
232 "ui_render",
233 !dens.is_empty() && dens.len() <= self.groups.len(),
234 &format!("groups={} violins={}", self.groups.len(), dens.len()),
235 );
236 }
237 fn state_json(&self) -> serde_json::Value {
238 serde_json::json!({
239 "kind": "violin",
240 "bins": self.bins,
241 "groups": self.densities().iter().map(|(n, d, m)| serde_json::json!({
242 "name": n, "median": m, "peak": d.peak, "grid": d.grid.len(),
243 })).collect::<Vec<_>>(),
244 })
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn typed_copy_is_the_dataset_csv() {
254 use facett_core::clip::{ClipKind, CopySource};
255 let v = ViolinView::new("v", vec![("a".into(), vec![1.0, 2.0, 3.0])]);
256 let p = v.copy_payload().expect("groups copy");
257 assert_eq!(p.kind(), ClipKind::Text);
258 assert_eq!(p.as_text(), v.to_csv());
259 }
260
261 #[test]
262 fn kde_grid_and_density_lengths_match_bins() {
263 let d = kde(&[1.0, 2.0, 2.0, 3.0, 5.0], 32).unwrap();
264 assert_eq!(d.grid.len(), 32);
265 assert_eq!(d.dens.len(), 32);
266 assert!(d.peak > 0.0);
267 }
268
269 #[test]
270 fn kde_density_is_nonnegative_and_peaks_near_mode() {
271 let mut data = vec![];
273 for _ in 0..20 { data.push(0.0); }
274 for _ in 0..20 { data.push(10.0); }
275 let d = kde(&data, 100).unwrap();
276 assert!(d.dens.iter().all(|&x| x >= 0.0));
277 let (peak_i, _) = d.dens.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap();
279 let peak_x = d.grid[peak_i];
280 assert!(peak_x.abs() < 2.0 || (peak_x - 10.0).abs() < 2.0, "peak at {peak_x} should be near 0 or 10");
281 }
282
283 #[test]
284 fn kde_integrates_to_about_one() {
285 let data: Vec<f64> = (0..200).map(|i| (i as f64) * 0.05).collect();
287 let d = kde(&data, 400).unwrap();
288 let dx = d.grid[1] - d.grid[0];
289 let area: f64 = d.dens.iter().sum::<f64>() * dx;
290 assert!((area - 1.0).abs() < 0.05, "area {area} should be ~1");
291 }
292
293 #[test]
294 fn kde_empty_is_none() {
295 assert!(kde(&[], 16).is_none());
296 assert!(kde(&[f64::NAN], 16).is_none());
297 }
298
299 #[test]
300 fn silverman_positive_even_for_constant_sample() {
301 assert!(silverman_bandwidth(&[5.0, 5.0, 5.0]) > 0.0);
302 assert!(silverman_bandwidth(&[]) > 0.0);
303 }
304
305 #[test]
306 fn csv_header_and_rows() {
307 let v = ViolinView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0])]);
308 let csv = v.to_csv();
309 assert!(csv.starts_with("group,n,median,peak_density\n"));
310 assert_eq!(csv.lines().count(), 2);
311 }
312}