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