Skip to main content

facett_plotters/
violin.rs

1//! **Violin plot** — a mirrored kernel-density silhouette per group. The KDE
2//! ([`kde`]) is a pure function (Gaussian kernel, Silverman bandwidth) sampled
3//! on an evenly-spaced grid; [`ViolinView`] paints the mirrored density as a
4//! filled polygon and overlays the median line.
5
6use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
7use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
8
9use crate::boxplot::{Quartiles, paint_tooltip};
10
11/// One density curve: `grid[i]` (the value axis) carries density `dens[i]`.
12#[derive(Clone, Debug, PartialEq)]
13pub struct Density {
14    pub grid: Vec<f64>,
15    pub dens: Vec<f64>,
16    /// Peak density (for normalising the silhouette width).
17    pub peak: f64,
18}
19
20/// Silverman's rule-of-thumb bandwidth: `1.06 · σ · n^(−1/5)`. Falls back to a
21/// small positive width on a degenerate (zero-variance) sample so the kernel
22/// stays well-defined.
23pub fn silverman_bandwidth(values: &[f64]) -> f64 {
24    let n = values.len();
25    if n < 2 {
26        return 1.0;
27    }
28    let mean = values.iter().sum::<f64>() / n as f64;
29    let var = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
30    let sd = var.sqrt();
31    let bw = 1.06 * sd * (n as f64).powf(-0.2);
32    if bw > 0.0 { bw } else { 1.0 }
33}
34
35/// Gaussian-kernel density estimate of `values`, evaluated on `bins` evenly
36/// spaced points spanning `[min, max]` padded by one bandwidth. `None` for an
37/// empty sample.
38pub fn kde(values: &[f64], bins: usize) -> Option<Density> {
39    let v: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
40    if v.is_empty() {
41        return None;
42    }
43    let bins = bins.max(2);
44    let bw = silverman_bandwidth(&v);
45    let (mut lo, mut hi) = (v[0], v[0]);
46    for &x in &v {
47        lo = lo.min(x);
48        hi = hi.max(x);
49    }
50    // Pad so the tails of the kernel are visible.
51    lo -= bw;
52    hi += bw;
53    let span = (hi - lo).max(1e-9);
54    let inv = 1.0 / (bw * (std::f64::consts::TAU).sqrt());
55    let n = v.len() as f64;
56    let mut grid = Vec::with_capacity(bins);
57    let mut dens = Vec::with_capacity(bins);
58    let mut peak = 0.0_f64;
59    for i in 0..bins {
60        let x = lo + span * (i as f64) / (bins as f64 - 1.0);
61        let mut d = 0.0;
62        for &xi in &v {
63            let u = (x - xi) / bw;
64            d += (-0.5 * u * u).exp();
65        }
66        d *= inv / n;
67        peak = peak.max(d);
68        grid.push(x);
69        dens.push(d);
70    }
71    Some(Density { grid, dens, peak })
72}
73
74/// A violin plot: named groups of raw samples.
75pub struct ViolinView {
76    pub title: String,
77    pub groups: Vec<(String, Vec<f64>)>,
78    /// KDE resolution (grid points per group).
79    pub bins: usize,
80}
81
82impl ViolinView {
83    pub fn new(title: impl Into<String>, groups: Vec<(String, Vec<f64>)>) -> Self {
84        Self { title: title.into(), groups, bins: 64 }
85    }
86
87    fn densities(&self) -> Vec<(String, Density, f64)> {
88        self.groups
89            .iter()
90            .filter_map(|(name, vals)| {
91                let d = kde(vals, self.bins)?;
92                let median = Quartiles::compute(vals)?.median;
93                Some((name.clone(), d, median))
94            })
95            .collect()
96    }
97
98    fn value_range(&self, dens: &[(String, Density, f64)]) -> Option<(f64, f64)> {
99        let mut r: Option<(f64, f64)> = None;
100        for (_, d, _) in dens {
101            for &g in &d.grid {
102                r = Some(match r {
103                    None => (g, g),
104                    Some((lo, hi)) => (lo.min(g), hi.max(g)),
105                });
106            }
107        }
108        r
109    }
110
111    fn to_csv(&self) -> String {
112        let mut s = String::from("group,n,median,peak_density\n");
113        for (name, d, median) in self.densities() {
114            let n = self.groups.iter().find(|(g, _)| g == &name).map(|(_, v)| v.len()).unwrap_or(0);
115            s.push_str(&format!("{name},{n},{median},{}\n", d.peak));
116        }
117        s
118    }
119}
120
121impl Facet for ViolinView {
122    fn title(&self) -> &str {
123        &self.title
124    }
125    fn caps(&self) -> FacetCaps {
126        FacetCaps::NONE.themeable().copyable().resizable()
127    }
128    fn copy(&mut self) -> Option<String> {
129        Some(self.to_csv())
130    }
131    fn ui(&mut self, ui: &mut Ui) {
132        let th = theme(ui);
133        let dens = self.densities();
134        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
135        let painter = ui.painter_at(rect);
136        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
137        let Some((vmin, vmax)) = self.value_range(&dens) else {
138            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
139            return;
140        };
141        let plot = rect.shrink2(vec2(28.0, 24.0));
142        let span = (vmax - vmin).max(1e-9);
143        let y_of = |v: f64| plot.bottom() - ((v - vmin) / span) as f32 * plot.height();
144        let n = dens.len().max(1);
145        let slot = plot.width() / n as f32;
146        let half_w = (slot * 0.42).min(80.0);
147        let hover = resp.hover_pos();
148        let mut tip: Option<(Pos2, String)> = None;
149        let base = ui.id().with("violin");
150
151        for (i, (name, d, median)) in dens.iter().enumerate() {
152            let cx = plot.left() + slot * (i as f32 + 0.5);
153            let color = facett_core::hash_color(name);
154            let peak = d.peak.max(1e-12);
155            // Build the mirrored silhouette: right edge down, left edge up.
156            let mut poly: Vec<Pos2> = Vec::with_capacity(d.grid.len() * 2);
157            for k in 0..d.grid.len() {
158                let w = (d.dens[k] / peak) as f32 * half_w;
159                poly.push(Pos2::new(cx + w, y_of(d.grid[k])));
160            }
161            for k in (0..d.grid.len()).rev() {
162                let w = (d.dens[k] / peak) as f32 * half_w;
163                poly.push(Pos2::new(cx - w, y_of(d.grid[k])));
164            }
165            painter.add(egui::Shape::convex_polygon(poly.clone(), color.gamma_multiply(0.40), Stroke::new(1.2, th.accent)));
166            // AccessKit node over the violin's bounding box (FC-4): role=Image,
167            // label = the group name, numeric_value = the median (the silhouette's
168            // headline stat). Keyed on the group name (stable across reorder, FC-5).
169            // The box spans the silhouette's full value extent at ±half_w.
170            let (gmin, gmax) = (
171                d.grid.first().copied().unwrap_or(*median),
172                d.grid.last().copied().unwrap_or(*median),
173            );
174            let body_rect = Rect::from_min_max(
175                Pos2::new(cx - half_w, y_of(gmax)),
176                Pos2::new(cx + half_w, y_of(gmin)),
177            );
178            a11y_node(
179                ui,
180                base,
181                name,
182                Sense::hover(),
183                body_rect,
184                Semantics::image(name.clone()).value(*median),
185            );
186            // Median line across the body.
187            let mw = {
188                // density at the median, for the line half-width
189                let idx = d.grid.iter().enumerate().min_by(|a, b| {
190                    (a.1 - median).abs().partial_cmp(&(b.1 - median).abs()).unwrap()
191                }).map(|(i, _)| i).unwrap_or(0);
192                (d.dens[idx] / peak) as f32 * half_w
193            };
194            painter.line_segment([Pos2::new(cx - mw, y_of(*median)), Pos2::new(cx + mw, y_of(*median))], Stroke::new(2.0, th.glow));
195            painter.text(Pos2::new(cx, plot.bottom() + 12.0), Align2::CENTER_CENTER, name, FontId::proportional(11.0), th.text);
196
197            if let Some(hp) = hover {
198                if hp.x >= cx - slot * 0.5 && hp.x < cx + slot * 0.5 && plot.y_range().contains(hp.y) {
199                    // map cursor y back to a value
200                    let v = vmin + ((plot.bottom() - hp.y) / plot.height()) as f64 * span;
201                    tip = Some((hp, format!("{name}\nvalue {v:.3}\nmedian {median:.3}\npeak {:.4}", d.peak)));
202                }
203            }
204        }
205        if let Some((at, text)) = tip {
206            paint_tooltip(&painter, &th, at, &text);
207        }
208    }
209    fn state_json(&self) -> serde_json::Value {
210        serde_json::json!({
211            "kind": "violin",
212            "bins": self.bins,
213            "groups": self.densities().iter().map(|(n, d, m)| serde_json::json!({
214                "name": n, "median": m, "peak": d.peak, "grid": d.grid.len(),
215            })).collect::<Vec<_>>(),
216        })
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn kde_grid_and_density_lengths_match_bins() {
226        let d = kde(&[1.0, 2.0, 2.0, 3.0, 5.0], 32).unwrap();
227        assert_eq!(d.grid.len(), 32);
228        assert_eq!(d.dens.len(), 32);
229        assert!(d.peak > 0.0);
230    }
231
232    #[test]
233    fn kde_density_is_nonnegative_and_peaks_near_mode() {
234        // bimodal-ish: cluster at 0 and at 10
235        let mut data = vec![];
236        for _ in 0..20 { data.push(0.0); }
237        for _ in 0..20 { data.push(10.0); }
238        let d = kde(&data, 100).unwrap();
239        assert!(d.dens.iter().all(|&x| x >= 0.0));
240        // the densest grid point should sit near one of the two clusters
241        let (peak_i, _) = d.dens.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap();
242        let peak_x = d.grid[peak_i];
243        assert!(peak_x.abs() < 2.0 || (peak_x - 10.0).abs() < 2.0, "peak at {peak_x} should be near 0 or 10");
244    }
245
246    #[test]
247    fn kde_integrates_to_about_one() {
248        // A proper density integrates to ~1 over a wide grid.
249        let data: Vec<f64> = (0..200).map(|i| (i as f64) * 0.05).collect();
250        let d = kde(&data, 400).unwrap();
251        let dx = d.grid[1] - d.grid[0];
252        let area: f64 = d.dens.iter().sum::<f64>() * dx;
253        assert!((area - 1.0).abs() < 0.05, "area {area} should be ~1");
254    }
255
256    #[test]
257    fn kde_empty_is_none() {
258        assert!(kde(&[], 16).is_none());
259        assert!(kde(&[f64::NAN], 16).is_none());
260    }
261
262    #[test]
263    fn silverman_positive_even_for_constant_sample() {
264        assert!(silverman_bandwidth(&[5.0, 5.0, 5.0]) > 0.0);
265        assert!(silverman_bandwidth(&[]) > 0.0);
266    }
267
268    #[test]
269    fn csv_header_and_rows() {
270        let v = ViolinView::new("t", vec![("a".into(), vec![1.0, 2.0, 3.0])]);
271        let csv = v.to_csv();
272        assert!(csv.starts_with("group,n,median,peak_density\n"));
273        assert_eq!(csv.lines().count(), 2);
274    }
275}