use crate::core::Result;
use crate::plots::traits::{PlotArea, PlotCompute, PlotConfig, PlotData, PlotRender};
use crate::render::skia::SkiaRenderer;
use crate::render::{Color, LineStyle, Theme};
use crate::stats::quantile::letter_values;
#[derive(Debug, Clone)]
pub struct BoxenConfig {
pub k_depth: Option<usize>,
pub width: f64,
pub color: Option<Color>,
pub saturation: f32,
pub show_outliers: bool,
pub outlier_size: f32,
pub line_width: f32,
pub orient: BoxenOrientation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoxenOrientation {
Vertical,
Horizontal,
}
impl Default for BoxenConfig {
fn default() -> Self {
Self {
k_depth: None, width: 0.8,
color: None,
saturation: 0.75,
show_outliers: true,
outlier_size: 4.0,
line_width: 1.0,
orient: BoxenOrientation::Vertical,
}
}
}
impl BoxenConfig {
pub fn new() -> Self {
Self::default()
}
pub fn k_depth(mut self, k: usize) -> Self {
self.k_depth = Some(k.max(1));
self
}
pub fn width(mut self, width: f64) -> Self {
self.width = width.clamp(0.1, 1.0);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn show_outliers(mut self, show: bool) -> Self {
self.show_outliers = show;
self
}
pub fn horizontal(mut self) -> Self {
self.orient = BoxenOrientation::Horizontal;
self
}
}
impl PlotConfig for BoxenConfig {}
pub struct Boxen;
#[derive(Debug, Clone)]
pub struct BoxenBox {
pub level: usize,
pub lower: f64,
pub upper: f64,
pub width: f64,
}
#[derive(Debug, Clone)]
pub struct BoxenData {
pub boxes: Vec<BoxenBox>,
pub median: f64,
pub outliers: Vec<f64>,
pub data_range: (f64, f64),
pub(crate) config: BoxenConfig,
}
pub fn compute_boxen(data: &[f64], config: &BoxenConfig) -> BoxenData {
if data.is_empty() {
return BoxenData {
boxes: vec![],
median: 0.0,
outliers: vec![],
data_range: (0.0, 1.0),
config: config.clone(),
};
}
let mut sorted: Vec<f64> = data.iter().copied().filter(|x| x.is_finite()).collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
if sorted.is_empty() {
return BoxenData {
boxes: vec![],
median: 0.0,
outliers: vec![],
data_range: (0.0, 1.0),
config: config.clone(),
};
}
let n = sorted.len();
let k = config.k_depth.unwrap_or_else(|| {
((n as f64).log2().floor() as usize).clamp(1, 10)
});
let lvs = letter_values(&sorted, Some(k));
let mut boxes = Vec::new();
let num_levels = lvs.len();
for (level, (lower, upper)) in lvs.iter().enumerate() {
let width_factor = 1.0 - (level as f64 / (num_levels + 1) as f64) * 0.3;
boxes.push(BoxenBox {
level,
lower: *lower,
upper: *upper,
width: config.width * width_factor,
});
}
let median = if n.is_multiple_of(2) {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
} else {
sorted[n / 2]
};
let outliers = if config.show_outliers && !boxes.is_empty() {
let outer_lower = boxes[0].lower;
let outer_upper = boxes[0].upper;
sorted
.iter()
.copied()
.filter(|&x| x < outer_lower || x > outer_upper)
.collect()
} else {
vec![]
};
let data_range = (*sorted.first().unwrap(), *sorted.last().unwrap());
BoxenData {
boxes,
median,
outliers,
data_range,
config: config.clone(),
}
}
pub fn boxen_rect(boxen: &BoxenBox, center: f64, orient: BoxenOrientation) -> Vec<(f64, f64)> {
let half_width = boxen.width / 2.0;
match orient {
BoxenOrientation::Vertical => {
vec![
(center - half_width, boxen.lower),
(center + half_width, boxen.lower),
(center + half_width, boxen.upper),
(center - half_width, boxen.upper),
]
}
BoxenOrientation::Horizontal => {
vec![
(boxen.lower, center - half_width),
(boxen.upper, center - half_width),
(boxen.upper, center + half_width),
(boxen.lower, center + half_width),
]
}
}
}
impl PlotCompute for Boxen {
type Input<'a> = &'a [f64];
type Config = BoxenConfig;
type Output = BoxenData;
fn compute(input: Self::Input<'_>, config: &Self::Config) -> Result<Self::Output> {
let result = compute_boxen(input, config);
if result.boxes.is_empty() && input.iter().any(|x| x.is_finite()) {
Ok(result)
} else if result.boxes.is_empty() {
Err(crate::core::PlottingError::EmptyDataSet)
} else {
Ok(result)
}
}
}
impl PlotData for BoxenData {
fn data_bounds(&self) -> ((f64, f64), (f64, f64)) {
match self.config.orient {
BoxenOrientation::Vertical => {
let x_range = (0.0, 1.0); let y_range = self.data_range;
(x_range, y_range)
}
BoxenOrientation::Horizontal => {
let x_range = self.data_range;
let y_range = (0.0, 1.0); (x_range, y_range)
}
}
}
fn is_empty(&self) -> bool {
self.boxes.is_empty()
}
}
impl PlotRender for BoxenData {
fn render(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
_theme: &Theme,
color: Color,
) -> Result<()> {
if self.boxes.is_empty() {
return Ok(());
}
let config = &self.config;
let center = 0.5;
for (i, boxen_box) in self.boxes.iter().enumerate() {
let saturation_factor = 1.0 - (i as f32 / self.boxes.len() as f32) * config.saturation;
let box_color = config.color.unwrap_or(color);
let adjusted_color = adjust_saturation(box_color, saturation_factor);
let rect = boxen_rect(boxen_box, center, config.orient);
let screen_points: Vec<(f32, f32)> = rect
.iter()
.map(|(x, y)| area.data_to_screen(*x, *y))
.collect();
if screen_points.len() >= 3 {
renderer.draw_filled_polygon(&screen_points, adjusted_color)?;
}
if config.line_width > 0.0 {
let mut outline = screen_points.clone();
outline.push(screen_points[0]); renderer.draw_polyline(&outline, color, config.line_width, LineStyle::Solid)?;
}
}
let median_half = config.width / 4.0;
match config.orient {
BoxenOrientation::Vertical => {
let (x1, y) = area.data_to_screen(center - median_half, self.median);
let (x2, _) = area.data_to_screen(center + median_half, self.median);
renderer.draw_line(
x1,
y,
x2,
y,
Color::new(255, 255, 255),
2.0,
LineStyle::Solid,
)?;
}
BoxenOrientation::Horizontal => {
let (x, y1) = area.data_to_screen(self.median, center - median_half);
let (_, y2) = area.data_to_screen(self.median, center + median_half);
renderer.draw_line(
x,
y1,
x,
y2,
Color::new(255, 255, 255),
2.0,
LineStyle::Solid,
)?;
}
}
if config.show_outliers {
for &outlier in &self.outliers {
let (px, py) = match config.orient {
BoxenOrientation::Vertical => area.data_to_screen(center, outlier),
BoxenOrientation::Horizontal => area.data_to_screen(outlier, center),
};
renderer.draw_marker(
px,
py,
config.outlier_size,
crate::render::MarkerStyle::Circle,
color,
)?;
}
}
Ok(())
}
}
fn adjust_saturation(color: Color, factor: f32) -> Color {
let gray = ((color.r as f32 + color.g as f32 + color.b as f32) / 3.0) as u8;
let blend = |c: u8| -> u8 {
((c as f32 * factor + gray as f32 * (1.0 - factor)).clamp(0.0, 255.0)) as u8
};
Color::new_rgba(blend(color.r), blend(color.g), blend(color.b), color.a)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_boxen_basic() {
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
let config = BoxenConfig::default();
let boxen = compute_boxen(&data, &config);
assert!(!boxen.boxes.is_empty());
assert!((boxen.median - 49.5).abs() < 1e-10);
}
#[test]
fn test_boxen_nested_boxes() {
let data: Vec<f64> = (0..1000).map(|i| i as f64).collect();
let config = BoxenConfig::default().k_depth(5);
let boxen = compute_boxen(&data, &config);
assert_eq!(boxen.boxes.len(), 5);
for i in 1..boxen.boxes.len() {
assert!(boxen.boxes[i].width <= boxen.boxes[i - 1].width);
}
}
#[test]
fn test_boxen_rect_vertical() {
let box_data = BoxenBox {
level: 0,
lower: 10.0,
upper: 20.0,
width: 0.8,
};
let rect = boxen_rect(&box_data, 0.0, BoxenOrientation::Vertical);
assert_eq!(rect.len(), 4);
assert!((rect[0].1 - 10.0).abs() < 1e-10); assert!((rect[2].1 - 20.0).abs() < 1e-10); }
#[test]
fn test_boxen_empty() {
let data: Vec<f64> = vec![];
let config = BoxenConfig::default();
let boxen = compute_boxen(&data, &config);
assert!(boxen.boxes.is_empty());
}
#[test]
fn test_boxen_config_implements_plot_config() {
fn assert_plot_config<T: PlotConfig>() {}
assert_plot_config::<BoxenConfig>();
}
#[test]
fn test_boxen_plot_compute_trait() {
use crate::plots::traits::PlotCompute;
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
let config = BoxenConfig::default();
let result = Boxen::compute(&data, &config);
assert!(result.is_ok());
let boxen_data = result.unwrap();
assert!(!boxen_data.boxes.is_empty());
}
#[test]
fn test_boxen_plot_compute_empty() {
use crate::plots::traits::PlotCompute;
let data: Vec<f64> = vec![];
let config = BoxenConfig::default();
let result = Boxen::compute(&data, &config);
assert!(result.is_err());
}
#[test]
fn test_boxen_plot_data_trait() {
use crate::plots::traits::PlotData;
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
let config = BoxenConfig::default();
let boxen_data = compute_boxen(&data, &config);
let ((x_min, x_max), (y_min, y_max)) = boxen_data.data_bounds();
assert!(x_min <= x_max);
assert!(y_min <= y_max);
assert!(!boxen_data.is_empty());
}
#[test]
fn test_adjust_saturation() {
let color = Color::new(100, 150, 200);
let adjusted = super::adjust_saturation(color, 0.5);
assert!(adjusted.r > 0 && adjusted.r < 255);
assert!(adjusted.g > 0 && adjusted.g < 255);
assert!(adjusted.b > 0 && adjusted.b < 255);
}
}