use crate::core::Result;
use crate::plots::traits::{
AxisScaleSupport, ComputedSeries, ComputedStyle, LegendKey, PlotArea, PlotCompute, PlotConfig,
PlotData, PlotPrimitive, PlotRender, draw_primitives,
};
use crate::render::skia::SkiaRenderer;
use crate::render::{Color, MarkerStyle, Theme};
use crate::stats::beeswarm::beeswarm_positions;
#[derive(Debug, Clone)]
pub struct SwarmConfig {
pub size: f32,
pub color: Option<Color>,
pub alpha: f32,
pub orientation: SwarmOrientation,
pub width: f64,
pub dodge: bool,
pub dodge_gap: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SwarmOrientation {
#[default]
Vertical,
Horizontal,
}
impl Default for SwarmConfig {
fn default() -> Self {
Self {
size: 5.0,
color: None,
alpha: 0.8,
orientation: SwarmOrientation::Vertical,
width: 0.8,
dodge: false,
dodge_gap: 0.05,
}
}
}
impl SwarmConfig {
pub fn new() -> Self {
Self::default()
}
pub fn size(mut self, size: f32) -> Self {
self.size = size.max(0.1);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn alpha(mut self, alpha: f32) -> Self {
self.alpha = alpha.clamp(0.0, 1.0);
self
}
pub fn horizontal(mut self) -> Self {
self.orientation = SwarmOrientation::Horizontal;
self
}
pub fn width(mut self, width: f64) -> Self {
self.width = width.clamp(0.1, 1.0);
self
}
pub fn dodge(mut self, dodge: bool) -> Self {
self.dodge = dodge;
self
}
}
impl PlotConfig for SwarmConfig {}
pub struct Swarm;
#[derive(Debug, Clone, Copy)]
pub struct SwarmPoint {
pub category: usize,
pub value: f64,
pub x: f64,
pub y: f64,
pub group: Option<usize>,
}
pub fn compute_swarm_points(
categories: &[usize],
values: &[f64],
groups: Option<&[usize]>,
config: &SwarmConfig,
) -> Vec<SwarmPoint> {
let n = categories.len().min(values.len());
let num_groups = groups.map_or(1, |g| g.iter().max().map_or(1, |&m| m + 1));
(0..n)
.map(|i| {
let category = categories[i];
let value = values[i];
let group = groups.map(|g| g.get(i).copied().unwrap_or(0));
let dodge_offset = match config.dodge && num_groups > 1 {
true => {
let dodge_width = config.width / num_groups as f64;
(group.unwrap_or(0) as f64 - (num_groups - 1) as f64 / 2.0) * dodge_width
}
false => 0.0,
};
let slot = category as f64 + dodge_offset;
let (x, y) = match config.orientation {
SwarmOrientation::Vertical => (slot, value),
SwarmOrientation::Horizontal => (value, slot),
};
SwarmPoint {
category,
value,
x,
y,
group,
}
})
.collect()
}
pub fn swarm_range(
points: &[SwarmPoint],
num_categories: usize,
orientation: SwarmOrientation,
) -> ((f64, f64), (f64, f64)) {
if points.is_empty() {
return ((0.0, 1.0), (0.0, 1.0));
}
let val_min = points.iter().map(|p| p.value).fold(f64::INFINITY, f64::min);
let val_max = points
.iter()
.map(|p| p.value)
.fold(f64::NEG_INFINITY, f64::max);
let (low, _) = crate::plots::boxplot::category_slot_span(0.0);
let (_, high) = crate::plots::boxplot::category_slot_span(num_categories as f64 - 1.0);
let cat_range = (low, high);
match orientation {
SwarmOrientation::Vertical => (cat_range, (val_min, val_max)),
SwarmOrientation::Horizontal => ((val_min, val_max), cat_range),
}
}
#[derive(Debug, Clone)]
pub struct SwarmData {
pub points: Vec<SwarmPoint>,
pub num_categories: usize,
pub category_names: Vec<String>,
pub(crate) config: SwarmConfig,
}
pub struct SwarmInput<'a> {
pub categories: &'a [usize],
pub values: &'a [f64],
pub groups: Option<&'a [usize]>,
pub names: Option<&'a [String]>,
}
impl<'a> SwarmInput<'a> {
pub fn new(categories: &'a [usize], values: &'a [f64]) -> Self {
Self {
categories,
values,
groups: None,
names: None,
}
}
pub fn with_groups(mut self, groups: &'a [usize]) -> Self {
self.groups = Some(groups);
self
}
pub fn with_names(mut self, names: &'a [String]) -> Self {
self.names = Some(names);
self
}
}
impl PlotCompute for Swarm {
type Input<'a> = SwarmInput<'a>;
type Config = SwarmConfig;
type Output = SwarmData;
fn compute(input: Self::Input<'_>, config: &Self::Config) -> Result<Self::Output> {
let points = compute_swarm_points(input.categories, input.values, input.groups, config);
if points.is_empty() {
return Err(crate::core::PlottingError::EmptyDataSet);
}
let num_categories = input.categories.iter().max().map_or(0, |&m| m + 1);
Ok(SwarmData {
points,
num_categories,
category_names: input.names.map(<[String]>::to_vec).unwrap_or_default(),
config: config.clone(),
})
}
}
impl PlotData for SwarmData {
fn data_bounds(&self) -> ((f64, f64), (f64, f64)) {
swarm_range(&self.points, self.num_categories, self.config.orientation)
}
fn is_empty(&self) -> bool {
self.points.is_empty()
}
}
impl ComputedSeries for SwarmData {
fn kind(&self) -> &'static str {
"swarm"
}
fn point_count(&self) -> usize {
self.points.len()
}
fn category_slots(&self) -> Vec<(String, f64)> {
match self.config.orientation {
SwarmOrientation::Vertical => {
crate::plots::boxplot::category_slots(&self.category_names, self.num_categories)
}
SwarmOrientation::Horizontal => Vec::new(),
}
}
fn legend_key(&self) -> LegendKey {
LegendKey::Marker
}
fn axis_scale_support(&self) -> (AxisScaleSupport, AxisScaleSupport) {
match self.config.orientation {
SwarmOrientation::Vertical => (AxisScaleSupport::ORDINAL, AxisScaleSupport::Scaled),
SwarmOrientation::Horizontal => (AxisScaleSupport::Scaled, AxisScaleSupport::ORDINAL),
}
}
fn primitives(&self, area: &PlotArea, style: &ComputedStyle) -> Vec<PlotPrimitive> {
let config = &self.config;
let base = config.color.unwrap_or(style.color);
let color = base
.with_alpha((f32::from(base.a) / 255.0) * config.alpha * style.alpha.clamp(0.0, 1.0));
let size_px = style.scale.points_to_pixels(config.size);
let vertical = config.orientation == SwarmOrientation::Vertical;
let width_px = self.spread_width_px(area, vertical);
let mut primitives = Vec::with_capacity(self.points.len());
for column in self.columns() {
let projected: Vec<(f32, f32)> = column
.iter()
.filter_map(|point| area.try_data_to_screen(point.x, point.y))
.collect();
let along: Vec<f64> = projected
.iter()
.map(|&(x, y)| f64::from(if vertical { y } else { x }))
.collect();
let nudges = beeswarm_positions(&along, f64::from(size_px), width_px);
primitives.extend(projected.iter().zip(nudges).map(|(&(x, y), nudge)| {
let nudge = nudge as f32;
PlotPrimitive::Marker {
at: if vertical {
(x + nudge, y)
} else {
(x, y + nudge)
},
size_px,
style: MarkerStyle::Circle,
color,
}
}));
}
primitives
}
}
impl SwarmData {
fn columns(&self) -> Vec<Vec<&SwarmPoint>> {
let slot_of = |point: &SwarmPoint| match self.config.orientation {
SwarmOrientation::Vertical => point.x,
SwarmOrientation::Horizontal => point.y,
};
let mut slots: Vec<f64> = Vec::new();
let mut columns: Vec<Vec<&SwarmPoint>> = Vec::new();
for point in &self.points {
let slot = slot_of(point);
let existing = slots.iter().position(|taken| *taken == slot);
match existing {
Some(index) => columns[index].push(point),
None => {
slots.push(slot);
columns.push(vec![point]);
}
}
}
columns
}
fn spread_width_px(&self, area: &PlotArea, vertical: bool) -> f64 {
let num_groups = self
.points
.iter()
.filter_map(|point| point.group)
.max()
.map_or(1, |group| group + 1);
let (origin, one_slot) = match vertical {
true => (
area.try_data_to_screen(0.0, 0.0),
area.try_data_to_screen(1.0, 0.0),
),
false => (
area.try_data_to_screen(0.0, 0.0),
area.try_data_to_screen(0.0, 1.0),
),
};
let slot_px = match (origin, one_slot) {
(Some(origin), Some(one_slot)) => match vertical {
true => f64::from(one_slot.0 - origin.0).abs(),
false => f64::from(one_slot.1 - origin.1).abs(),
},
_ => 0.0,
};
slot_px * self.config.width / num_groups.max(1) as f64
}
}
impl PlotRender for SwarmData {
fn render(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
_theme: &Theme,
color: Color,
) -> Result<()> {
let style = ComputedStyle::opaque(renderer.render_scale(), color);
draw_primitives(renderer, &self.primitives(area, &style))
}
fn render_styled(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
_theme: &Theme,
color: Color,
alpha: f32,
_line_width: Option<f32>,
) -> Result<()> {
let style = ComputedStyle {
scale: renderer.render_scale(),
color,
alpha,
line_width: None,
};
draw_primitives(renderer, &self.primitives(area, &style))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn marker_xs(data: &SwarmData, area: &PlotArea) -> Vec<f32> {
let renderer = SkiaRenderer::new(200, 200, Theme::default()).unwrap();
let style = ComputedStyle::opaque(renderer.render_scale(), Color::from_rgb(0, 0, 0));
let mut xs: Vec<f32> = data
.primitives(area, &style)
.into_iter()
.filter_map(|primitive| match primitive {
PlotPrimitive::Marker { at, .. } => Some(at.0),
_ => None,
})
.collect();
xs.sort_by(f32::total_cmp);
xs
}
fn swarm_of(categories: &[usize], values: &[f64]) -> SwarmData {
Swarm::compute(SwarmInput::new(categories, values), &SwarmConfig::default()).unwrap()
}
fn full_area(data: &SwarmData) -> PlotArea {
let ((x_min, x_max), (y_min, y_max)) = data.data_bounds();
PlotArea::new(0.0, 0.0, 400.0, 300.0, x_min, x_max, y_min, y_max)
}
#[test]
fn test_swarm_basic() {
let categories = vec![0, 0, 0, 1, 1, 1];
let values = vec![1.0, 1.0, 1.0, 2.0, 2.0, 2.0];
let config = SwarmConfig::default();
let points = compute_swarm_points(&categories, &values, None, &config);
assert_eq!(points.len(), 6);
for point in &points {
assert_eq!(
point.x, point.category as f64,
"a swarm point's nominal position is its category slot"
);
}
}
#[test]
fn coincident_observations_are_spread_apart_when_drawn() {
let data = swarm_of(&[0, 0, 0], &[1.0, 1.0, 1.0]);
let xs = marker_xs(&data, &full_area(&data));
assert_eq!(xs.len(), 3);
assert!(
xs[1] - xs[0] > 1.0 && xs[2] - xs[1] > 1.0,
"coincident observations were not spread apart: {xs:?}"
);
}
#[test]
fn observations_the_axis_already_separates_are_not_nudged() {
let data = swarm_of(&[0, 0, 0], &[0.0, 50.0, 100.0]);
let xs = marker_xs(&data, &full_area(&data));
assert_eq!(xs.len(), 3);
assert!(
xs.iter().all(|x| (x - xs[0]).abs() < 1.0e-3),
"well-separated observations were nudged sideways: {xs:?}"
);
}
#[test]
fn the_swarm_does_not_depend_on_the_unit_the_values_are_measured_in() {
let metres = swarm_of(&[0, 0, 0, 0, 0], &[0.0, 0.1, 0.2, 0.3, 0.4]);
let millimetres = swarm_of(&[0, 0, 0, 0, 0], &[0.0, 100.0, 200.0, 300.0, 400.0]);
assert_eq!(
marker_xs(&metres, &full_area(&metres)),
marker_xs(&millimetres, &full_area(&millimetres)),
"the same data in different units produced different swarms"
);
}
#[test]
fn no_marker_leaves_its_own_category_slot() {
let categories: Vec<usize> = (0..60).map(|i| i % 2).collect();
let values: Vec<f64> = (0..60).map(|_| 1.0).collect();
let data = swarm_of(&categories, &values);
let area = full_area(&data);
let xs = marker_xs(&data, &area);
let (slot_low, _) = crate::plots::boxplot::category_slot_span(0.0);
let (_, slot_high) = crate::plots::boxplot::category_slot_span(0.0);
let left_edge = area.data_to_screen(slot_low, 1.0).0;
let right_edge = area.data_to_screen(slot_high, 1.0).0;
let slot_zero = xs.iter().filter(|&&x| x <= right_edge).count();
assert_eq!(slot_zero, 30, "markers escaped their category slot: {xs:?}");
assert!(
xs.iter().all(|&x| x >= left_edge),
"markers escaped the left edge of the axis: {xs:?}"
);
}
#[test]
fn test_swarm_horizontal() {
let categories = vec![0, 1];
let values = vec![1.0, 2.0];
let config = SwarmConfig::default().horizontal();
let points = compute_swarm_points(&categories, &values, None, &config);
for point in &points {
assert!((point.x - point.value).abs() < 1e-10);
}
}
#[test]
fn test_swarm_with_groups() {
let categories = vec![0, 0, 0, 0];
let values = vec![1.0, 1.0, 2.0, 2.0];
let groups = vec![0, 1, 0, 1];
let config = SwarmConfig::default().dodge(true);
let points = compute_swarm_points(&categories, &values, Some(&groups), &config);
assert_eq!(points.len(), 4);
for point in &points {
assert!(point.group.is_some());
}
}
#[test]
fn test_swarm_empty() {
let categories: Vec<usize> = vec![];
let values: Vec<f64> = vec![];
let config = SwarmConfig::default();
let points = compute_swarm_points(&categories, &values, None, &config);
assert!(points.is_empty());
}
#[test]
fn test_swarm_config_implements_plot_config() {
fn assert_plot_config<T: PlotConfig>() {}
assert_plot_config::<SwarmConfig>();
}
#[test]
fn test_swarm_plot_compute_trait() {
use crate::plots::traits::PlotCompute;
let categories = vec![0, 0, 1, 1, 2, 2];
let values = vec![1.0, 1.5, 2.0, 2.5, 3.0, 3.5];
let config = SwarmConfig::default();
let input = SwarmInput::new(&categories, &values);
let result = Swarm::compute(input, &config);
assert!(result.is_ok());
let swarm_data = result.unwrap();
assert_eq!(swarm_data.points.len(), 6);
assert_eq!(swarm_data.num_categories, 3);
}
#[test]
fn test_swarm_plot_compute_with_groups() {
use crate::plots::traits::PlotCompute;
let categories = vec![0, 0, 1, 1];
let values = vec![1.0, 2.0, 1.0, 2.0];
let groups = vec![0, 1, 0, 1];
let config = SwarmConfig::default().dodge(true);
let input = SwarmInput::new(&categories, &values).with_groups(&groups);
let result = Swarm::compute(input, &config);
assert!(result.is_ok());
let swarm_data = result.unwrap();
assert_eq!(swarm_data.points.len(), 4);
}
#[test]
fn test_swarm_plot_compute_empty() {
use crate::plots::traits::PlotCompute;
let categories: Vec<usize> = vec![];
let values: Vec<f64> = vec![];
let config = SwarmConfig::default();
let input = SwarmInput::new(&categories, &values);
let result = Swarm::compute(input, &config);
assert!(result.is_err());
}
fn swarm_ink(dpi_scale: f32) -> usize {
let categories = vec![0, 1, 2];
let values = vec![1.0, 5.0, 3.0];
let data = Swarm::compute(
SwarmInput::new(&categories, &values),
&SwarmConfig::default(),
)
.unwrap();
let mut renderer = SkiaRenderer::new(200, 200, Theme::default()).unwrap();
renderer.set_dpi_scale(dpi_scale);
let ((x_min, x_max), (y_min, y_max)) = data.data_bounds();
let area = PlotArea::new(20.0, 20.0, 160.0, 160.0, x_min, x_max, y_min, y_max);
data.render(
&mut renderer,
&area,
&Theme::default(),
Color::from_rgb(200, 0, 0),
)
.unwrap();
renderer
.into_image()
.pixels
.chunks_exact(4)
.filter(|p| p[3] > 0 && (p[0] < 250 || p[1] < 250 || p[2] < 250))
.count()
}
#[test]
fn test_swarm_markers_keep_their_physical_size_at_higher_dpi() {
let single = swarm_ink(1.0);
let double = swarm_ink(2.0);
assert!(
double > single * 2,
"swarm markers did not grow with DPI ({double} vs {single} inked pixels)"
);
}
#[test]
fn test_swarm_plot_data_trait() {
use crate::plots::traits::{PlotCompute, PlotData};
let categories = vec![0, 1, 2];
let values = vec![1.0, 5.0, 3.0];
let config = SwarmConfig::default();
let input = SwarmInput::new(&categories, &values);
let swarm_data = Swarm::compute(input, &config).unwrap();
let ((x_min, x_max), (y_min, y_max)) = swarm_data.data_bounds();
assert!(x_min <= x_max);
assert!(y_min <= y_max);
assert!(!swarm_data.is_empty());
}
}