Skip to main content

chartml_core/shapes/
arc.rs

1/// Generates SVG path `d` strings for pie/doughnut slices.
2/// Equivalent to D3's `d3.arc()`.
3use super::line::fmt;
4use std::f64::consts::PI;
5
6/// Generates SVG path `d` strings for arc segments (pie/doughnut slices).
7pub struct ArcGenerator {
8    inner_radius: f64,
9    outer_radius: f64,
10    corner_radius: f64,
11}
12
13impl ArcGenerator {
14    /// Create a new ArcGenerator.
15    /// Use inner_radius=0 for pie charts, >0 for doughnut charts.
16    pub fn new(inner_radius: f64, outer_radius: f64) -> Self {
17        Self {
18            inner_radius,
19            outer_radius,
20            corner_radius: 0.0,
21        }
22    }
23
24    /// Set the corner radius for rounded corners (not yet implemented, stored for future use).
25    pub fn corner_radius(mut self, r: f64) -> Self {
26        self.corner_radius = r;
27        self
28    }
29
30    /// Generate an SVG path for an arc from start_angle to end_angle.
31    ///
32    /// Angles are in radians, 0 = 12 o'clock, clockwise.
33    /// The arc is centered at the origin.
34    pub fn generate(&self, start_angle: f64, end_angle: f64) -> String {
35        let delta = (end_angle - start_angle).abs();
36
37        // Handle near-full circle: use two half-arcs to avoid SVG arc rendering issues
38        if delta >= 2.0 * PI - 1e-6 {
39            return self.generate_full_circle(start_angle);
40        }
41
42        let outer_start = angle_to_point(start_angle, self.outer_radius);
43        let outer_end = angle_to_point(end_angle, self.outer_radius);
44        let large_arc = if delta > PI { 1 } else { 0 };
45
46        if self.inner_radius > 0.0 {
47            // Doughnut segment
48            let inner_start = angle_to_point(start_angle, self.inner_radius);
49            let inner_end = angle_to_point(end_angle, self.inner_radius);
50            // Counter-sweep for inner arc (going back)
51            let inner_large_arc = large_arc;
52
53            format!(
54                "M{},{} A{},{} 0 {},1 {},{} L{},{} A{},{} 0 {},0 {},{} Z",
55                fmt(outer_start.0), fmt(outer_start.1),
56                fmt(self.outer_radius), fmt(self.outer_radius),
57                large_arc,
58                fmt(outer_end.0), fmt(outer_end.1),
59                fmt(inner_end.0), fmt(inner_end.1),
60                fmt(self.inner_radius), fmt(self.inner_radius),
61                inner_large_arc,
62                fmt(inner_start.0), fmt(inner_start.1),
63            )
64        } else {
65            // Pie segment (no inner radius)
66            format!(
67                "M{},{} A{},{} 0 {},1 {},{} L0,0 Z",
68                fmt(outer_start.0), fmt(outer_start.1),
69                fmt(self.outer_radius), fmt(self.outer_radius),
70                large_arc,
71                fmt(outer_end.0), fmt(outer_end.1),
72            )
73        }
74    }
75
76    /// Generate a full circle (or near-full) using two half-arcs.
77    fn generate_full_circle(&self, start_angle: f64) -> String {
78        let mid_angle = start_angle + PI;
79        let outer_start = angle_to_point(start_angle, self.outer_radius);
80        let outer_mid = angle_to_point(mid_angle, self.outer_radius);
81
82        if self.inner_radius > 0.0 {
83            let inner_start = angle_to_point(start_angle, self.inner_radius);
84            let inner_mid = angle_to_point(mid_angle, self.inner_radius);
85
86            format!(
87                "M{},{} A{},{} 0 1,1 {},{} A{},{} 0 1,1 {},{} M{},{} A{},{} 0 1,0 {},{} A{},{} 0 1,0 {},{}Z",
88                fmt(outer_start.0), fmt(outer_start.1),
89                fmt(self.outer_radius), fmt(self.outer_radius),
90                fmt(outer_mid.0), fmt(outer_mid.1),
91                fmt(self.outer_radius), fmt(self.outer_radius),
92                fmt(outer_start.0), fmt(outer_start.1),
93                fmt(inner_start.0), fmt(inner_start.1),
94                fmt(self.inner_radius), fmt(self.inner_radius),
95                fmt(inner_mid.0), fmt(inner_mid.1),
96                fmt(self.inner_radius), fmt(self.inner_radius),
97                fmt(inner_start.0), fmt(inner_start.1),
98            )
99        } else {
100            format!(
101                "M{},{} A{},{} 0 1,1 {},{} A{},{} 0 1,1 {},{}Z",
102                fmt(outer_start.0), fmt(outer_start.1),
103                fmt(self.outer_radius), fmt(self.outer_radius),
104                fmt(outer_mid.0), fmt(outer_mid.1),
105                fmt(self.outer_radius), fmt(self.outer_radius),
106                fmt(outer_start.0), fmt(outer_start.1),
107            )
108        }
109    }
110}
111
112/// Convert an angle (0 = 12 o'clock, clockwise) to cartesian coordinates centered at origin.
113/// x = r * sin(angle), y = -r * cos(angle)
114fn angle_to_point(angle: f64, radius: f64) -> (f64, f64) {
115    (radius * angle.sin(), -radius * angle.cos())
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::f64::consts::FRAC_PI_2;
122
123    #[test]
124    fn arc_quarter_circle() {
125        let gen = ArcGenerator::new(0.0, 100.0);
126        let path = gen.generate(0.0, FRAC_PI_2);
127        assert!(path.starts_with("M"), "Path should start with M, got: {}", path);
128        assert!(path.contains("A"), "Path should contain A command, got: {}", path);
129        assert!(path.ends_with("Z"), "Path should end with Z, got: {}", path);
130    }
131
132    #[test]
133    fn arc_full_circle() {
134        let gen = ArcGenerator::new(0.0, 100.0);
135        let path = gen.generate(0.0, 2.0 * PI);
136        assert!(path.starts_with("M"), "Path should start with M, got: {}", path);
137        assert!(path.contains("A"), "Path should contain A command, got: {}", path);
138        // Full circle uses two half-arcs
139        let arc_count = path.matches("A").count();
140        assert!(arc_count >= 2, "Full circle should have at least 2 arc commands, got: {}", arc_count);
141    }
142
143    #[test]
144    fn arc_doughnut() {
145        let gen = ArcGenerator::new(50.0, 100.0);
146        let path = gen.generate(0.0, FRAC_PI_2);
147        // Doughnut segment should contain two arc commands (outer and inner)
148        let arc_count = path.matches("A").count();
149        assert!(arc_count >= 2, "Doughnut should have at least 2 arc commands, got: {} in path: {}", arc_count, path);
150    }
151}