use crate::artist::{Line2D, Patch};
use crate::core::color::Rgba;
use crate::figure::Axes;
const GRID_POINTS: usize = 128;
const HALF_WIDTH: f64 = 0.4;
const X_MARGIN: f64 = 0.5;
const BODY_ALPHA: f64 = 0.3;
fn std_dev(data: &[f64]) -> Option<f64> {
let n = data.len();
if n < 2 {
return None;
}
let mean = data.iter().sum::<f64>() / n as f64;
let var = data.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
Some(var.sqrt())
}
fn kde(data: &[f64], bw: f64, p: f64) -> f64 {
let norm = 1.0 / (data.len() as f64 * bw * (2.0 * std::f64::consts::PI).sqrt());
let sum: f64 = data
.iter()
.map(|&x| {
let z = (p - x) / bw;
(-0.5 * z * z).exp()
})
.sum();
norm * sum
}
impl Axes {
pub fn violinplot(&mut self, data: &[&[f64]], positions: Option<&[f64]>) -> &mut Self {
for (i, dataset) in data.iter().enumerate() {
let pos = positions
.and_then(|p| p.get(i).copied())
.unwrap_or((i + 1) as f64);
let finite: Vec<f64> = dataset.iter().copied().filter(|v| v.is_finite()).collect();
if finite.is_empty() {
continue;
}
let dmin = finite.iter().copied().fold(f64::INFINITY, f64::min);
let dmax = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
self.add_line(Line2D::new(vec![pos, pos], vec![dmin, dmax]).with_color(Rgba::BLACK));
self.include_data_bbox(pos - X_MARGIN, dmin, pos + X_MARGIN, dmax);
let Some(sd) = std_dev(&finite) else {
continue;
};
if sd <= 0.0 || dmax <= dmin {
continue;
}
let n = finite.len();
let bw = sd * (n as f64).powf(-0.2);
let mut grid = Vec::with_capacity(GRID_POINTS);
let mut density = Vec::with_capacity(GRID_POINTS);
let mut peak = 0.0_f64;
for j in 0..GRID_POINTS {
let t = j as f64 / (GRID_POINTS - 1) as f64;
let y = dmin + (dmax - dmin) * t;
let d = kde(&finite, bw, y);
peak = peak.max(d);
grid.push(y);
density.push(d);
}
if peak <= 0.0 {
continue;
}
let mut verts = Vec::with_capacity(2 * GRID_POINTS);
for j in 0..GRID_POINTS {
let hw = HALF_WIDTH * density[j] / peak;
verts.push([pos + hw, grid[j]]);
}
for j in (0..GRID_POINTS).rev() {
let hw = HALF_WIDTH * density[j] / peak;
verts.push([pos - hw, grid[j]]);
}
let color = self.next_cycle_color();
self.add_patch(
Patch::polygon(&verts)
.facecolor(Some(color.with_alpha(BODY_ALPHA)))
.edgecolor(Some(color)),
);
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Bbox;
fn approx(a: f64, b: f64) {
assert!((a - b).abs() < 1e-9, "expected {b}, got {a}");
}
fn right_half_widths(patch: &Patch, pos: f64) -> Vec<f64> {
let verts = patch.path().vertices();
let n = (verts.len() - 1) / 2;
verts[..n].iter().map(|v| v[0] - pos).collect()
}
#[test]
fn violinplot_body_is_left_right_symmetric() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
ax.violinplot(&[&data], None);
assert_eq!(ax.patches.len(), 1);
let verts = ax.patches[0].path().vertices();
let n = (verts.len() - 1) / 2;
for j in 0..n {
let right = verts[j];
let left = verts[2 * n - 1 - j];
approx(right[1], left[1]);
approx(right[0] - 1.0, 1.0 - left[0]);
}
}
#[test]
fn violinplot_normalizes_peak_and_concentration_widens_center() {
let mut tight = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let mut spread = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let concentrated = [0.0, 0.0, 0.0, 0.0, 0.1, -0.1, 0.05, -0.05, 1.0, -1.0];
let uniform = [-1.0, -0.6, -0.2, 0.2, 0.6, 1.0, -0.8, 0.8, -0.4, 0.4];
tight.violinplot(&[&concentrated], None);
spread.violinplot(&[&uniform], None);
let tight_hw = right_half_widths(&tight.patches[0], 1.0);
let spread_hw = right_half_widths(&spread.patches[0], 1.0);
let max = |v: &[f64]| v.iter().copied().fold(0.0_f64, f64::max);
approx(max(&tight_hw), HALF_WIDTH);
approx(max(&spread_hw), HALF_WIDTH);
let mid = GRID_POINTS / 2;
assert!(
tight_hw[mid] > spread_hw[mid],
"concentrated mid {} should exceed spread mid {}",
tight_hw[mid],
spread_hw[mid]
);
}
#[test]
fn violinplot_positions_and_limits_span_data() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let a = [1.0, 2.0, 3.0, 4.0, 5.0];
let b = [10.0, 11.0, 12.0, 13.0, 14.0];
ax.violinplot(&[&a, &b], None);
let e = ax
.data_limits()
.expect("violinplot contributes data limits");
approx(e.xmin(), 0.5);
approx(e.xmax(), 2.5);
approx(e.ymin(), 1.0);
approx(e.ymax(), 14.0);
}
#[test]
fn violinplot_custom_positions_are_honored() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let a = [1.0, 2.0, 3.0, 4.0, 5.0];
let b = [2.0, 3.0, 4.0, 5.0, 6.0];
ax.violinplot(&[&a, &b], Some(&[3.0, 7.0]));
let e = ax
.data_limits()
.expect("violinplot contributes data limits");
approx(e.xmin(), 2.5);
approx(e.xmax(), 7.5);
}
#[test]
fn violinplot_degenerate_dataset_does_not_panic() {
let mut ax = Axes::new(Bbox::from_extents(0.0, 0.0, 1.0, 1.0));
let single = [4.0];
let constant = [3.0, 3.0, 3.0];
let empty: [f64; 0] = [];
ax.violinplot(&[&single, &constant, &empty], None);
assert!(ax.patches.is_empty());
assert_eq!(ax.lines.len(), 2);
let e = ax
.data_limits()
.expect("violinplot contributes data limits");
assert!(e.xmin().is_finite() && e.xmax().is_finite());
assert!(e.ymin().is_finite() && e.ymax().is_finite());
}
}