#![allow(clippy::cast_precision_loss)]
use starsight_layer_1::backends::DrawBackend;
use starsight_layer_1::errors::Result;
use starsight_layer_1::paths::{Path, PathStyle};
use starsight_layer_1::primitives::{Color, Point, Rect};
use starsight_layer_2::coords::{CartesianCoord, Coord};
use crate::marks::{DataExtent, LegendGlyph, Mark, Orientation};
use crate::statistics::BoxPlotStats;
#[derive(Clone, Debug, PartialEq)]
pub struct BoxPlotGroup {
pub label: String,
pub data: Vec<f64>,
}
impl BoxPlotGroup {
#[must_use]
pub fn new(label: impl Into<String>, data: Vec<f64>) -> Self {
Self {
label: label.into(),
data,
}
}
}
#[derive(Clone, Debug)]
pub struct BoxPlotMark {
pub groups: Vec<BoxPlotGroup>,
pub color: Color,
pub palette: Option<Vec<Color>>,
pub half_width: f32,
pub show_outliers: bool,
pub label: Option<String>,
cached_x_labels: Vec<String>,
}
impl BoxPlotMark {
#[must_use]
pub fn new(groups: Vec<BoxPlotGroup>) -> Self {
let cached_x_labels = groups.iter().map(|g| g.label.clone()).collect();
Self {
groups,
color: Color::BLUE,
palette: None,
half_width: 0.35,
show_outliers: true,
label: None,
cached_x_labels,
}
}
#[must_use]
pub fn color(mut self, c: Color) -> Self {
self.color = c;
self.palette = None;
self
}
#[must_use]
pub fn palette(mut self, palette: Vec<Color>) -> Self {
self.palette = Some(palette);
self
}
#[must_use]
pub fn half_width(mut self, w: f32) -> Self {
self.half_width = w;
self
}
#[must_use]
pub fn show_outliers(mut self, show: bool) -> Self {
self.show_outliers = show;
self
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
fn group_color(&self, i: usize) -> Color {
match self.palette.as_deref() {
Some(palette) if !palette.is_empty() => palette[i % palette.len()],
_ => self.color,
}
}
fn clamped_half_width(&self) -> f32 {
self.half_width.clamp(0.05, 0.5)
}
}
impl Mark for BoxPlotMark {
fn render(&self, coord: &dyn Coord, backend: &mut dyn DrawBackend) -> Result<()> {
let coord = crate::marks::require_cartesian(coord)?;
if self.groups.is_empty() {
return Ok(());
}
let area = &coord.plot_area;
let n = self.groups.len();
let band_width = area.width() / n as f32;
let half_width_px = band_width * self.clamped_half_width();
let cap_half = half_width_px * 0.5;
for (i, group) in self.groups.iter().enumerate() {
let center_x = area.left + (i as f32 + 0.5) * band_width;
let stats = BoxPlotStats::compute(&group.data);
self.render_one(
coord,
backend,
&stats,
center_x,
half_width_px,
cap_half,
self.group_color(i),
)?;
}
Ok(())
}
fn data_extent(&self) -> Option<DataExtent> {
if self.groups.is_empty() {
return None;
}
let mut y_min = f64::INFINITY;
let mut y_max = f64::NEG_INFINITY;
let mut any = false;
for group in &self.groups {
for &v in &group.data {
if v.is_nan() {
continue;
}
any = true;
if v < y_min {
y_min = v;
}
if v > y_max {
y_max = v;
}
}
}
if !any {
return None;
}
Some(DataExtent {
x_min: 0.0,
x_max: self.groups.len() as f64,
y_min,
y_max,
})
}
fn legend_color(&self) -> Option<Color> {
self.label.as_ref()?;
Some(
self.palette
.as_deref()
.and_then(|p| p.first().copied())
.unwrap_or(self.color),
)
}
fn legend_label(&self) -> Option<&str> {
self.label.as_deref()
}
fn legend_glyph(&self) -> LegendGlyph {
LegendGlyph::Bar
}
fn as_bar_info(&self) -> Option<(Option<&str>, Option<&str>, Orientation)> {
Some((None, None, Orientation::Vertical))
}
fn as_bar_data(&self) -> Option<(&[String], &[f64])> {
Some((&self.cached_x_labels, &[]))
}
}
impl BoxPlotMark {
#[allow(clippy::too_many_arguments)]
fn render_one(
&self,
coord: &CartesianCoord,
backend: &mut dyn DrawBackend,
stats: &BoxPlotStats,
center_x: f32,
half_width_px: f32,
cap_half: f32,
color: Color,
) -> Result<()> {
let area = &coord.plot_area;
let to_y =
|v: f64| -> f32 { area.bottom - coord.y_axis.scale.map(v) as f32 * area.height() };
let q1_px = to_y(stats.q1);
let q3_px = to_y(stats.q3);
let median_px = to_y(stats.median);
let min_px = to_y(stats.min);
let max_px = to_y(stats.max);
let body_top = q3_px.min(q1_px);
let body_bottom = q3_px.max(q1_px);
let body = Rect::new(
center_x - half_width_px,
body_top,
center_x + half_width_px,
body_bottom,
);
backend.fill_rect(body, color)?;
let outline = PathStyle::stroke(Color::BLACK, 1.0);
let outline_path = Path::new()
.move_to(Point::new(body.left, body.top))
.line_to(Point::new(body.right, body.top))
.line_to(Point::new(body.right, body.bottom))
.line_to(Point::new(body.left, body.bottom))
.close();
backend.draw_path(&outline_path, &outline)?;
let median_line = Path::new()
.move_to(Point::new(center_x - half_width_px, median_px))
.line_to(Point::new(center_x + half_width_px, median_px));
let median_style = PathStyle::stroke(Color::WHITE, 2.0);
backend.draw_path(&median_line, &median_style)?;
let whisker_style = PathStyle::stroke(Color::BLACK, 1.0);
let upper = Path::new()
.move_to(Point::new(center_x, body_top))
.line_to(Point::new(center_x, max_px));
backend.draw_path(&upper, &whisker_style)?;
let upper_cap = Path::new()
.move_to(Point::new(center_x - cap_half, max_px))
.line_to(Point::new(center_x + cap_half, max_px));
backend.draw_path(&upper_cap, &whisker_style)?;
let lower = Path::new()
.move_to(Point::new(center_x, body_bottom))
.line_to(Point::new(center_x, min_px));
backend.draw_path(&lower, &whisker_style)?;
let lower_cap = Path::new()
.move_to(Point::new(center_x - cap_half, min_px))
.line_to(Point::new(center_x + cap_half, min_px));
backend.draw_path(&lower_cap, &whisker_style)?;
if self.show_outliers {
for &v in &stats.outliers {
let py = to_y(v);
draw_outlier_dot(backend, Point::new(center_x, py), 3.0, Color::BLACK)?;
}
}
Ok(())
}
}
fn draw_outlier_dot(
backend: &mut dyn DrawBackend,
center: Point,
radius: f32,
color: Color,
) -> Result<()> {
use starsight_layer_1::paths::PathCommand;
let k = 0.552_284_8 * radius;
let cx = center.x;
let cy = center.y;
let mut path = Path::new().move_to(Point::new(cx + radius, cy));
path.commands.push(PathCommand::CubicTo(
Point::new(cx + radius, cy + k),
Point::new(cx + k, cy + radius),
Point::new(cx, cy + radius),
));
path.commands.push(PathCommand::CubicTo(
Point::new(cx - k, cy + radius),
Point::new(cx - radius, cy + k),
Point::new(cx - radius, cy),
));
path.commands.push(PathCommand::CubicTo(
Point::new(cx - radius, cy - k),
Point::new(cx - k, cy - radius),
Point::new(cx, cy - radius),
));
path.commands.push(PathCommand::CubicTo(
Point::new(cx + k, cy - radius),
Point::new(cx + radius, cy - k),
Point::new(cx + radius, cy),
));
backend.draw_path(&path, &PathStyle::fill(color))
}
#[cfg(test)]
mod tests {
use super::{BoxPlotGroup, BoxPlotMark};
use crate::marks::{LegendGlyph, Mark};
use starsight_layer_1::primitives::Color;
#[test]
fn data_extent_covers_outliers() {
let mark = BoxPlotMark::new(vec![BoxPlotGroup::new(
"A",
vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 100.0],
)]);
let extent = mark.data_extent().expect("non-empty extent");
assert_eq!(extent.x_min, 0.0);
assert_eq!(extent.x_max, 1.0);
assert!(extent.y_min <= 1.0);
assert!(extent.y_max >= 100.0);
}
#[test]
fn empty_groups_have_no_extent() {
let mark = BoxPlotMark::new(vec![]);
assert!(mark.data_extent().is_none());
}
#[test]
fn all_nan_groups_have_no_extent() {
let mark = BoxPlotMark::new(vec![BoxPlotGroup::new("A", vec![f64::NAN, f64::NAN])]);
assert!(mark.data_extent().is_none());
}
#[test]
fn legend_color_only_when_labelled() {
let unlabeled = BoxPlotMark::new(vec![BoxPlotGroup::new("A", vec![1.0, 2.0])]);
assert!(unlabeled.legend_color().is_none());
let labeled = unlabeled.color(Color::RED).label("samples");
assert_eq!(labeled.legend_color(), Some(Color::RED));
assert_eq!(labeled.legend_glyph(), LegendGlyph::Bar);
}
#[test]
fn palette_overrides_color_for_first_legend_swatch() {
let mark = BoxPlotMark::new(vec![BoxPlotGroup::new("A", vec![1.0, 2.0])])
.palette(vec![Color::GREEN, Color::BLUE])
.label("groups");
assert_eq!(mark.legend_color(), Some(Color::GREEN));
}
#[test]
fn half_width_clamps_to_safe_range() {
let mark = BoxPlotMark::new(vec![BoxPlotGroup::new("A", vec![1.0, 2.0])]).half_width(10.0);
assert!(mark.clamped_half_width() <= 0.5);
let tiny = BoxPlotMark::new(vec![BoxPlotGroup::new("A", vec![1.0, 2.0])]).half_width(-1.0);
assert!(tiny.clamped_half_width() >= 0.05);
}
}