Skip to main content

chartml_core/shapes/
pie.rs

1/// Computes start/end angles for pie slices from data values.
2/// Equivalent to D3's `d3.pie()`.
3use std::f64::consts::PI;
4
5/// A computed pie slice with angular position.
6#[derive(Debug, Clone)]
7pub struct PieSlice {
8    /// Index of the original data value.
9    pub index: usize,
10    /// The original data value.
11    pub value: f64,
12    /// Start angle in radians.
13    pub start_angle: f64,
14    /// End angle in radians.
15    pub end_angle: f64,
16}
17
18/// Computes pie layout angles from data values.
19pub struct PieLayout {
20    start_angle: f64,
21    end_angle: f64,
22    sort: bool,
23}
24
25impl PieLayout {
26    /// Create a new PieLayout with default settings (full circle, unsorted).
27    pub fn new() -> Self {
28        Self {
29            start_angle: 0.0,
30            end_angle: 2.0 * PI,
31            sort: false,
32        }
33    }
34
35    /// Set the start angle in radians.
36    pub fn start_angle(mut self, angle: f64) -> Self {
37        self.start_angle = angle;
38        self
39    }
40
41    /// Set the end angle in radians.
42    pub fn end_angle(mut self, angle: f64) -> Self {
43        self.end_angle = angle;
44        self
45    }
46
47    /// Set whether to sort slices by value (descending).
48    pub fn sort(mut self, sort: bool) -> Self {
49        self.sort = sort;
50        self
51    }
52
53    /// Compute pie slices from data values.
54    /// Each value gets a proportional angle within [start_angle, end_angle].
55    pub fn layout(&self, values: &[f64]) -> Vec<PieSlice> {
56        if values.is_empty() {
57            return Vec::new();
58        }
59
60        let total: f64 = values.iter().sum();
61        let angle_span = self.end_angle - self.start_angle;
62
63        // Build indexed values
64        let mut indexed: Vec<(usize, f64)> = values.iter().enumerate().map(|(i, &v)| (i, v)).collect();
65
66        if self.sort {
67            indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
68        }
69
70        let mut slices = Vec::with_capacity(indexed.len());
71        let mut current_angle = self.start_angle;
72
73        for &(index, value) in &indexed {
74            let slice_angle = if total > 0.0 {
75                value / total * angle_span
76            } else {
77                0.0
78            };
79            let start = current_angle;
80            let end = current_angle + slice_angle;
81            slices.push(PieSlice {
82                index,
83                value,
84                start_angle: start,
85                end_angle: end,
86            });
87            current_angle = end;
88        }
89
90        slices
91    }
92}
93
94impl Default for PieLayout {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    #![allow(clippy::unwrap_used)]
103    use super::*;
104
105    #[test]
106    fn pie_basic() {
107        let layout = PieLayout::new();
108        let slices = layout.layout(&[1.0, 2.0, 3.0]);
109        assert_eq!(slices.len(), 3);
110        // Angles should sum to 2*PI
111        let total_angle: f64 = slices.iter().map(|s| s.end_angle - s.start_angle).sum();
112        assert!((total_angle - 2.0 * PI).abs() < 1e-10, "Total angle should be 2*PI, got: {}", total_angle);
113        // First slice should be 1/6 of the circle
114        let first_angle = slices[0].end_angle - slices[0].start_angle;
115        assert!((first_angle - 2.0 * PI / 6.0).abs() < 1e-10);
116    }
117
118    #[test]
119    fn pie_single_value() {
120        let layout = PieLayout::new();
121        let slices = layout.layout(&[1.0]);
122        assert_eq!(slices.len(), 1);
123        assert!((slices[0].start_angle - 0.0).abs() < 1e-10);
124        assert!((slices[0].end_angle - 2.0 * PI).abs() < 1e-10);
125    }
126
127    #[test]
128    fn pie_equal_values() {
129        let layout = PieLayout::new();
130        let slices = layout.layout(&[1.0, 1.0, 1.0]);
131        assert_eq!(slices.len(), 3);
132        let expected_angle = 2.0 * PI / 3.0;
133        for slice in &slices {
134            let angle = slice.end_angle - slice.start_angle;
135            assert!((angle - expected_angle).abs() < 1e-10, "Each slice should be 2*PI/3, got: {}", angle);
136        }
137    }
138
139    #[test]
140    fn pie_sorted_descending() {
141        let layout = PieLayout::new().sort(true);
142        let slices = layout.layout(&[1.0, 3.0, 2.0]);
143        assert_eq!(slices.len(), 3);
144        // Sorted descending: first slice should be value 3.0 (index 1)
145        assert_eq!(slices[0].index, 1);
146        assert!((slices[0].value - 3.0).abs() < 1e-10);
147        // Second should be value 2.0 (index 2)
148        assert_eq!(slices[1].index, 2);
149        assert!((slices[1].value - 2.0).abs() < 1e-10);
150        // Third should be value 1.0 (index 0)
151        assert_eq!(slices[2].index, 0);
152    }
153
154    #[test]
155    fn pie_custom_angle_range() {
156        // Half circle (semicircle)
157        let layout = PieLayout::new().start_angle(0.0).end_angle(PI);
158        let slices = layout.layout(&[1.0, 1.0]);
159        assert_eq!(slices.len(), 2);
160        let total_angle: f64 = slices.iter().map(|s| s.end_angle - s.start_angle).sum();
161        assert!((total_angle - PI).abs() < 1e-10, "Total angle should be PI, got: {}", total_angle);
162        // Each slice should be PI/2
163        let each = PI / 2.0;
164        assert!((slices[0].end_angle - slices[0].start_angle - each).abs() < 1e-10);
165    }
166
167    #[test]
168    fn pie_with_zero() {
169        let layout = PieLayout::new();
170        let slices = layout.layout(&[0.0, 1.0, 2.0]);
171        assert_eq!(slices.len(), 3);
172        // Zero-value slice should have start == end angle
173        assert!((slices[0].end_angle - slices[0].start_angle).abs() < 1e-10,
174            "Zero-value slice should have zero angle span");
175    }
176}