Skip to main content

ggplot_rs/coord/
polar.rs

1use crate::render::Rect;
2
3use super::Coord;
4
5/// Polar coordinate system.
6///
7/// Maps one aesthetic to angle and the other to radius.
8/// - `theta = "x"` (default): x maps to angle, y to radius (pie charts, wind roses)
9/// - `theta = "y"`: y maps to angle, x to radius (Coxcomb charts)
10pub struct CoordPolar {
11    /// Which variable maps to angle: "x" or "y".
12    pub theta: String,
13    /// Start angle in radians (0 = 12 o'clock position).
14    pub start: f64,
15    /// Direction: 1 = clockwise, -1 = counterclockwise.
16    pub direction: f64,
17    /// Inner radius as a fraction of the outer radius (0 = pie, 0.5 = donut).
18    pub inner_radius: f64,
19    /// Angle swept by the full theta range, in radians (default `2π`). Set via
20    /// [`CoordPolar::with_span`] for partial arcs such as gauges.
21    pub span: f64,
22    /// Pad the discrete axis like a cartesian band (default `true`). With
23    /// `false`, `width = 1` bars fill their band edge to edge, so a
24    /// `theta = "y"` pie reaches the centre (ggplot2's `coord_radial(expand =)`).
25    pub expand: bool,
26    /// The r axis along the start spoke (`r.axis.inside = TRUE`) or at the
27    /// panel's left edge (`FALSE`); `None` = inside for a partial arc,
28    /// outside for a full circle (#411).
29    pub r_axis_inside: Option<bool>,
30    /// Theta labels follow the circle (`rotate.angle = TRUE`, #411).
31    pub rotate_angle: bool,
32    /// Radius runs outside-in (`coord_radial(reverse = "r")`, #411).
33    pub reverse_r: bool,
34}
35
36impl CoordPolar {
37    pub fn new() -> Self {
38        CoordPolar {
39            theta: "x".to_string(),
40            start: 0.0,
41            direction: 1.0,
42            inner_radius: 0.0,
43            span: std::f64::consts::TAU,
44            expand: true,
45            r_axis_inside: None,
46            rotate_angle: false,
47            reverse_r: false,
48        }
49    }
50
51    /// ggplot2's `coord_radial(r.axis.inside =)` (#411).
52    pub fn r_axis_inside(mut self, inside: bool) -> Self {
53        self.r_axis_inside = Some(inside);
54        self
55    }
56
57    /// ggplot2's `coord_radial(rotate.angle = TRUE)` (#411).
58    pub fn rotate_angle(mut self, rotate: bool) -> Self {
59        self.rotate_angle = rotate;
60        self
61    }
62
63    /// ggplot2's `coord_radial(reverse = "theta" | "r" | "thetar")` (#411):
64    /// theta runs anticlockwise and / or r outside-in.
65    pub fn reverse(mut self, theta: bool, r: bool) -> Self {
66        if theta {
67            self.direction = -self.direction.signum();
68        }
69        self.reverse_r = r;
70        self
71    }
72
73    /// The pole and outer radius in pixels for `plot_area`.
74    fn frame(&self, plot_area: &Rect) -> ((f64, f64), f64) {
75        let (bx0, bx1, by0, by1) = self.unit_bbox();
76        let (bw, bh) = ((bx1 - bx0).max(1e-9), (by1 - by0).max(1e-9));
77        let max_radius = (plot_area.width / bw).min(plot_area.height / bh);
78        let cx = plot_area.x + plot_area.width / 2.0 - max_radius * (bx0 + bx1) / 2.0;
79        let cy = plot_area.y + plot_area.height / 2.0 - max_radius * (by0 + by1) / 2.0;
80        ((cx, cy), max_radius)
81    }
82
83    /// Sweep the theta range over `[start, end]` (radians, 0 = 12 o'clock,
84    /// clockwise positive) instead of the full circle — e.g.
85    /// `with_span(-PI/2, PI/2)` for a half-donut gauge opening downward, or
86    /// `with_span(-0.75*PI, 0.75*PI)` for a 270° gauge. The partial arc is
87    /// scaled and centred to fill the panel.
88    pub fn with_span(mut self, start: f64, end: f64) -> Self {
89        if start.is_finite() && end.is_finite() && (end - start).abs() > 1e-9 {
90            self.start = start;
91            self.span = (end - start).abs().min(std::f64::consts::TAU);
92            self.direction = if end >= start { 1.0 } else { -1.0 };
93        }
94        self
95    }
96
97    /// Bounding box `(min_x, max_x, min_y, max_y)` of the swept annulus
98    /// sector, for a unit outer radius centred at the origin (screen axes:
99    /// x right, y down).
100    fn unit_bbox(&self) -> (f64, f64, f64, f64) {
101        use std::f64::consts::{FRAC_PI_2, TAU};
102        let (a0, sweep) = (self.start, self.direction * self.span);
103        let pt = |a: f64, r: f64| (r * a.sin(), -r * a.cos());
104        let mut pts = vec![
105            pt(a0, 1.0),
106            pt(a0 + sweep, 1.0),
107            pt(a0, self.inner_radius),
108            pt(a0 + sweep, self.inner_radius),
109        ];
110        // Cardinal directions inside the sweep reach the outer extremes.
111        let (lo, hi) = if sweep >= 0.0 {
112            (a0, a0 + sweep)
113        } else {
114            (a0 + sweep, a0)
115        };
116        let mut k = (lo / FRAC_PI_2).ceil();
117        while k * FRAC_PI_2 <= hi + 1e-12 && (k * FRAC_PI_2 - lo) <= TAU {
118            pts.push(pt(k * FRAC_PI_2, 1.0));
119            k += 1.0;
120        }
121        let mut b = (
122            f64::INFINITY,
123            f64::NEG_INFINITY,
124            f64::INFINITY,
125            f64::NEG_INFINITY,
126        );
127        for (x, y) in pts {
128            b = (b.0.min(x), b.1.max(x), b.2.min(y), b.3.max(y));
129        }
130        b
131    }
132
133    /// Pad the discrete axis (`true`, default) or not (`false`): with `false`
134    /// and `width = 1` bars, a `theta = "y"` pie is solid at the centre. A
135    /// donut ([`inner_radius`](Self::inner_radius)) keeps its hole either way.
136    pub fn expand(mut self, expand: bool) -> Self {
137        self.expand = expand;
138        self
139    }
140
141    /// Set the inner radius (fraction of outer, `0.0`..`1.0`) to punch a donut hole.
142    pub fn inner_radius(mut self, frac: f64) -> Self {
143        self.inner_radius = frac.clamp(0.0, 0.95);
144        self
145    }
146
147    pub fn theta(mut self, theta: &str) -> Self {
148        self.theta = theta.to_string();
149        self
150    }
151
152    pub fn start(mut self, start: f64) -> Self {
153        self.start = start;
154        self
155    }
156
157    pub fn direction(mut self, dir: f64) -> Self {
158        self.direction = dir;
159        self
160    }
161}
162
163impl Default for CoordPolar {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169impl Coord for CoordPolar {
170    fn transform(&self, point: (f64, f64), plot_area: &Rect) -> (f64, f64) {
171        let (nx, ny) = point;
172
173        // Determine which normalized value maps to angle vs radius
174        let (angle_norm, radius_norm) = if self.theta == "x" {
175            (nx, ny)
176        } else {
177            (ny, nx)
178        };
179
180        // Convert to angle (the full theta range sweeps `span`, 2π by default)
181        let angle = self.start + self.direction * angle_norm * self.span;
182
183        // Fit the swept sector's bounding box into the panel: a full circle
184        // gets radius = half the smaller side, centred; a partial arc (gauge)
185        // is scaled up and shifted so it fills the panel.
186        let ((cx, cy), max_radius) = self.frame(plot_area);
187        let radius_norm = if self.reverse_r {
188            1.0 - radius_norm
189        } else {
190            radius_norm
191        };
192        // Map [0,1] into [inner_radius, 1] so a donut leaves a centre hole.
193        let radius = (self.inner_radius + radius_norm * (1.0 - self.inner_radius)) * max_radius;
194
195        // Convert polar to Cartesian pixel coordinates
196        // angle=0 points up (12 o'clock), increases clockwise
197        let px = cx + radius * angle.sin();
198        let py = cy - radius * angle.cos();
199
200        (px, py)
201    }
202
203    fn gridlines(&self) -> bool {
204        false
205    }
206
207    fn discrete_expand(&self) -> bool {
208        self.expand
209    }
210
211    fn is_flipped(&self) -> bool {
212        false
213    }
214
215    fn is_polar(&self) -> bool {
216        true
217    }
218
219    fn polar_theta_is_x(&self) -> bool {
220        self.theta == "x"
221    }
222
223    /// A continuous theta scale isn't padded (ggplot2's `coord_polar`): the
224    /// data spans the whole sweep, and a full circle closes (#411).
225    fn train_scales(&mut self, scales: &mut crate::scale::ScaleSet) {
226        let aes = if self.theta == "x" {
227            crate::aes::Aesthetic::X
228        } else {
229            crate::aes::Aesthetic::Y
230        };
231        if let Some(s) = scales.get_mut(&aes).filter(|s| !s.is_discrete()) {
232            s.set_expand(0.0, 0.0);
233        }
234    }
235
236    fn polar_guide(&self, plot_area: &Rect) -> Option<super::PolarGuide> {
237        let full = self.span >= std::f64::consts::TAU - 1e-9;
238        Some(super::PolarGuide {
239            centre: self.frame(plot_area).0,
240            full_circle: full,
241            r_axis_inside: self.r_axis_inside.unwrap_or(!full),
242            rotate_labels: self.rotate_angle,
243        })
244    }
245}