use crate::core::Result;
use crate::plots::polar::radar::{RADAR_BOUNDS_RADIUS, RADAR_LABEL_RADIUS};
use crate::plots::traits::{PlotArea, PlotCompute, PlotConfig, PlotData, PlotRender};
use crate::render::skia::SkiaRenderer;
use crate::render::{Color, LineStyle, MarkerStyle, Theme};
pub(crate) const POLAR_LABEL_RADIUS: f64 = RADAR_LABEL_RADIUS;
pub(crate) const POLAR_BOUNDS_RADIUS: f64 = RADAR_BOUNDS_RADIUS;
const POLAR_RING_SEGMENTS: usize = 72;
#[derive(Debug, Clone)]
pub struct PolarPlotConfig {
pub theta_offset: f64,
pub theta_direction: bool,
pub show_rgrid: bool,
pub show_thetagrid: bool,
pub rgrid_count: usize,
pub thetagrid_count: usize,
pub color: Option<Color>,
pub line_width: f32,
pub marker_size: f32,
pub fill: bool,
pub fill_alpha: f32,
pub show_theta_labels: bool,
pub show_r_labels: bool,
pub r_label_position: f64,
pub label_font_size: f32,
}
impl Default for PolarPlotConfig {
fn default() -> Self {
Self {
theta_offset: 0.0,
theta_direction: true,
show_rgrid: true,
show_thetagrid: true,
rgrid_count: 5,
thetagrid_count: 12,
color: None,
line_width: 1.5,
marker_size: 0.0,
fill: false,
fill_alpha: 0.3,
show_theta_labels: true,
show_r_labels: true,
r_label_position: 22.5, label_font_size: 10.0,
}
}
}
impl PolarPlotConfig {
pub fn new() -> Self {
Self::default()
}
pub fn theta_offset(mut self, offset: f64) -> Self {
self.theta_offset = offset;
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn line_width(mut self, width: f32) -> Self {
self.line_width = width.max(0.1);
self
}
pub fn marker_size(mut self, size: f32) -> Self {
self.marker_size = size.max(0.0);
self
}
pub fn fill(mut self, fill: bool) -> Self {
self.fill = fill;
self
}
pub fn fill_alpha(mut self, alpha: f32) -> Self {
self.fill_alpha = alpha.clamp(0.0, 1.0);
self
}
pub fn show_rgrid(mut self, show: bool) -> Self {
self.show_rgrid = show;
self
}
pub fn show_thetagrid(mut self, show: bool) -> Self {
self.show_thetagrid = show;
self
}
pub fn rgrid_count(mut self, count: usize) -> Self {
self.rgrid_count = count;
self
}
pub fn thetagrid_count(mut self, count: usize) -> Self {
self.thetagrid_count = count;
self
}
pub fn show_theta_labels(mut self, show: bool) -> Self {
self.show_theta_labels = show;
self
}
pub fn show_r_labels(mut self, show: bool) -> Self {
self.show_r_labels = show;
self
}
pub fn r_label_position(mut self, degrees: f64) -> Self {
self.r_label_position = degrees;
self
}
pub fn label_font_size(mut self, size: f32) -> Self {
self.label_font_size = size.max(1.0);
self
}
}
impl PlotConfig for PolarPlotConfig {}
pub struct PolarPlot;
#[derive(Debug, Clone, Copy)]
pub struct PolarPoint {
pub r: f64,
pub theta: f64,
pub x: f64,
pub y: f64,
}
impl PolarPoint {
pub fn from_polar(r: f64, theta: f64) -> Self {
Self {
r,
theta,
x: r * theta.cos(),
y: r * theta.sin(),
}
}
pub fn from_cartesian(x: f64, y: f64) -> Self {
Self {
r: (x * x + y * y).sqrt(),
theta: y.atan2(x),
x,
y,
}
}
}
#[derive(Debug, Clone)]
pub struct PositionedLabel {
pub x: f64,
pub y: f64,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct PolarPlotData {
pub points: Vec<PolarPoint>,
pub r_max: f64,
pub fill_polygon: Vec<(f64, f64)>,
pub closed: bool,
pub grid_rings: Vec<Vec<(f64, f64)>>,
pub grid_spokes: Vec<((f64, f64), (f64, f64))>,
pub theta_labels: Vec<PositionedLabel>,
pub r_labels: Vec<PositionedLabel>,
pub(crate) config: PolarPlotConfig,
}
const CLOSING_SEGMENT_EPSILON: f64 = 1e-9;
impl PolarPlotData {
pub fn closing_segment(&self) -> Option<((f64, f64), (f64, f64))> {
if !self.closed {
return None;
}
let first = self.points.first()?;
let last = self.points.last()?;
let gap = ((last.x - first.x).powi(2) + (last.y - first.y).powi(2)).sqrt();
(gap > CLOSING_SEGMENT_EPSILON * self.r_max)
.then_some(((last.x, last.y), (first.x, first.y)))
}
pub fn bounds_radius(&self) -> f64 {
self.r_max * POLAR_BOUNDS_RADIUS
}
}
pub struct PolarPlotInput<'a> {
pub r: &'a [f64],
pub theta: &'a [f64],
}
impl<'a> PolarPlotInput<'a> {
pub fn new(r: &'a [f64], theta: &'a [f64]) -> Self {
Self { r, theta }
}
}
pub fn compute_polar_plot(r: &[f64], theta: &[f64], config: &PolarPlotConfig) -> PolarPlotData {
use std::f64::consts::PI;
let n = r.len().min(theta.len());
if n == 0 {
return PolarPlotData {
points: vec![],
r_max: 1.0,
fill_polygon: vec![],
closed: false,
grid_rings: vec![],
grid_spokes: vec![],
theta_labels: vec![],
r_labels: vec![],
config: config.clone(),
};
}
let finite_theta: Vec<f64> = (0..n)
.filter(|&i| r[i].is_finite() && theta[i].is_finite())
.map(|i| theta[i])
.collect();
let mut points = Vec::with_capacity(finite_theta.len());
let mut r_max = 0.0_f64;
for i in 0..n {
if !r[i].is_finite() || !theta[i].is_finite() {
continue;
}
let adjusted_theta = if config.theta_direction {
theta[i] + config.theta_offset
} else {
-theta[i] + config.theta_offset
};
let point = PolarPoint::from_polar(r[i], adjusted_theta);
r_max = r_max.max(r[i].abs());
points.push(point);
}
let r_max = if r_max > 0.0 { r_max } else { 1.0 };
let closed = is_full_turn(&finite_theta);
let fill_polygon = if config.fill && !points.is_empty() {
let mut polygon: Vec<(f64, f64)> = points.iter().map(|p| (p.x, p.y)).collect();
if !closed {
polygon.push((0.0, 0.0));
}
polygon
} else {
vec![]
};
let (ring_radii, spokes) = polar_grid(r_max, config.rgrid_count, config.thetagrid_count);
let grid_rings = if config.show_rgrid {
ring_radii
.iter()
.map(|&radius| circle_vertices(0.0, 0.0, radius, POLAR_RING_SEGMENTS))
.collect()
} else {
vec![]
};
let grid_spokes = if config.show_thetagrid {
spokes
} else {
Vec::new()
};
let theta_labels = if config.show_theta_labels {
let label_radius = r_max * POLAR_LABEL_RADIUS;
(0..config.thetagrid_count)
.map(|i| {
let angle = 2.0 * PI * i as f64 / config.thetagrid_count as f64;
let degrees = (angle * 180.0 / PI).round() as i32;
PositionedLabel {
x: label_radius * angle.cos(),
y: label_radius * angle.sin(),
text: format!("{}°", degrees),
}
})
.collect()
} else {
vec![]
};
let r_labels = if config.show_r_labels {
let label_angle = config.r_label_position * PI / 180.0; (1..=config.rgrid_count)
.map(|i| {
let radius = r_max * i as f64 / config.rgrid_count as f64;
PositionedLabel {
x: radius * label_angle.cos(),
y: radius * label_angle.sin(),
text: format!("{:.1}", radius),
}
})
.collect()
} else {
vec![]
};
PolarPlotData {
points,
r_max,
fill_polygon,
closed,
grid_rings,
grid_spokes,
theta_labels,
r_labels,
config: config.clone(),
}
}
const FULL_TURN_EPSILON: f64 = 1e-6;
const FULL_TURN_MIN_SAMPLES: usize = 3;
fn is_full_turn(theta: &[f64]) -> bool {
use std::f64::consts::TAU;
let mut count = 0usize;
let mut previous = 0.0_f64;
let mut cumulative = 0.0_f64;
let mut lowest = 0.0_f64;
let mut highest = 0.0_f64;
for t in theta.iter().copied().filter(|t| t.is_finite()) {
if count > 0 {
let step = t - previous;
cumulative += step - TAU * (step / TAU).round();
lowest = lowest.min(cumulative);
highest = highest.max(cumulative);
}
count += 1;
previous = t;
}
if count < FULL_TURN_MIN_SAMPLES {
return false;
}
let sweep = highest - lowest;
let mean_step = sweep / (count - 1) as f64;
sweep + mean_step >= TAU - FULL_TURN_EPSILON
}
#[allow(clippy::type_complexity)]
pub fn polar_grid(
r_max: f64,
r_count: usize,
theta_count: usize,
) -> (Vec<f64>, Vec<((f64, f64), (f64, f64))>) {
let radii: Vec<f64> = (1..=r_count)
.map(|i| r_max * i as f64 / r_count as f64)
.collect();
let angular_step = 2.0 * std::f64::consts::PI / theta_count as f64;
let angular_lines: Vec<((f64, f64), (f64, f64))> = (0..theta_count)
.map(|i| {
let theta = i as f64 * angular_step;
((0.0, 0.0), (r_max * theta.cos(), r_max * theta.sin()))
})
.collect();
(radii, angular_lines)
}
pub fn circle_vertices(cx: f64, cy: f64, radius: f64, n_segments: usize) -> Vec<(f64, f64)> {
let step = 2.0 * std::f64::consts::PI / n_segments as f64;
(0..=n_segments)
.map(|i| {
let theta = i as f64 * step;
(cx + radius * theta.cos(), cy + radius * theta.sin())
})
.collect()
}
impl PlotCompute for PolarPlot {
type Input<'a> = PolarPlotInput<'a>;
type Config = PolarPlotConfig;
type Output = PolarPlotData;
fn compute(input: Self::Input<'_>, config: &Self::Config) -> Result<Self::Output> {
if input.r.is_empty() || input.theta.is_empty() {
return Err(crate::core::PlottingError::EmptyDataSet);
}
Ok(compute_polar_plot(input.r, input.theta, config))
}
}
impl PlotData for PolarPlotData {
fn data_bounds(&self) -> ((f64, f64), (f64, f64)) {
let radius = self.bounds_radius();
((-radius, radius), (-radius, radius))
}
fn is_empty(&self) -> bool {
self.points.is_empty()
}
}
impl PlotRender for PolarPlotData {
fn render(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
) -> Result<()> {
self.render_styled(renderer, area, theme, color, 1.0, None)
}
fn render_styled(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
alpha: f32,
line_width: Option<f32>,
) -> Result<()> {
self.render_styled_with_grid(renderer, area, theme, color, alpha, line_width, None)
}
fn render_styled_with_grid(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
alpha: f32,
line_width: Option<f32>,
grid_style: Option<&crate::core::GridStyle>,
) -> Result<()> {
if self.points.is_empty() {
return Ok(());
}
let config = &self.config;
let base_color = config.color.unwrap_or(color);
let line_color =
base_color.with_alpha((f32::from(base_color.a) / 255.0) * alpha.clamp(0.0, 1.0));
let render_scale = renderer.render_scale();
let line_width_px = render_scale.points_to_pixels(line_width.unwrap_or(config.line_width));
let marker_size_px = render_scale.points_to_pixels(config.marker_size);
let label_font_size_px = render_scale.points_to_pixels(config.label_font_size);
if grid_style.is_none_or(|style| style.visible) {
let grid_color = grid_style.map_or(theme.grid_color, |style| {
style.color.with_alpha(style.alpha)
});
let grid_line_width =
render_scale.points_to_pixels(grid_style.map_or(0.5, |style| style.line_width));
let grid_line_style = grid_style
.map(|style| style.line_style.clone())
.unwrap_or(LineStyle::Solid);
for ring in &self.grid_rings {
if ring.len() < 2 {
continue;
}
let screen_ring: Vec<(f32, f32)> = ring
.iter()
.map(|(x, y)| area.data_to_screen(*x, *y))
.collect();
renderer.draw_polyline(
&screen_ring,
grid_color,
grid_line_width,
grid_line_style.clone(),
)?;
}
for &((x1, y1), (x2, y2)) in &self.grid_spokes {
let (sx1, sy1) = area.data_to_screen(x1, y1);
let (sx2, sy2) = area.data_to_screen(x2, y2);
renderer.draw_line(
sx1,
sy1,
sx2,
sy2,
grid_color,
grid_line_width,
grid_line_style.clone(),
)?;
}
}
if config.fill && !self.fill_polygon.is_empty() {
let fill_color = base_color.with_alpha(
(f32::from(base_color.a) / 255.0) * config.fill_alpha * alpha.clamp(0.0, 1.0),
);
let screen_polygon: Vec<(f32, f32)> = self
.fill_polygon
.iter()
.map(|(x, y)| area.data_to_screen(*x, *y))
.collect();
renderer.draw_filled_polygon(&screen_polygon, fill_color)?;
}
if self.points.len() > 1 {
for i in 0..self.points.len() - 1 {
let p1 = &self.points[i];
let p2 = &self.points[i + 1];
let (sx1, sy1) = area.data_to_screen(p1.x, p1.y);
let (sx2, sy2) = area.data_to_screen(p2.x, p2.y);
renderer.draw_line(
sx1,
sy1,
sx2,
sy2,
line_color,
line_width_px,
LineStyle::Solid,
)?;
}
if let Some(segment) = self.closing_segment() {
let ((x1, y1), (x2, y2)) = segment;
let (sx1, sy1) = area.data_to_screen(x1, y1);
let (sx2, sy2) = area.data_to_screen(x2, y2);
renderer.draw_line(
sx1,
sy1,
sx2,
sy2,
line_color,
line_width_px,
LineStyle::Solid,
)?;
}
}
if config.marker_size > 0.0 {
for point in &self.points {
let (sx, sy) = area.data_to_screen(point.x, point.y);
renderer.draw_marker(sx, sy, marker_size_px, MarkerStyle::Circle, line_color)?;
}
}
let label_color = theme.foreground;
for label in &self.theta_labels {
let (sx, sy) = area.data_to_screen(label.x, label.y);
renderer.draw_text_centered(&label.text, sx, sy, label_font_size_px, label_color)?;
}
for label in &self.r_labels {
let (sx, sy) = area.data_to_screen(label.x, label.y);
renderer.draw_text_centered(&label.text, sx, sy, label_font_size_px, label_color)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::{PI, TAU};
#[test]
fn test_polar_point_from_polar() {
let point = PolarPoint::from_polar(1.0, 0.0);
assert!((point.x - 1.0).abs() < 1e-10);
assert!((point.y - 0.0).abs() < 1e-10);
let point = PolarPoint::from_polar(1.0, PI / 2.0);
assert!((point.x - 0.0).abs() < 1e-10);
assert!((point.y - 1.0).abs() < 1e-10);
}
#[test]
fn test_polar_point_from_cartesian() {
let point = PolarPoint::from_cartesian(1.0, 0.0);
assert!((point.r - 1.0).abs() < 1e-10);
assert!((point.theta - 0.0).abs() < 1e-10);
}
#[test]
fn test_compute_polar_plot() {
let r = vec![1.0, 2.0, 3.0];
let theta = vec![0.0, PI / 2.0, PI];
let config = PolarPlotConfig::default();
let data = compute_polar_plot(&r, &theta, &config);
assert_eq!(data.points.len(), 3);
assert!((data.r_max - 3.0).abs() < 1e-10);
}
#[test]
fn test_full_turn_fill_has_no_origin_seam() {
let n = 200;
let theta: Vec<f64> = (0..n).map(|i| i as f64 * 2.0 * PI / n as f64).collect();
let r: Vec<f64> = theta.iter().map(|&t| 2.0 + t.cos()).collect();
let config = PolarPlotConfig::default().fill(true);
let data = compute_polar_plot(&r, &theta, &config);
assert_eq!(data.fill_polygon.len(), n);
assert!(
!data
.fill_polygon
.iter()
.any(|&(x, y)| x.abs() < 1e-12 && y.abs() < 1e-12),
"full-turn fill must not close through the origin"
);
let theta: Vec<f64> = (0..=n).map(|i| i as f64 * 2.0 * PI / n as f64).collect();
let r: Vec<f64> = theta.iter().map(|&t| 2.0 + t.cos()).collect();
let data = compute_polar_plot(&r, &theta, &config);
assert_eq!(data.fill_polygon.len(), n + 1);
}
#[test]
fn test_partial_arc_fill_closes_through_origin() {
let n = 50;
let theta: Vec<f64> = (0..n).map(|i| i as f64 * PI / (n - 1) as f64).collect();
let r: Vec<f64> = vec![1.0; n];
let config = PolarPlotConfig::default().fill(true);
let data = compute_polar_plot(&r, &theta, &config);
assert_eq!(data.fill_polygon.len(), n + 1);
let last = data.fill_polygon[n];
assert!(last.0.abs() < 1e-12 && last.1.abs() < 1e-12);
}
#[test]
fn test_is_full_turn() {
assert!(is_full_turn(&[0.0, PI, 2.0 * PI]));
assert!(is_full_turn(&[0.0, 2.0 * PI / 3.0, 4.0 * PI / 3.0]));
assert!(!is_full_turn(&[0.0, PI / 2.0, PI]));
assert!(!is_full_turn(&[0.0, PI]));
assert!(!is_full_turn(&[]));
assert!(is_full_turn(&[0.0, f64::NAN, PI, 2.0 * PI]));
}
fn wrapped_sweep(start: f64, sweep: f64, n: usize) -> Vec<f64> {
(0..n)
.map(|i| (start + sweep * i as f64 / n as f64).rem_euclid(TAU))
.collect()
}
#[test]
fn test_is_full_turn_partial_arc_crossing_zero() {
assert!(!is_full_turn(&[6.1, 6.2, 0.0, 0.1]));
assert!(!is_full_turn(&[0.1, 0.0, 6.2, 6.1]));
assert!(!is_full_turn(&wrapped_sweep(6.0, 0.5, 32)));
}
#[test]
fn test_wrapping_partial_arc_fills_as_a_wedge() {
let theta = vec![6.1, 6.2, 0.0, 0.1];
let r = vec![1.0; theta.len()];
let config = PolarPlotConfig::default().fill(true);
let data = compute_polar_plot(&r, &theta, &config);
assert!(!data.closed, "a 0.28 rad wedge is not a full turn");
assert!(data.closing_segment().is_none());
assert_eq!(data.fill_polygon.len(), theta.len() + 1);
let last = data.fill_polygon[theta.len()];
assert!(last.0.abs() < 1e-12 && last.1.abs() < 1e-12);
}
#[test]
fn test_is_full_turn_not_starting_at_zero() {
let theta = wrapped_sweep(1.0, TAU, 180);
assert!(is_full_turn(&theta));
let r = vec![1.0; theta.len()];
let config = PolarPlotConfig::default().fill(true);
let data = compute_polar_plot(&r, &theta, &config);
assert!(data.closed);
assert_eq!(data.fill_polygon.len(), theta.len());
assert!(
!data
.fill_polygon
.iter()
.any(|&(x, y)| x.abs() < 1e-12 && y.abs() < 1e-12)
);
}
#[test]
fn test_is_full_turn_clockwise() {
assert!(is_full_turn(&wrapped_sweep(0.0, -TAU, 180)));
assert!(is_full_turn(&wrapped_sweep(2.4, -TAU, 180)));
assert!(is_full_turn(&[0.0, -2.0 * PI / 3.0, -4.0 * PI / 3.0]));
assert!(!is_full_turn(&wrapped_sweep(0.3, -PI / 2.0, 16)));
}
#[test]
fn test_is_full_turn_half_circle() {
let n = 50;
let theta: Vec<f64> = (0..n).map(|i| i as f64 * PI / (n - 1) as f64).collect();
assert!(!is_full_turn(&theta));
assert!(!is_full_turn(&wrapped_sweep(5.5, PI, n)));
}
#[test]
fn test_is_full_turn_multi_turn_spiral() {
let n = 100;
let theta: Vec<f64> = (0..n).map(|i| i as f64 * 2.0 * TAU / n as f64).collect();
assert!(is_full_turn(&theta));
assert!(is_full_turn(&wrapped_sweep(0.7, 2.0 * TAU, n)));
}
#[test]
fn test_is_full_turn_survives_backtracking() {
assert!(is_full_turn(&[
0.0,
PI / 2.0,
PI,
3.0 * PI / 2.0,
TAU,
3.0 * PI / 2.0,
]));
assert!(!is_full_turn(&[0.0, PI / 2.0, PI, PI / 2.0, 0.0]));
assert!(is_full_turn(&[
0.0,
-PI / 2.0,
-PI,
-3.0 * PI / 2.0,
-TAU,
-3.0 * PI / 2.0,
]));
}
#[test]
fn test_compute_polar_plot_drops_non_finite_samples() {
let config = PolarPlotConfig::default();
let data = compute_polar_plot(
&[1.0, 2.0, f64::NAN, 3.0, 4.0],
&[0.0, 1.0, 2.0, f64::INFINITY, 3.0],
&config,
);
assert_eq!(data.points.len(), 3, "non-finite pairs should be dropped");
assert!(
data.points
.iter()
.all(|p| p.x.is_finite() && p.y.is_finite()),
"no NaN or infinite coordinates may survive"
);
assert!(data.r_max.is_finite() && data.r_max > 0.0);
}
#[test]
fn test_closing_segment() {
let n = 200;
let theta: Vec<f64> = (0..n).map(|i| i as f64 * 2.0 * PI / n as f64).collect();
let r: Vec<f64> = vec![1.0; n];
let config = PolarPlotConfig::default();
let data = compute_polar_plot(&r, &theta, &config);
let (from, to) = data.closing_segment().expect("full turn needs closing");
assert!((from.0 - data.points[n - 1].x).abs() < 1e-12);
assert!((to.0 - data.points[0].x).abs() < 1e-12);
let theta: Vec<f64> = (0..=n).map(|i| i as f64 * 2.0 * PI / n as f64).collect();
let r: Vec<f64> = vec![1.0; n + 1];
let data = compute_polar_plot(&r, &theta, &config);
assert!(data.closing_segment().is_none());
let theta: Vec<f64> = (0..n).map(|i| i as f64 * PI / n as f64).collect();
let r: Vec<f64> = vec![1.0; n];
let data = compute_polar_plot(&r, &theta, &config);
assert!(!data.closed);
assert!(data.closing_segment().is_none());
}
#[test]
fn test_fill_disabled_leaves_polygon_empty() {
let r = vec![1.0, 2.0, 3.0];
let theta = vec![0.0, PI / 2.0, PI];
let data = compute_polar_plot(&r, &theta, &PolarPlotConfig::default());
assert!(data.fill_polygon.is_empty());
}
#[test]
fn test_polar_grid() {
let (radii, lines) = polar_grid(10.0, 5, 8);
assert_eq!(radii.len(), 5);
assert_eq!(lines.len(), 8);
assert!((radii[4] - 10.0).abs() < 1e-10);
}
#[test]
fn computed_polar_data_carries_its_grid() {
let r = vec![1.0, 2.0, 4.0];
let theta = vec![0.0, PI / 2.0, PI];
let config = PolarPlotConfig::default();
let data = compute_polar_plot(&r, &theta, &config);
assert_eq!(data.grid_rings.len(), config.rgrid_count);
assert_eq!(data.grid_spokes.len(), config.thetagrid_count);
for ring in &data.grid_rings {
assert_eq!(ring.len(), POLAR_RING_SEGMENTS + 1);
assert!((ring[0].0 - ring[ring.len() - 1].0).abs() < 1e-9);
assert!((ring[0].1 - ring[ring.len() - 1].1).abs() < 1e-9);
}
let outer = data.grid_rings.last().expect("outer ring");
for &(x, y) in outer {
assert!((x.hypot(y) - data.r_max).abs() < 1e-9);
}
for &((x1, y1), (x2, y2)) in &data.grid_spokes {
assert!(x1.abs() < 1e-12 && y1.abs() < 1e-12);
assert!((x2.hypot(y2) - data.r_max).abs() < 1e-9);
}
}
#[test]
fn grid_visibility_flags_are_live() {
let r = vec![1.0, 2.0, 3.0];
let theta = vec![0.0, PI / 2.0, PI];
let grid_of = |config: PolarPlotConfig| compute_polar_plot(&r, &theta, &config);
let no_rings = grid_of(PolarPlotConfig::default().show_rgrid(false));
assert!(no_rings.grid_rings.is_empty());
assert!(!no_rings.grid_spokes.is_empty());
let no_spokes = grid_of(PolarPlotConfig::default().show_thetagrid(false));
assert!(!no_spokes.grid_rings.is_empty());
assert!(no_spokes.grid_spokes.is_empty());
let counted = grid_of(PolarPlotConfig::default().rgrid_count(3).thetagrid_count(6));
assert_eq!(counted.grid_rings.len(), 3);
assert_eq!(counted.grid_spokes.len(), 6);
assert_eq!(counted.r_labels.len(), 3);
assert_eq!(counted.theta_labels.len(), 6);
}
#[test]
fn radial_labels_sit_on_their_rings() {
let r = vec![0.5, 2.0, 3.0];
let theta = vec![0.0, 1.0, 2.0];
let data = compute_polar_plot(&r, &theta, &PolarPlotConfig::default());
for (ring, label) in data.grid_rings.iter().zip(&data.r_labels) {
let ring_radius = ring[0].0.hypot(ring[0].1);
let label_radius = label.x.hypot(label.y);
assert!((ring_radius - label_radius).abs() < 1e-9);
}
}
#[test]
fn polar_reserves_the_same_square_as_radar() {
assert_eq!(POLAR_LABEL_RADIUS, RADAR_LABEL_RADIUS);
assert_eq!(POLAR_BOUNDS_RADIUS, RADAR_BOUNDS_RADIUS);
const { assert!(POLAR_BOUNDS_RADIUS > POLAR_LABEL_RADIUS) };
let r = vec![1.0, 2.0, 4.0];
let theta = vec![0.0, PI / 2.0, PI];
let data = compute_polar_plot(&r, &theta, &PolarPlotConfig::default());
assert!((data.bounds_radius() - data.r_max * POLAR_BOUNDS_RADIUS).abs() < 1e-12);
let ((x_min, x_max), (y_min, y_max)) = data.data_bounds();
assert!((x_max - data.bounds_radius()).abs() < 1e-12);
assert!((x_min + data.bounds_radius()).abs() < 1e-12);
assert!((y_max - data.bounds_radius()).abs() < 1e-12);
assert!((y_min + data.bounds_radius()).abs() < 1e-12);
for label in data.theta_labels.iter().chain(&data.r_labels) {
assert!(label.x.abs() <= data.bounds_radius());
assert!(label.y.abs() <= data.bounds_radius());
}
assert!(data.r_max / data.bounds_radius() > 0.75);
}
#[test]
fn test_circle_vertices() {
let vertices = circle_vertices(0.0, 0.0, 1.0, 4);
assert_eq!(vertices.len(), 5); }
#[test]
fn test_polar_config_implements_plot_config() {
fn assert_plot_config<T: PlotConfig>() {}
assert_plot_config::<PolarPlotConfig>();
}
#[test]
fn test_polar_plot_compute_trait() {
use crate::plots::traits::PlotCompute;
let r = vec![1.0, 2.0, 3.0];
let theta = vec![0.0, PI / 2.0, PI];
let config = PolarPlotConfig::default();
let input = PolarPlotInput::new(&r, &theta);
let result = PolarPlot::compute(input, &config);
assert!(result.is_ok());
let polar_data = result.unwrap();
assert_eq!(polar_data.points.len(), 3);
}
#[test]
fn test_polar_plot_data_trait() {
use crate::plots::traits::{PlotCompute, PlotData};
let r = vec![1.0, 2.0, 3.0];
let theta = vec![0.0, PI / 2.0, PI];
let config = PolarPlotConfig::default();
let input = PolarPlotInput::new(&r, &theta);
let polar_data = PolarPlot::compute(input, &config).unwrap();
let ((x_min, x_max), (y_min, y_max)) = polar_data.data_bounds();
assert!(x_min < 0.0);
assert!(x_max > 0.0);
assert!(y_min < 0.0);
assert!(y_max > 0.0);
assert!(!polar_data.is_empty());
}
}