use crate::core::Result;
use crate::plots::traits::{PlotArea, PlotCompute, PlotConfig, PlotData, PlotRender};
use crate::render::primitives::{Wedge, pie_wedges};
use crate::render::{Color, SkiaRenderer, Theme};
use std::f64::consts::PI;
pub const DEFAULT_DONUT_INNER_RADIUS: f64 = 0.4;
#[derive(Debug, Clone)]
pub struct PieConfig {
pub labels: Vec<String>,
pub colors: Option<Vec<Color>>,
pub explode: Vec<f64>,
pub show_percentages: bool,
pub show_values: bool,
pub show_labels: bool,
pub inner_radius: f64,
pub start_angle: f64,
pub counter_clockwise: bool,
pub text_color: Color,
pub label_font_size: f32,
pub label_distance: f64,
pub shadow: f64,
pub edge_color: Option<Color>,
pub edge_width: f32,
}
impl Default for PieConfig {
fn default() -> Self {
Self {
labels: vec![],
colors: None,
explode: vec![],
show_percentages: true,
show_values: false,
show_labels: true,
inner_radius: 0.0,
start_angle: 90.0, counter_clockwise: true,
text_color: Color::from_rgb(0, 0, 0),
label_font_size: 10.0,
label_distance: 0.6,
shadow: 0.0,
edge_color: Some(Color::from_rgb(255, 255, 255)),
edge_width: 1.0,
}
}
}
impl PieConfig {
pub fn new(labels: Vec<String>) -> Self {
Self {
labels,
..Default::default()
}
}
pub fn colors(mut self, colors: Vec<Color>) -> Self {
self.colors = Some(colors);
self
}
pub fn explode(mut self, explode: Vec<f64>) -> Self {
self.explode = explode;
self
}
pub fn donut(mut self, inner_radius: f64) -> Self {
self.inner_radius = inner_radius.clamp(0.0, 0.95);
self
}
pub fn start_angle(mut self, angle: f64) -> Self {
self.start_angle = angle;
self
}
pub fn clockwise(mut self) -> Self {
self.counter_clockwise = false;
self
}
pub fn percentages(mut self, show: bool) -> Self {
self.show_percentages = show;
self
}
pub fn values(mut self, show: bool) -> Self {
self.show_values = show;
self
}
pub fn labels(mut self, show: bool) -> Self {
self.show_labels = show;
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.label_font_size = size;
self
}
pub fn label_distance(mut self, distance: f64) -> Self {
self.label_distance = distance;
self
}
pub fn edge_color(mut self, color: Color) -> Self {
self.edge_color = Some(color);
self
}
pub fn no_edge(mut self) -> Self {
self.edge_color = None;
self
}
}
impl PlotConfig for PieConfig {}
pub struct Pie;
#[derive(Debug, Clone)]
pub struct PieData {
pub values: Vec<f64>,
pub wedges: Vec<Wedge>,
pub total: f64,
pub percentages: Vec<f64>,
pub start_angles: Vec<f64>,
pub end_angles: Vec<f64>,
pub(crate) config: PieConfig,
}
impl PieData {
pub fn from_values(values: &[f64], cx: f64, cy: f64, radius: f64, config: &PieConfig) -> Self {
let kept: Vec<usize> = values
.iter()
.enumerate()
.filter(|&(_, &v)| v > 0.0)
.map(|(index, _)| index)
.collect();
let positive_values: Vec<f64> = kept.iter().map(|&index| values[index]).collect();
let total: f64 = positive_values.iter().sum();
let config = filter_per_value_config(config, &kept, values.len());
let percentages: Vec<f64> = if total > 0.0 {
positive_values.iter().map(|v| v / total * 100.0).collect()
} else {
vec![0.0; positive_values.len()]
};
let start_angle_rad = -config.start_angle.to_radians();
let mut wedges = pie_wedges(&positive_values, cx, cy, radius, Some(start_angle_rad));
if config.counter_clockwise {
for wedge in &mut wedges {
let (start, end) = (wedge.start_angle, wedge.end_angle);
wedge.start_angle = 2.0 * start_angle_rad - end;
wedge.end_angle = 2.0 * start_angle_rad - start;
}
}
let mut start_angles = Vec::with_capacity(wedges.len());
let mut end_angles = Vec::with_capacity(wedges.len());
for (i, wedge) in wedges.iter_mut().enumerate() {
start_angles.push(wedge.start_angle);
end_angles.push(wedge.end_angle);
if config.inner_radius > 0.0 {
*wedge = wedge.inner_radius(radius * config.inner_radius);
}
if i < config.explode.len() && config.explode[i] > 0.0 {
*wedge = wedge.explode(config.explode[i] * radius * 0.1);
}
}
Self {
values: positive_values,
wedges,
total,
percentages,
start_angles,
end_angles,
config,
}
}
pub fn compute(values: &[f64], config: &PieConfig) -> Self {
Self::from_values(values, 0.5, 0.5, 0.5, config)
}
}
fn filter_per_value_config(config: &PieConfig, kept: &[usize], value_count: usize) -> PieConfig {
let mut filtered = config.clone();
if kept.len() == value_count {
return filtered;
}
filtered.labels = kept
.iter()
.filter_map(|&index| config.labels.get(index).cloned())
.collect();
filtered.explode = kept
.iter()
.filter_map(|&index| config.explode.get(index).copied())
.collect();
if let Some(colors) = config.colors.as_ref().filter(|c| c.len() == value_count) {
filtered.colors = Some(kept.iter().map(|&index| colors[index]).collect());
}
filtered
}
pub fn render_pie(
renderer: &mut SkiaRenderer,
values: &[f64],
cx: f64,
cy: f64,
radius: f64,
config: &PieConfig,
theme: &Theme,
) -> crate::core::Result<PieData> {
let pie_data = PieData::from_values(values, cx, cy, radius, config);
if pie_data.wedges.is_empty() {
return Ok(pie_data);
}
let config = &pie_data.config;
let colors = if let Some(ref colors) = config.colors {
colors.clone()
} else {
let palette = theme.color_palette.clone();
(0..pie_data.wedges.len())
.map(|i| palette[i % palette.len()])
.collect()
};
let segments = 64;
let render_scale = renderer.render_scale();
let edge_width_px = render_scale.points_to_pixels(config.edge_width);
let label_font_size_px = render_scale.points_to_pixels(config.label_font_size);
let shadow_offset_px = render_scale.points_to_pixels(config.shadow as f32) as f64;
if config.shadow > 0.0 {
let shadow_color = Color::from_rgb(100, 100, 100).with_alpha(0.3);
for wedge in &pie_data.wedges {
let mut shadow_wedge = *wedge;
let polygon = shadow_wedge.as_polygon(segments);
let shadow_polygon: Vec<(f32, f32)> = polygon
.iter()
.map(|(x, y)| {
(
(*x + shadow_offset_px) as f32,
(*y + shadow_offset_px) as f32,
)
})
.collect();
renderer.draw_filled_polygon(&shadow_polygon, shadow_color)?;
}
}
for (i, wedge) in pie_data.wedges.iter().enumerate() {
let color = colors[i % colors.len()];
let polygon = wedge.as_polygon(segments);
let polygon_f32: Vec<(f32, f32)> = polygon
.iter()
.map(|(x, y)| (*x as f32, *y as f32))
.collect();
renderer.draw_filled_polygon(&polygon_f32, color)?;
if let Some(edge_color) = config.edge_color {
renderer.draw_polygon_outline(&polygon_f32, edge_color, edge_width_px)?;
}
}
if config.show_labels || config.show_percentages || config.show_values {
for (i, wedge) in pie_data.wedges.iter().enumerate() {
let label_parts: Vec<String> = [
if config.show_labels && i < config.labels.len() {
Some(config.labels[i].clone())
} else {
None
},
if config.show_percentages {
Some(format!("{:.1}%", pie_data.percentages[i]))
} else {
None
},
if config.show_values {
Some(format!("{:.1}", pie_data.values[i]))
} else {
None
},
]
.into_iter()
.flatten()
.collect();
if !label_parts.is_empty() {
let label = label_parts.join("\n");
let label_r = if config.inner_radius > 0.0 {
radius * (1.0 + config.inner_radius) / 2.0 * config.label_distance
} else {
radius * config.label_distance
};
let (lx, ly) = wedge.centroid();
let mid_angle = (wedge.start_angle + wedge.end_angle) / 2.0;
let label_x = cx + label_r * mid_angle.cos();
let label_y = cy + label_r * mid_angle.sin();
renderer.draw_text_centered(
&label,
label_x as f32,
label_y as f32,
label_font_size_px,
config.text_color,
)?;
}
}
}
Ok(pie_data)
}
impl PlotCompute for Pie {
type Input<'a> = &'a [f64];
type Config = PieConfig;
type Output = PieData;
fn compute(input: Self::Input<'_>, config: &Self::Config) -> Result<Self::Output> {
let positive_count = input.iter().filter(|&&v| v > 0.0).count();
if positive_count == 0 {
return Err(crate::core::PlottingError::EmptyDataSet);
}
Ok(PieData::compute(input, config))
}
}
impl PlotData for PieData {
fn data_bounds(&self) -> ((f64, f64), (f64, f64)) {
((0.0, 1.0), (0.0, 1.0))
}
fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
impl PlotRender for PieData {
fn render(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
) -> Result<()> {
self.render_styled(renderer, area, theme, color, 1.0, None)
}
fn render_styled(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
_color: Color,
alpha: f32,
line_width: Option<f32>,
) -> Result<()> {
if self.wedges.is_empty() {
return Ok(());
}
let config = &self.config;
let (cx, cy) = area.data_to_screen(0.5, 0.5);
let (edge_x, _) = area.data_to_screen(1.0, 0.5);
let (_, edge_y) = area.data_to_screen(0.5, 1.0);
let radius = ((edge_x - cx).abs().min((edge_y - cy).abs())) * 0.9;
let screen_data =
PieData::from_values(&self.values, cx as f64, cy as f64, radius as f64, config);
let colors = if let Some(ref colors) = config.colors {
colors.clone()
} else {
let palette = theme.color_palette.clone();
(0..screen_data.wedges.len())
.map(|i| palette[i % palette.len()])
.collect()
}
.into_iter()
.map(|color| color.with_alpha((f32::from(color.a) / 255.0) * alpha.clamp(0.0, 1.0)))
.collect::<Vec<_>>();
let segments = 64;
let render_scale = renderer.render_scale();
let edge_width_px = render_scale.points_to_pixels(line_width.unwrap_or(config.edge_width));
let label_font_size_px = render_scale.points_to_pixels(config.label_font_size);
let shadow_offset_px = render_scale.points_to_pixels(config.shadow as f32) as f64;
if config.shadow > 0.0 {
let shadow_color = Color::from_rgb(100, 100, 100).with_alpha(0.3 * alpha);
for wedge in &screen_data.wedges {
let polygon = wedge.as_polygon(segments);
let shadow_polygon: Vec<(f32, f32)> = polygon
.iter()
.map(|(x, y)| {
(
(*x + shadow_offset_px) as f32,
(*y + shadow_offset_px) as f32,
)
})
.collect();
renderer.draw_filled_polygon(&shadow_polygon, shadow_color)?;
}
}
for (i, wedge) in screen_data.wedges.iter().enumerate() {
let color = colors[i % colors.len()];
let polygon = wedge.as_polygon(segments);
let polygon_f32: Vec<(f32, f32)> = polygon
.iter()
.map(|(x, y)| (*x as f32, *y as f32))
.collect();
renderer.draw_filled_polygon(&polygon_f32, color)?;
if let Some(edge_color) = config.edge_color {
let edge_color = edge_color
.with_alpha((f32::from(edge_color.a) / 255.0) * alpha.clamp(0.0, 1.0));
renderer.draw_polygon_outline(&polygon_f32, edge_color, edge_width_px)?;
}
}
if config.show_labels || config.show_percentages || config.show_values {
for (i, wedge) in screen_data.wedges.iter().enumerate() {
let label_parts: Vec<String> = [
if config.show_labels && i < config.labels.len() {
Some(config.labels[i].clone())
} else {
None
},
if config.show_percentages {
Some(format!("{:.1}%", screen_data.percentages[i]))
} else {
None
},
if config.show_values {
Some(format!("{:.1}", screen_data.values[i]))
} else {
None
},
]
.into_iter()
.flatten()
.collect();
if !label_parts.is_empty() {
let label = label_parts.join("\n");
let label_r = if config.inner_radius > 0.0 {
radius as f64 * (1.0 + config.inner_radius) / 2.0 * config.label_distance
} else {
radius as f64 * config.label_distance
};
let mid_angle = (wedge.start_angle + wedge.end_angle) / 2.0;
let label_x = cx as f64 + label_r * mid_angle.cos();
let label_y = cy as f64 + label_r * mid_angle.sin();
renderer.draw_text_centered(
&label,
label_x as f32,
label_y as f32,
label_font_size_px,
config.text_color,
)?;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pie_data_basic() {
let values = vec![30.0, 20.0, 50.0];
let config = PieConfig::default();
let data = PieData::from_values(&values, 100.0, 100.0, 50.0, &config);
assert_eq!(data.wedges.len(), 3);
assert!((data.total - 100.0).abs() < 1e-10);
assert!((data.percentages[0] - 30.0).abs() < 1e-10);
assert!((data.percentages[1] - 20.0).abs() < 1e-10);
assert!((data.percentages[2] - 50.0).abs() < 1e-10);
}
#[test]
fn test_pie_config_donut() {
let config = PieConfig::default().donut(0.5);
assert!((config.inner_radius - 0.5).abs() < 1e-10);
}
#[test]
fn test_pie_data_with_explode() {
let values = vec![25.0, 25.0, 50.0];
let config = PieConfig::default().explode(vec![0.1, 0.0, 0.0]);
let data = PieData::from_values(&values, 100.0, 100.0, 50.0, &config);
assert_eq!(data.wedges.len(), 3);
assert!(data.wedges[0].explode > 0.0);
assert!((data.wedges[1].explode - 0.0).abs() < 1e-10);
}
#[test]
fn test_pie_ignores_negative() {
let values = vec![30.0, -10.0, 20.0];
let config = PieConfig::default();
let data = PieData::from_values(&values, 100.0, 100.0, 50.0, &config);
assert_eq!(data.wedges.len(), 2);
}
#[test]
fn test_pie_per_value_config_tracks_filtered_values() {
let red = Color::from_rgb(255, 0, 0);
let green = Color::from_rgb(0, 255, 0);
let blue = Color::from_rgb(0, 0, 255);
let values = vec![30.0, -10.0, 20.0];
let config = PieConfig::new(vec!["a".to_string(), "b".to_string(), "c".to_string()])
.explode(vec![0.0, 0.5, 0.0])
.colors(vec![red, green, blue]);
let data = PieData::from_values(&values, 100.0, 100.0, 50.0, &config);
assert_eq!(data.values, vec![30.0, 20.0]);
assert_eq!(data.config.labels, vec!["a".to_string(), "c".to_string()]);
assert_eq!(data.config.explode, vec![0.0, 0.0]);
assert_eq!(data.config.colors, Some(vec![red, blue]));
assert!(data.wedges.iter().all(|wedge| wedge.explode.abs() < 1e-10));
let again = PieData::from_values(&data.values, 0.0, 0.0, 1.0, &data.config);
assert_eq!(again.config.labels, data.config.labels);
assert_eq!(again.config.colors, data.config.colors);
}
#[test]
fn test_pie_all_values_positive_keeps_config_untouched() {
let values = vec![30.0, 20.0, 50.0];
let config = PieConfig::new(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
let data = PieData::from_values(&values, 100.0, 100.0, 50.0, &config);
assert_eq!(data.config.labels, config.labels);
}
#[test]
fn test_pie_starts_at_twelve_oclock_counter_clockwise() {
let values = vec![30.0, 20.0, 50.0];
let config = PieConfig::default();
let (cx, cy) = (100.0, 100.0);
let data = PieData::from_values(&values, cx, cy, 50.0, &config);
let boundary = data.wedges[0].end_angle;
assert!((boundary + PI / 2.0).abs() < 1e-9, "boundary: {boundary}");
let (mx, my) = data.wedges[0].centroid();
assert!(mx < cx, "midpoint x {mx} should be left of {cx}");
assert!(my < cy, "midpoint y {my} should be above {cy}");
for pair in data.wedges.windows(2) {
assert!((pair[0].start_angle - pair[1].end_angle).abs() < 1e-9);
}
let swept: f64 = data
.wedges
.iter()
.map(|wedge| (wedge.end_angle - wedge.start_angle).abs())
.sum();
assert!((swept - 2.0 * PI).abs() < 1e-9);
}
#[test]
fn test_pie_clockwise_reverses_direction() {
let values = vec![30.0, 20.0, 50.0];
let config = PieConfig::default().clockwise();
let (cx, cy) = (100.0, 100.0);
let data = PieData::from_values(&values, cx, cy, 50.0, &config);
assert!((data.wedges[0].start_angle + PI / 2.0).abs() < 1e-9);
let (mx, my) = data.wedges[0].centroid();
assert!(mx > cx, "midpoint x {mx} should be right of {cx}");
assert!(my < cy, "midpoint y {my} should be above {cy}");
}
#[test]
fn test_pie_start_angle_zero_is_three_oclock() {
let values = vec![25.0, 75.0];
let config = PieConfig::default().start_angle(0.0);
let (cx, cy) = (0.0, 0.0);
let data = PieData::from_values(&values, cx, cy, 1.0, &config);
assert!(data.wedges[0].end_angle.abs() < 1e-9);
let (mx, my) = data.wedges[0].centroid();
assert!(mx > 0.0 && my < 0.0, "midpoint ({mx}, {my})");
}
#[test]
fn test_pie_config_implements_plot_config() {
fn assert_plot_config<T: PlotConfig>() {}
assert_plot_config::<PieConfig>();
}
#[test]
fn test_clockwise_changes_the_rendered_image() {
fn render(config: PieConfig) -> Vec<u8> {
let values = vec![10.0, 20.0, 70.0];
let data = PieData::compute(&values, &config);
let mut renderer = SkiaRenderer::new(160, 160, Theme::default()).unwrap();
let area = PlotArea::new(0.0, 0.0, 160.0, 160.0, 0.0, 1.0, 0.0, 1.0);
data.render(
&mut renderer,
&area,
&Theme::default(),
Color::from_rgb(0, 0, 0),
)
.unwrap();
renderer.into_image().pixels
}
let ccw = render(PieConfig::default().labels(false).percentages(false));
let cw = render(
PieConfig::default()
.labels(false)
.percentages(false)
.clockwise(),
);
assert_ne!(
ccw, cw,
"PieConfig::clockwise produced a byte-identical image"
);
}
#[test]
fn test_pie_plot_compute_trait() {
use crate::plots::traits::PlotCompute;
let values = vec![30.0, 20.0, 50.0];
let config = PieConfig::default();
let result = Pie::compute(&values, &config);
assert!(result.is_ok());
let pie_data = result.unwrap();
assert_eq!(pie_data.wedges.len(), 3);
assert!((pie_data.total - 100.0).abs() < 1e-10);
}
#[test]
fn test_pie_plot_compute_empty() {
use crate::plots::traits::PlotCompute;
let values: Vec<f64> = vec![];
let config = PieConfig::default();
let result = Pie::compute(&values, &config);
assert!(result.is_err());
}
#[test]
fn test_pie_plot_compute_all_negative() {
use crate::plots::traits::PlotCompute;
let values = vec![-10.0, -20.0];
let config = PieConfig::default();
let result = Pie::compute(&values, &config);
assert!(result.is_err());
}
#[test]
fn test_pie_plot_data_trait() {
use crate::plots::traits::{PlotCompute, PlotData};
let values = vec![30.0, 20.0, 50.0];
let config = PieConfig::default();
let pie_data = Pie::compute(&values, &config).unwrap();
let ((x_min, x_max), (y_min, y_max)) = pie_data.data_bounds();
assert!((x_min - 0.0).abs() < 1e-10);
assert!((x_max - 1.0).abs() < 1e-10);
assert!((y_min - 0.0).abs() < 1e-10);
assert!((y_max - 1.0).abs() < 1e-10);
assert!(!pie_data.is_empty());
}
}