Skip to main content

plotkit_core/charts/
polar.rs

1//! Polar plot builder methods.
2//!
3//! Provides a fluent builder API for configuring [`PolarArtist`] instances.
4//! Polar plots represent data in a polar coordinate system where each point
5//! is defined by an angle (theta, in radians) and a radial distance (r).
6//!
7//! Two modes are supported:
8//! - **Line mode** (`filled = false`): draws a polyline connecting the data
9//!   points in polar space.
10//! - **Filled mode** (`filled = true`): closes the polar path and fills it,
11//!   producing a radar/area chart.
12//!
13//! # Examples
14//!
15//! ```ignore
16//! ax.polar_plot(&theta, &r)?
17//!     .color(Color::TAB_BLUE)
18//!     .linewidth(2.0)
19//!     .label("Wind speed");
20//!
21//! ax.polar_fill(&theta, &r)?
22//!     .color(Color::TAB_ORANGE)
23//!     .alpha(0.3)
24//!     .label("Coverage");
25//! ```
26
27use crate::artist::PolarArtist;
28use crate::primitives::Color;
29use crate::theme::Marker;
30
31impl PolarArtist {
32    /// Sets the line/fill color.
33    ///
34    /// Accepts any [`Color`] value.
35    pub fn color(&mut self, color: Color) -> &mut Self {
36        self.color = color;
37        self
38    }
39
40    /// Sets the legend label for this polar series.
41    ///
42    /// When a label is set, this series will appear in the legend if one is
43    /// displayed on the axes.
44    pub fn label(&mut self, label: &str) -> &mut Self {
45        self.label = Some(label.to_string());
46        self
47    }
48
49    /// Sets the opacity (0.0 = fully transparent, 1.0 = fully opaque).
50    ///
51    /// The value is clamped to the `[0.0, 1.0]` range.
52    pub fn alpha(&mut self, alpha: f64) -> &mut Self {
53        self.alpha = alpha.clamp(0.0, 1.0);
54        self
55    }
56
57    /// Sets the stroke width in pixels for the polar line.
58    ///
59    /// A width of `1.5` is the default. Values below `1.0` may produce
60    /// sub-pixel rendering depending on the backend.
61    pub fn linewidth(&mut self, width: f64) -> &mut Self {
62        self.linewidth = width;
63        self
64    }
65
66    /// Controls whether the polar path is closed and filled.
67    ///
68    /// When `true`, the path is closed (connecting the last point back to
69    /// the first) and the interior is filled with the configured color and
70    /// alpha. When `false` (default for `polar_plot`), only the line is drawn.
71    pub fn filled(&mut self, filled: bool) -> &mut Self {
72        self.filled = filled;
73        self
74    }
75
76    /// Sets the marker shape drawn at each data point.
77    ///
78    /// By default, no markers are drawn (`None`). Set to `Some(Marker::Circle)`
79    /// or another variant to show markers at each vertex.
80    pub fn marker(&mut self, marker: Marker) -> &mut Self {
81        self.marker = Some(marker);
82        self
83    }
84
85    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)` in
86    /// Cartesian coordinates derived from the polar data.
87    ///
88    /// The polar data is converted to Cartesian coordinates (x = r*cos(theta),
89    /// y = r*sin(theta)) and the bounding box of those points is returned.
90    /// Falls back to `(-1.0, 1.0, -1.0, 1.0)` when no finite data exists.
91    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
92        if self.r.is_empty() || self.theta.is_empty() {
93            return (-1.0, 1.0, -1.0, 1.0);
94        }
95
96        let r_max = self.max_finite_r();
97        if r_max <= 0.0 || !r_max.is_finite() {
98            return (-1.0, 1.0, -1.0, 1.0);
99        }
100
101        // For a polar plot, the data space is a circle of radius r_max
102        // centered at the origin. We add a small margin.
103        let extent = r_max * 1.1;
104        (-extent, extent, -extent, extent)
105    }
106
107    /// Returns the maximum finite, positive radial value.
108    ///
109    /// Returns 0.0 if no finite positive values exist.
110    pub fn max_finite_r(&self) -> f64 {
111        self.r
112            .iter()
113            .copied()
114            .filter(|v| v.is_finite() && *v >= 0.0)
115            .fold(0.0_f64, f64::max)
116    }
117
118    /// Converts polar coordinates (r, theta) to Cartesian coordinates (x, y).
119    ///
120    /// The angle `theta` is in radians, measured counter-clockwise from the
121    /// positive x-axis (3 o'clock position).
122    pub fn polar_to_cartesian(r: f64, theta: f64) -> (f64, f64) {
123        (r * theta.cos(), r * theta.sin())
124    }
125
126    /// Returns an iterator of (x, y) Cartesian points derived from the polar data.
127    ///
128    /// Non-finite r or theta values are filtered out.
129    pub fn cartesian_points(&self) -> Vec<(f64, f64)> {
130        let n = self.r.len().min(self.theta.len());
131        (0..n)
132            .filter(|&i| self.r[i].is_finite() && self.theta[i].is_finite() && self.r[i] >= 0.0)
133            .map(|i| Self::polar_to_cartesian(self.r[i], self.theta[i]))
134            .collect()
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Tests
140// ---------------------------------------------------------------------------
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::f64::consts::{FRAC_PI_2, PI, TAU};
146
147    /// Helper: build a sample PolarArtist for testing.
148    fn sample_polar() -> PolarArtist {
149        PolarArtist {
150            theta: vec![0.0, FRAC_PI_2, PI, 3.0 * FRAC_PI_2],
151            r: vec![1.0, 2.0, 1.5, 0.5],
152            color: Color::TAB_BLUE,
153            label: None,
154            alpha: 1.0,
155            linewidth: 1.5,
156            filled: false,
157            marker: None,
158        }
159    }
160
161    // -- Builder methods ---------------------------------------------------
162
163    #[test]
164    fn builder_color() {
165        let mut a = sample_polar();
166        a.color(Color::TAB_RED);
167        assert_eq!(a.color, Color::TAB_RED);
168    }
169
170    #[test]
171    fn builder_label() {
172        let mut a = sample_polar();
173        a.label("wind");
174        assert_eq!(a.label, Some("wind".to_string()));
175    }
176
177    #[test]
178    fn builder_alpha() {
179        let mut a = sample_polar();
180        a.alpha(0.5);
181        assert!((a.alpha - 0.5).abs() < f64::EPSILON);
182    }
183
184    #[test]
185    fn builder_alpha_clamped() {
186        let mut a = sample_polar();
187        a.alpha(1.5);
188        assert!((a.alpha - 1.0).abs() < f64::EPSILON);
189        a.alpha(-0.5);
190        assert!(a.alpha.abs() < f64::EPSILON);
191    }
192
193    #[test]
194    fn builder_linewidth() {
195        let mut a = sample_polar();
196        a.linewidth(3.0);
197        assert!((a.linewidth - 3.0).abs() < f64::EPSILON);
198    }
199
200    #[test]
201    fn builder_filled() {
202        let mut a = sample_polar();
203        assert!(!a.filled);
204        a.filled(true);
205        assert!(a.filled);
206    }
207
208    #[test]
209    fn builder_marker() {
210        let mut a = sample_polar();
211        assert!(a.marker.is_none());
212        a.marker(Marker::Circle);
213        assert_eq!(a.marker, Some(Marker::Circle));
214    }
215
216    // -- Data bounds -------------------------------------------------------
217
218    #[test]
219    fn data_bounds_basic() {
220        let a = sample_polar();
221        let (xmin, xmax, ymin, ymax) = a.data_bounds();
222        // r_max = 2.0, extent = 2.0 * 1.1 = 2.2
223        assert!((xmin - (-2.2)).abs() < 1e-10);
224        assert!((xmax - 2.2).abs() < 1e-10);
225        assert!((ymin - (-2.2)).abs() < 1e-10);
226        assert!((ymax - 2.2).abs() < 1e-10);
227    }
228
229    #[test]
230    fn data_bounds_empty() {
231        let a = PolarArtist {
232            theta: vec![],
233            r: vec![],
234            color: Color::TAB_BLUE,
235            label: None,
236            alpha: 1.0,
237            linewidth: 1.5,
238            filled: false,
239            marker: None,
240        };
241        assert_eq!(a.data_bounds(), (-1.0, 1.0, -1.0, 1.0));
242    }
243
244    #[test]
245    fn data_bounds_all_nan() {
246        let a = PolarArtist {
247            theta: vec![0.0, 1.0],
248            r: vec![f64::NAN, f64::NAN],
249            color: Color::TAB_BLUE,
250            label: None,
251            alpha: 1.0,
252            linewidth: 1.5,
253            filled: false,
254            marker: None,
255        };
256        assert_eq!(a.data_bounds(), (-1.0, 1.0, -1.0, 1.0));
257    }
258
259    // -- Polar to Cartesian ------------------------------------------------
260
261    #[test]
262    fn polar_to_cartesian_at_zero() {
263        let (x, y) = PolarArtist::polar_to_cartesian(1.0, 0.0);
264        assert!((x - 1.0).abs() < 1e-10);
265        assert!(y.abs() < 1e-10);
266    }
267
268    #[test]
269    fn polar_to_cartesian_at_90_deg() {
270        let (x, y) = PolarArtist::polar_to_cartesian(2.0, FRAC_PI_2);
271        assert!(x.abs() < 1e-10);
272        assert!((y - 2.0).abs() < 1e-10);
273    }
274
275    #[test]
276    fn polar_to_cartesian_at_pi() {
277        let (x, y) = PolarArtist::polar_to_cartesian(1.0, PI);
278        assert!((x - (-1.0)).abs() < 1e-10);
279        assert!(y.abs() < 1e-10);
280    }
281
282    #[test]
283    fn polar_to_cartesian_at_270_deg() {
284        let (x, y) = PolarArtist::polar_to_cartesian(3.0, 3.0 * FRAC_PI_2);
285        assert!(x.abs() < 1e-10);
286        assert!((y - (-3.0)).abs() < 1e-10);
287    }
288
289    // -- Cartesian points --------------------------------------------------
290
291    #[test]
292    fn cartesian_points_basic() {
293        let a = PolarArtist {
294            theta: vec![0.0, FRAC_PI_2],
295            r: vec![1.0, 2.0],
296            color: Color::TAB_BLUE,
297            label: None,
298            alpha: 1.0,
299            linewidth: 1.5,
300            filled: false,
301            marker: None,
302        };
303        let pts = a.cartesian_points();
304        assert_eq!(pts.len(), 2);
305        assert!((pts[0].0 - 1.0).abs() < 1e-10);
306        assert!(pts[0].1.abs() < 1e-10);
307        assert!(pts[1].0.abs() < 1e-10);
308        assert!((pts[1].1 - 2.0).abs() < 1e-10);
309    }
310
311    #[test]
312    fn cartesian_points_nan_filtered() {
313        let a = PolarArtist {
314            theta: vec![0.0, f64::NAN, PI],
315            r: vec![1.0, 2.0, f64::NAN],
316            color: Color::TAB_BLUE,
317            label: None,
318            alpha: 1.0,
319            linewidth: 1.5,
320            filled: false,
321            marker: None,
322        };
323        let pts = a.cartesian_points();
324        // Only the first point survives: (1.0, 0.0)
325        assert_eq!(pts.len(), 1);
326        assert!((pts[0].0 - 1.0).abs() < 1e-10);
327    }
328
329    // -- Angle ranges ------------------------------------------------------
330
331    #[test]
332    fn negative_angles() {
333        let (x, y) = PolarArtist::polar_to_cartesian(1.0, -FRAC_PI_2);
334        assert!(x.abs() < 1e-10);
335        assert!((y - (-1.0)).abs() < 1e-10);
336    }
337
338    #[test]
339    fn angles_greater_than_two_pi() {
340        // 2*PI + PI/2 should equal PI/2 modulo 2*PI
341        let (x1, y1) = PolarArtist::polar_to_cartesian(1.0, TAU + FRAC_PI_2);
342        let (x2, y2) = PolarArtist::polar_to_cartesian(1.0, FRAC_PI_2);
343        assert!((x1 - x2).abs() < 1e-10);
344        assert!((y1 - y2).abs() < 1e-10);
345    }
346
347    #[test]
348    fn max_finite_r_ignores_nan_and_negative() {
349        let a = PolarArtist {
350            theta: vec![0.0, 1.0, 2.0, 3.0],
351            r: vec![f64::NAN, -1.0, 5.0, 3.0],
352            color: Color::TAB_BLUE,
353            label: None,
354            alpha: 1.0,
355            linewidth: 1.5,
356            filled: false,
357            marker: None,
358        };
359        assert!((a.max_finite_r() - 5.0).abs() < 1e-10);
360    }
361
362    #[test]
363    fn data_bounds_with_single_point() {
364        let a = PolarArtist {
365            theta: vec![0.0],
366            r: vec![3.0],
367            color: Color::TAB_BLUE,
368            label: None,
369            alpha: 1.0,
370            linewidth: 1.5,
371            filled: false,
372            marker: None,
373        };
374        let (xmin, xmax, ymin, ymax) = a.data_bounds();
375        // r_max = 3.0, extent = 3.3
376        assert!((xmin - (-3.3)).abs() < 1e-10);
377        assert!((xmax - 3.3).abs() < 1e-10);
378        assert!((ymin - (-3.3)).abs() < 1e-10);
379        assert!((ymax - 3.3).abs() < 1e-10);
380    }
381
382    #[test]
383    fn builder_chaining_returns_self() {
384        let mut a = sample_polar();
385        let _ = a
386            .color(Color::TAB_GREEN)
387            .label("test")
388            .alpha(0.7)
389            .linewidth(2.5)
390            .filled(true)
391            .marker(Marker::Diamond);
392        assert_eq!(a.color, Color::TAB_GREEN);
393        assert_eq!(a.label, Some("test".to_string()));
394        assert!((a.alpha - 0.7).abs() < f64::EPSILON);
395        assert!((a.linewidth - 2.5).abs() < f64::EPSILON);
396        assert!(a.filled);
397        assert_eq!(a.marker, Some(Marker::Diamond));
398    }
399}