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}
27
28impl CoordPolar {
29    pub fn new() -> Self {
30        CoordPolar {
31            theta: "x".to_string(),
32            start: 0.0,
33            direction: 1.0,
34            inner_radius: 0.0,
35            span: std::f64::consts::TAU,
36            expand: true,
37        }
38    }
39
40    /// Sweep the theta range over `[start, end]` (radians, 0 = 12 o'clock,
41    /// clockwise positive) instead of the full circle — e.g.
42    /// `with_span(-PI/2, PI/2)` for a half-donut gauge opening downward, or
43    /// `with_span(-0.75*PI, 0.75*PI)` for a 270° gauge. The partial arc is
44    /// scaled and centred to fill the panel.
45    pub fn with_span(mut self, start: f64, end: f64) -> Self {
46        if start.is_finite() && end.is_finite() && (end - start).abs() > 1e-9 {
47            self.start = start;
48            self.span = (end - start).abs().min(std::f64::consts::TAU);
49            self.direction = if end >= start { 1.0 } else { -1.0 };
50        }
51        self
52    }
53
54    /// Bounding box `(min_x, max_x, min_y, max_y)` of the swept annulus
55    /// sector, for a unit outer radius centred at the origin (screen axes:
56    /// x right, y down).
57    fn unit_bbox(&self) -> (f64, f64, f64, f64) {
58        use std::f64::consts::{FRAC_PI_2, TAU};
59        let (a0, sweep) = (self.start, self.direction * self.span);
60        let pt = |a: f64, r: f64| (r * a.sin(), -r * a.cos());
61        let mut pts = vec![
62            pt(a0, 1.0),
63            pt(a0 + sweep, 1.0),
64            pt(a0, self.inner_radius),
65            pt(a0 + sweep, self.inner_radius),
66        ];
67        // Cardinal directions inside the sweep reach the outer extremes.
68        let (lo, hi) = if sweep >= 0.0 {
69            (a0, a0 + sweep)
70        } else {
71            (a0 + sweep, a0)
72        };
73        let mut k = (lo / FRAC_PI_2).ceil();
74        while k * FRAC_PI_2 <= hi + 1e-12 && (k * FRAC_PI_2 - lo) <= TAU {
75            pts.push(pt(k * FRAC_PI_2, 1.0));
76            k += 1.0;
77        }
78        let mut b = (
79            f64::INFINITY,
80            f64::NEG_INFINITY,
81            f64::INFINITY,
82            f64::NEG_INFINITY,
83        );
84        for (x, y) in pts {
85            b = (b.0.min(x), b.1.max(x), b.2.min(y), b.3.max(y));
86        }
87        b
88    }
89
90    /// Pad the discrete axis (`true`, default) or not (`false`): with `false`
91    /// and `width = 1` bars, a `theta = "y"` pie is solid at the centre. A
92    /// donut ([`inner_radius`](Self::inner_radius)) keeps its hole either way.
93    pub fn expand(mut self, expand: bool) -> Self {
94        self.expand = expand;
95        self
96    }
97
98    /// Set the inner radius (fraction of outer, `0.0`..`1.0`) to punch a donut hole.
99    pub fn inner_radius(mut self, frac: f64) -> Self {
100        self.inner_radius = frac.clamp(0.0, 0.95);
101        self
102    }
103
104    pub fn theta(mut self, theta: &str) -> Self {
105        self.theta = theta.to_string();
106        self
107    }
108
109    pub fn start(mut self, start: f64) -> Self {
110        self.start = start;
111        self
112    }
113
114    pub fn direction(mut self, dir: f64) -> Self {
115        self.direction = dir;
116        self
117    }
118}
119
120impl Default for CoordPolar {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl Coord for CoordPolar {
127    fn transform(&self, point: (f64, f64), plot_area: &Rect) -> (f64, f64) {
128        let (nx, ny) = point;
129
130        // Determine which normalized value maps to angle vs radius
131        let (angle_norm, radius_norm) = if self.theta == "x" {
132            (nx, ny)
133        } else {
134            (ny, nx)
135        };
136
137        // Convert to angle (the full theta range sweeps `span`, 2π by default)
138        let angle = self.start + self.direction * angle_norm * self.span;
139
140        // Fit the swept sector's bounding box into the panel: a full circle
141        // gets radius = half the smaller side, centred; a partial arc (gauge)
142        // is scaled up and shifted so it fills the panel.
143        let (bx0, bx1, by0, by1) = self.unit_bbox();
144        let (bw, bh) = ((bx1 - bx0).max(1e-9), (by1 - by0).max(1e-9));
145        let max_radius = (plot_area.width / bw).min(plot_area.height / bh);
146        // Map [0,1] into [inner_radius, 1] so a donut leaves a centre hole.
147        let radius = (self.inner_radius + radius_norm * (1.0 - self.inner_radius)) * max_radius;
148
149        // Center of the polar plot
150        let cx = plot_area.x + plot_area.width / 2.0 - max_radius * (bx0 + bx1) / 2.0;
151        let cy = plot_area.y + plot_area.height / 2.0 - max_radius * (by0 + by1) / 2.0;
152
153        // Convert polar to Cartesian pixel coordinates
154        // angle=0 points up (12 o'clock), increases clockwise
155        let px = cx + radius * angle.sin();
156        let py = cy - radius * angle.cos();
157
158        (px, py)
159    }
160
161    fn gridlines(&self) -> bool {
162        false
163    }
164
165    fn discrete_expand(&self) -> bool {
166        self.expand
167    }
168
169    fn is_flipped(&self) -> bool {
170        false
171    }
172
173    fn is_polar(&self) -> bool {
174        true
175    }
176
177    fn polar_theta_is_x(&self) -> bool {
178        self.theta == "x"
179    }
180}