use crate::core::Result;
use crate::core::style_utils::StyleResolver;
use crate::plots::traits::{PlotArea, PlotCompute, PlotConfig, PlotData, PlotRender};
use crate::render::skia::SkiaRenderer;
use crate::render::{Color, LineStyle, Theme};
use crate::stats::kde::{KdeResult, kde_1d, scotts_rule, silvermans_rule};
#[allow(deprecated)] #[derive(Debug, Clone)]
pub struct ViolinConfig {
pub n_points: usize,
pub bandwidth: BandwidthMethod,
pub show_box: bool,
pub show_quartiles: bool,
pub show_median: bool,
pub show_points: bool,
pub split: bool,
#[deprecated(
since = "0.6.0",
note = "not yet implemented; tracked for a future release. Violins are always width-normalised (ViolinScale::Width)"
)]
pub scale: ViolinScale,
pub width: f64,
pub orientation: Orientation,
pub fill_color: Option<Color>,
pub fill_alpha: f32,
pub line_color: Option<Color>,
pub line_width: f32,
pub inner_color: Color,
pub category: Option<String>,
pub x_position: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum BandwidthMethod {
#[default]
Scott,
Silverman,
Fixed(f64),
}
impl From<f64> for BandwidthMethod {
fn from(value: f64) -> Self {
BandwidthMethod::Fixed(value)
}
}
impl BandwidthMethod {
pub fn resolve(self, data: &[f64]) -> f64 {
match self {
BandwidthMethod::Fixed(bw) if crate::stats::kde::is_valid_bandwidth(bw) => bw,
BandwidthMethod::Fixed(_) | BandwidthMethod::Scott => scotts_rule(data),
BandwidthMethod::Silverman => silvermans_rule(data),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViolinScale {
Area,
#[default]
Width,
Count,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Orientation {
#[default]
Vertical,
Horizontal,
}
impl Default for ViolinConfig {
#[allow(deprecated)] fn default() -> Self {
Self {
n_points: 100,
bandwidth: BandwidthMethod::Scott,
show_box: true,
show_quartiles: true,
show_median: true,
show_points: false,
split: false,
scale: ViolinScale::Width,
width: 0.8,
orientation: Orientation::Vertical,
fill_color: None,
fill_alpha: 0.7,
line_color: None,
line_width: 1.0,
inner_color: Color::from_rgb(51, 51, 51), category: None,
x_position: None,
}
}
}
impl ViolinConfig {
pub fn new() -> Self {
Self::default()
}
pub fn n_points(mut self, n: usize) -> Self {
self.n_points = n.max(10);
self
}
pub fn bandwidth(mut self, method: impl Into<BandwidthMethod>) -> Self {
self.bandwidth = method.into();
self
}
pub fn box_plot(mut self, show: bool) -> Self {
self.show_box = show;
self
}
pub fn quartiles(mut self, show: bool) -> Self {
self.show_quartiles = show;
self
}
pub fn median(mut self, show: bool) -> Self {
self.show_median = show;
self
}
pub fn points(mut self, show: bool) -> Self {
self.show_points = show;
self
}
pub fn split(mut self, split: bool) -> Self {
self.split = split;
self
}
#[deprecated(
since = "0.6.0",
note = "not yet implemented; tracked for a future release. Violins are always width-normalised (ViolinScale::Width)"
)]
#[allow(deprecated)]
pub fn scale(mut self, scale: ViolinScale) -> Self {
self.scale = scale;
self
}
pub fn width(mut self, width: f64) -> Self {
self.width = width.max(0.1);
self
}
pub fn horizontal(mut self) -> Self {
self.orientation = Orientation::Horizontal;
self
}
pub fn vertical(mut self) -> Self {
self.orientation = Orientation::Vertical;
self
}
pub fn fill_color(mut self, color: Color) -> Self {
self.fill_color = Some(color);
self
}
pub fn fill_alpha(mut self, alpha: f32) -> Self {
self.fill_alpha = alpha.clamp(0.0, 1.0);
self
}
pub fn line_color(mut self, color: Color) -> Self {
self.line_color = Some(color);
self
}
pub fn line_width(mut self, width: f32) -> Self {
self.line_width = width.max(0.0);
self
}
}
impl PlotConfig for ViolinConfig {}
pub struct Violin;
#[derive(Debug, Clone)]
pub struct ViolinData {
pub data: Vec<f64>,
pub kde: KdeResult,
pub quartiles: (f64, f64, f64),
pub range: (f64, f64),
pub(crate) config: ViolinConfig,
}
impl ViolinData {
pub fn from_values(data: &[f64], config: &ViolinConfig) -> Option<Self> {
if data.is_empty() {
return None;
}
let mut sorted: Vec<f64> = data.iter().filter(|v| v.is_finite()).copied().collect();
if sorted.is_empty() {
return None;
}
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = sorted.len();
let min = sorted[0];
let max = sorted[n - 1];
let bandwidth = Some(config.bandwidth.resolve(&sorted));
let kde = kde_1d(&sorted, bandwidth, Some(config.n_points));
let q1 = percentile(&sorted, 25.0);
let median = percentile(&sorted, 50.0);
let q3 = percentile(&sorted, 75.0);
Some(Self {
data: sorted,
kde,
quartiles: (q1, median, q3),
range: (min, max),
config: config.clone(),
})
}
pub fn max_density(&self) -> f64 {
self.kde.density.iter().copied().fold(0.0, f64::max)
}
fn draw_points(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
center: f64,
color: Color,
alpha: f32,
) -> Result<()> {
if !self.config.show_points || self.data.is_empty() {
return Ok(());
}
let clip_rect = (area.x, area.y, area.width, area.height);
let size = renderer
.render_scale()
.points_to_pixels(VIOLIN_POINT_SIZE_PT);
let point_color =
color.with_alpha((f32::from(color.a) / 255.0) * alpha.clamp(0.0, 1.0) * 0.75);
for &value in &self.data {
if !value.is_finite() {
continue;
}
let projected = match self.config.orientation {
Orientation::Vertical => area.try_data_to_screen(center, value),
Orientation::Horizontal => area.try_data_to_screen(value, center),
};
let Some((px, py)) = projected else {
continue;
};
renderer.draw_marker_clipped(
px,
py,
size,
crate::render::MarkerStyle::Circle,
point_color,
clip_rect,
)?;
}
Ok(())
}
}
const VIOLIN_POINT_SIZE_PT: f32 = 2.5;
fn percentile(sorted: &[f64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let n = sorted.len();
let idx = (p / 100.0) * (n - 1) as f64;
let lower = idx.floor() as usize;
let upper = idx.ceil() as usize;
let frac = idx - lower as f64;
if lower >= n || upper >= n {
sorted[n - 1]
} else {
sorted[lower] * (1.0 - frac) + sorted[upper] * frac
}
}
#[allow(clippy::type_complexity)]
pub fn violin_polygon(
violin: &ViolinData,
center: f64,
half_width: f64,
config: &ViolinConfig,
) -> (Vec<(f64, f64)>, Vec<(f64, f64)>) {
let max_density = violin.max_density();
if max_density <= 0.0 {
return (vec![], vec![]);
}
let scale = half_width / max_density;
let mut left_side = Vec::with_capacity(violin.kde.x.len());
let mut right_side = Vec::with_capacity(violin.kde.x.len());
for (i, (&x, &d)) in violin
.kde
.x
.iter()
.zip(violin.kde.density.iter())
.enumerate()
{
let width = d * scale;
match config.orientation {
Orientation::Vertical => {
if config.split {
left_side.push((center, x));
right_side.push((center + width, x));
} else {
left_side.push((center - width, x));
right_side.push((center + width, x));
}
}
Orientation::Horizontal => {
if config.split {
left_side.push((x, center));
right_side.push((x, center + width));
} else {
left_side.push((x, center - width));
right_side.push((x, center + width));
}
}
}
}
(left_side, right_side)
}
pub fn close_violin_polygon(left: &[(f64, f64)], right: &[(f64, f64)]) -> Vec<(f64, f64)> {
if left.is_empty() || right.is_empty() {
return vec![];
}
let mut polygon = Vec::with_capacity(left.len() + right.len());
polygon.extend_from_slice(left);
for point in right.iter().rev() {
polygon.push(*point);
}
polygon
}
impl PlotCompute for Violin {
type Input<'a> = &'a [f64];
type Config = ViolinConfig;
type Output = ViolinData;
fn compute(input: Self::Input<'_>, config: &Self::Config) -> Result<Self::Output> {
ViolinData::from_values(input, config).ok_or(crate::core::PlottingError::EmptyDataSet)
}
}
impl PlotData for ViolinData {
fn data_bounds(&self) -> ((f64, f64), (f64, f64)) {
let kde_range = if self.kde.x.is_empty() {
self.range
} else {
let kde_min = self.kde.x.first().copied().unwrap_or(self.range.0);
let kde_max = self.kde.x.last().copied().unwrap_or(self.range.1);
(kde_min, kde_max)
};
let slot = crate::plots::boxplot::category_slot_span(self.config.x_center());
match self.config.orientation {
Orientation::Vertical => (slot, kde_range),
Orientation::Horizontal => (kde_range, slot),
}
}
fn is_empty(&self) -> bool {
self.data.is_empty()
}
}
impl PlotRender for ViolinData {
fn render(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
_theme: &Theme,
color: Color,
) -> Result<()> {
if self.data.is_empty() {
return Ok(());
}
let config = &self.config;
let half_width = config.width / 2.0;
let render_scale = renderer.render_scale();
let line_width_px = render_scale.points_to_pixels(config.line_width);
let min_box_width_px = render_scale.points_to_pixels(4.0);
let inner_line_width_px = render_scale.points_to_pixels(1.0);
let median_marker_size_px = render_scale.points_to_pixels(4.0);
let center = config.x_center();
let (left, right) = violin_polygon(self, center, half_width, config);
let polygon = close_violin_polygon(&left, &right);
if polygon.is_empty() {
return Ok(());
}
let screen_points: Vec<(f32, f32)> = area.project_points(polygon.iter().copied());
let clip_rect = (area.x, area.y, area.width, area.height);
if screen_points.len() >= 3 {
let fill_color = config
.fill_color
.unwrap_or(color)
.with_alpha(config.fill_alpha);
renderer.draw_filled_polygon_clipped(&screen_points, fill_color, clip_rect)?;
}
let line_color = config.line_color.unwrap_or(color);
if screen_points.len() >= 2 && config.line_width > 0.0 {
let mut outline = screen_points.clone();
outline.push(screen_points[0]); renderer.draw_polyline_clipped(
&outline,
line_color,
line_width_px,
LineStyle::Solid,
clip_rect,
)?;
}
let (q1, median, q3) = self.quartiles;
self.draw_points(renderer, area, center, line_color, 1.0)?;
if config.show_box {
let box_half_width = half_width * 0.025;
let (x1, y1) = area.data_to_screen(center - box_half_width, q1);
let (x2, y2) = area.data_to_screen(center + box_half_width, q3);
let box_x = x1.min(x2);
let box_y = y1.min(y2);
let box_width = (x2 - x1).abs().max(min_box_width_px);
let box_height = (y2 - y1).abs();
renderer.draw_rectangle(
box_x,
box_y,
box_width,
box_height,
config.inner_color,
true,
)?;
}
if config.show_quartiles {
let line_half = half_width * 0.12;
let (q1_x1, q1_y) = area.data_to_screen(center - line_half, q1);
let (q1_x2, _) = area.data_to_screen(center + line_half, q1);
renderer.draw_line(
q1_x1,
q1_y,
q1_x2,
q1_y,
config.inner_color,
inner_line_width_px,
LineStyle::Solid,
)?;
let (q3_x1, q3_y) = area.data_to_screen(center - line_half, q3);
let (q3_x2, _) = area.data_to_screen(center + line_half, q3);
renderer.draw_line(
q3_x1,
q3_y,
q3_x2,
q3_y,
config.inner_color,
inner_line_width_px,
LineStyle::Solid,
)?;
}
if config.show_median {
let (mx, my) = area.data_to_screen(center, median);
renderer.draw_marker(
mx,
my,
median_marker_size_px,
crate::render::MarkerStyle::Circle,
Color::from_rgb(255, 255, 255),
)?;
}
Ok(())
}
fn render_styled(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
alpha: f32,
line_width: Option<f32>,
) -> Result<()> {
if self.data.is_empty() {
return Ok(());
}
let config = &self.config;
let resolver = StyleResolver::new(theme);
let half_width = config.width / 2.0;
let render_scale = renderer.render_scale();
let min_box_width_px = render_scale.points_to_pixels(4.0);
let inner_line_width_px = render_scale.points_to_pixels(1.0);
let median_marker_size_px = render_scale.points_to_pixels(4.0);
let center = config.x_center();
let (left, right) = violin_polygon(self, center, half_width, config);
let polygon = close_violin_polygon(&left, &right);
if polygon.is_empty() {
return Ok(());
}
let screen_points: Vec<(f32, f32)> = area.project_points(polygon.iter().copied());
let clip_rect = (area.x, area.y, area.width, area.height);
let fill_base = config.fill_color.unwrap_or(color);
let fill_alpha =
(f32::from(fill_base.a) / 255.0) * config.fill_alpha * alpha.clamp(0.0, 1.0);
let fill_color = fill_base.with_alpha(fill_alpha);
if screen_points.len() >= 3 {
renderer.draw_filled_polygon_clipped(&screen_points, fill_color, clip_rect)?;
}
let actual_line_width = render_scale.points_to_pixels(
line_width.unwrap_or_else(|| resolver.line_width(Some(config.line_width))),
);
let line_color = resolver.edge_color(color, config.line_color);
let line_color =
line_color.with_alpha((f32::from(line_color.a) / 255.0) * alpha.clamp(0.0, 1.0));
if screen_points.len() >= 2 && actual_line_width > 0.0 {
let mut outline = screen_points.clone();
outline.push(screen_points[0]); renderer.draw_polyline_clipped(
&outline,
line_color,
actual_line_width,
LineStyle::Solid,
clip_rect,
)?;
}
let (q1, median, q3) = self.quartiles;
self.draw_points(renderer, area, center, line_color, 1.0)?;
if config.show_box {
let box_half_width = half_width * 0.025;
let (x1, y1) = area.data_to_screen(center - box_half_width, q1);
let (x2, y2) = area.data_to_screen(center + box_half_width, q3);
let box_x = x1.min(x2);
let box_y = y1.min(y2);
let box_width = (x2 - x1).abs().max(min_box_width_px);
let box_height = (y2 - y1).abs();
renderer.draw_rectangle(
box_x,
box_y,
box_width,
box_height,
config.inner_color,
true,
)?;
}
if config.show_quartiles {
let line_half = half_width * 0.12;
let (q1_x1, q1_y) = area.data_to_screen(center - line_half, q1);
let (q1_x2, _) = area.data_to_screen(center + line_half, q1);
renderer.draw_line(
q1_x1,
q1_y,
q1_x2,
q1_y,
config.inner_color,
inner_line_width_px,
LineStyle::Solid,
)?;
let (q3_x1, q3_y) = area.data_to_screen(center - line_half, q3);
let (q3_x2, _) = area.data_to_screen(center + line_half, q3);
renderer.draw_line(
q3_x1,
q3_y,
q3_x2,
q3_y,
config.inner_color,
inner_line_width_px,
LineStyle::Solid,
)?;
}
if config.show_median {
let (mx, my) = area.data_to_screen(center, median);
renderer.draw_marker(
mx,
my,
median_marker_size_px,
crate::render::MarkerStyle::Circle,
Color::from_rgb(255, 255, 255),
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_violin_data_basic() {
let data: Vec<f64> = (0..100).map(|i| (i as f64 * 0.1).sin()).collect();
let config = ViolinConfig::default();
let violin = ViolinData::from_values(&data, &config);
assert!(violin.is_some());
let violin = violin.unwrap();
assert!(!violin.kde.x.is_empty());
assert!(violin.max_density() > 0.0);
}
#[test]
fn test_violin_data_empty() {
let data: Vec<f64> = vec![];
let config = ViolinConfig::default();
let violin = ViolinData::from_values(&data, &config);
assert!(violin.is_none());
}
#[test]
fn test_violin_polygon() {
let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
let config = ViolinConfig::default();
let violin = ViolinData::from_values(&data, &config).unwrap();
let (left, right) = violin_polygon(&violin, 0.5, 0.3, &config);
assert!(!left.is_empty());
assert!(!right.is_empty());
assert_eq!(left.len(), right.len());
}
#[test]
fn test_close_polygon() {
let left = vec![(0.0, 0.0), (0.0, 1.0), (0.0, 2.0)];
let right = vec![(1.0, 0.0), (1.0, 1.0), (1.0, 2.0)];
let closed = close_violin_polygon(&left, &right);
assert_eq!(closed.len(), 6);
}
#[test]
fn test_percentile() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert!((percentile(&data, 50.0) - 3.0).abs() < 1e-10);
assert!((percentile(&data, 0.0) - 1.0).abs() < 1e-10);
assert!((percentile(&data, 100.0) - 5.0).abs() < 1e-10);
}
#[test]
fn test_violin_config_implements_plot_config() {
fn assert_plot_config<T: PlotConfig>() {}
assert_plot_config::<ViolinConfig>();
}
#[test]
fn test_violin_plot_compute_trait() {
use crate::plots::traits::PlotCompute;
let data: Vec<f64> = (0..100).map(|i| (i as f64 * 0.1).sin()).collect();
let config = ViolinConfig::default();
let result = Violin::compute(&data, &config);
assert!(result.is_ok());
let violin_data = result.unwrap();
assert!(!violin_data.data.is_empty());
assert!(violin_data.max_density() > 0.0);
}
#[test]
fn test_violin_plot_compute_empty() {
use crate::plots::traits::PlotCompute;
let data: Vec<f64> = vec![];
let config = ViolinConfig::default();
let result = Violin::compute(&data, &config);
assert!(result.is_err());
}
fn skewed_sample() -> Vec<f64> {
(1..=200).map(|i| ((i as f64) / 20.0).exp()).collect()
}
#[test]
fn test_silverman_is_not_silently_scott() {
let data = skewed_sample();
let scott = ViolinData::from_values(&data, &ViolinConfig::new()).unwrap();
let silverman = ViolinData::from_values(
&data,
&ViolinConfig::new().bandwidth(BandwidthMethod::Silverman),
)
.unwrap();
assert!(
(scott.kde.bandwidth - silverman.kde.bandwidth).abs() > 1e-9,
"Silverman ({}) collapsed onto Scott ({})",
silverman.kde.bandwidth,
scott.kde.bandwidth
);
}
#[test]
fn test_bandwidth_methods_resolve_to_their_named_estimator() {
let data = skewed_sample();
let mut sorted = data.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(
BandwidthMethod::Scott.resolve(&sorted),
crate::stats::kde::scotts_rule(&sorted)
);
assert_eq!(
BandwidthMethod::Silverman.resolve(&sorted),
crate::stats::kde::silvermans_rule(&sorted)
);
assert_eq!(BandwidthMethod::Fixed(0.25).resolve(&sorted), 0.25);
}
#[test]
fn test_fixed_bandwidth_reaches_the_kde() {
let data = skewed_sample();
let violin = ViolinData::from_values(
&data,
&ViolinConfig::new().bandwidth(BandwidthMethod::Fixed(0.5)),
)
.unwrap();
assert!((violin.kde.bandwidth - 0.5).abs() < 1e-12);
}
#[test]
fn test_degenerate_fixed_bandwidth_falls_back_instead_of_producing_nan() {
let data = skewed_sample();
for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
let violin = ViolinData::from_values(
&data,
&ViolinConfig::new().bandwidth(BandwidthMethod::Fixed(bad)),
)
.unwrap();
assert!(violin.kde.bandwidth.is_finite() && violin.kde.bandwidth > 0.0);
assert!(violin.kde.density.iter().all(|d| d.is_finite()));
}
}
fn render_violin(config: ViolinConfig) -> crate::core::Result<crate::core::plot::Image> {
let data: Vec<f64> = (0..60).map(|i| i as f64 / 6.0).collect();
let violin = ViolinData::from_values(&data, &config).unwrap();
let mut renderer = SkiaRenderer::new(200, 200, Theme::default())?;
let ((_, _), (y_min, y_max)) = violin.data_bounds();
let area = PlotArea::new(0.0, 0.0, 200.0, 200.0, 0.0, 1.0, y_min, y_max);
violin.render(
&mut renderer,
&area,
&Theme::default(),
Color::from_rgb(0, 0, 255),
)?;
Ok(renderer.into_image())
}
#[test]
fn test_show_points_changes_the_rendered_image() {
let without = render_violin(ViolinConfig::new().points(false)).unwrap();
let with = render_violin(ViolinConfig::new().points(true)).unwrap();
assert_ne!(
without.pixels, with.pixels,
"ViolinConfig::points(true) produced a byte-identical image"
);
}
#[test]
fn test_show_points_is_off_by_default() {
let default = render_violin(ViolinConfig::new()).unwrap();
let explicit_off = render_violin(ViolinConfig::new().points(false)).unwrap();
assert_eq!(default.pixels, explicit_off.pixels);
}
#[test]
fn test_violin_plot_data_trait() {
use crate::plots::traits::PlotData;
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
let config = ViolinConfig::default();
let violin_data = ViolinData::from_values(&data, &config).unwrap();
let ((x_min, x_max), (y_min, y_max)) = violin_data.data_bounds();
assert!(x_min <= x_max);
assert!(y_min <= y_max);
assert!(!violin_data.is_empty());
}
#[test]
fn test_violin_sits_in_its_own_category_slot() {
use crate::plots::traits::PlotData as _;
let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
let first = ViolinData::from_values(&data, &ViolinConfig::new()).unwrap();
assert_eq!(first.config.x_center(), 0.0);
let ((x_min, x_max), _) = first.data_bounds();
assert_eq!((x_min, x_max), (-0.5, 0.5));
let second = ViolinData::from_values(&data, &ViolinConfig::new().x_position(1.0)).unwrap();
let ((x_min, x_max), _) = second.data_bounds();
assert_eq!((x_min, x_max), (0.5, 1.5));
}
#[test]
fn test_violin_polygon_follows_the_slot_it_was_given() {
let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
let config = ViolinConfig::new().x_position(2.0);
let violin = ViolinData::from_values(&data, &config).unwrap();
let (left, right) = violin_polygon(&violin, config.x_center(), 0.4, &config);
for (x, _) in left.iter().chain(right.iter()) {
assert!(
(1.6..=2.4).contains(x),
"vertex {x} escaped the slot centred on 2.0"
);
}
}
#[test]
fn test_horizontal_violin_puts_its_slot_on_the_y_axis() {
use crate::plots::traits::PlotData as _;
let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
let violin =
ViolinData::from_values(&data, &ViolinConfig::new().horizontal().x_position(1.0))
.unwrap();
assert_eq!(violin.data_bounds().1, (0.5, 1.5));
}
}