use std::f64::consts::PI;
use kuva::plot::polar::{PolarMode, PolarPlot};
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::render::render::render_multiple;
use kuva::backend::svg::SvgBackend;
use kuva::TickFormat;
fn render(plot: PolarPlot) -> String {
let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots);
SvgBackend.render_scene(&render_multiple(plots, layout))
}
fn render_titled(plot: PolarPlot, title: &str) -> String {
let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots).with_title(title);
SvgBackend.render_scene(&render_multiple(plots, layout))
}
fn write(name: &str, svg: &str) {
std::fs::create_dir_all("test_outputs").ok();
std::fs::write(format!("test_outputs/{name}.svg"), svg).unwrap();
}
#[test]
fn test_polar_basic() {
let theta: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
let r: Vec<f64> = theta.iter().map(|&t| 1.0 + t.to_radians().cos()).collect();
let plot = PolarPlot::new().with_series(r, theta);
let svg = render(plot);
assert!(svg.contains("<svg"));
assert!(svg.contains("<circle") || svg.contains("<path"));
write("polar_basic", &svg);
}
#[test]
fn test_polar_line() {
let theta: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
let r: Vec<f64> = vec![1.5; 36];
let plot = PolarPlot::new().with_series_line(r, theta);
let svg = render(plot);
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
write("polar_line", &svg);
}
#[test]
fn test_polar_grid() {
let theta: Vec<f64> = (0..12).map(|i| i as f64 * 30.0).collect();
let r: Vec<f64> = vec![1.0; 12];
let plot = PolarPlot::new()
.with_series(r, theta)
.with_grid(true)
.with_r_grid_lines(4);
let svg = render(plot);
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
write("polar_grid", &svg);
}
#[test]
fn test_polar_clockwise() {
let theta: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
let r: Vec<f64> = theta.iter().map(|&t| 1.0 + 0.5 * t.to_radians().cos()).collect();
let plot = PolarPlot::new()
.with_series(r, theta)
.with_clockwise(true)
.with_theta_start(0.0);
let svg = render(plot);
assert!(svg.contains("<svg"));
write("polar_clockwise", &svg);
}
#[test]
fn test_polar_r_max_override() {
let theta: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
let r: Vec<f64> = vec![0.5; 36];
let plot = PolarPlot::new()
.with_series(r, theta)
.with_r_max(2.0);
let svg = render(plot);
assert!(svg.contains("<svg"));
write("polar_r_max", &svg);
}
#[test]
fn test_polar_multiple_series() {
let theta: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
let r1: Vec<f64> = vec![1.0; 36];
let r2: Vec<f64> = vec![2.0; 36];
let plot = PolarPlot::new()
.with_series_labeled(r1, theta.clone(), "Series A", PolarMode::Scatter)
.with_series_labeled(r2, theta, "Series B", PolarMode::Scatter);
let svg = render(plot);
assert!(svg.contains("<svg"));
assert!(svg.contains("<circle") || svg.contains("<path"));
write("polar_multiple_series", &svg);
}
#[test]
fn test_polar_legend() {
let theta: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
let r: Vec<f64> = vec![1.0; 36];
let plot = PolarPlot::new()
.with_series_labeled(r, theta, "Wind speed", PolarMode::Scatter)
.with_legend(true);
let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots)
.with_title("Polar Legend Test");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
assert!(svg.contains("<svg"));
assert!(svg.contains("Wind speed"));
write("polar_legend", &svg);
}
#[test]
fn test_polar_x_tick_format() {
let theta: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
let r: Vec<f64> = vec![1.0; 36];
let plot = PolarPlot::new()
.with_series_labeled(r, theta, "Wind speed", PolarMode::Scatter)
.with_theta_divisions(8)
.with_legend(true);
let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots)
.with_x_tick_format(TickFormat::Custom(std::sync::Arc::new(
|v| {
if v < 45.0 {
"N".to_string()
} else if v < 90.0 {
"NE".to_string()
} else if v < 135.0 {
"E".to_string()
} else if v < 180.0 {
"SE".to_string()
} else if v < 225.0 {
"S".to_string()
} else if v < 270.0 {
"SW".to_string()
} else if v < 315.0 {
"W".to_string()
} else {
"NW".to_string()
}
},
)))
.with_title("Polar Custom X Ticks Test");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
assert!(svg.contains("<svg"));
assert!(svg.contains("Wind speed"));
assert!(svg.contains("NE"));
assert!(svg.contains("SE"));
assert!(svg.contains("SW"));
assert!(svg.contains("NW"));
write("polar_x_ticks", &svg);
}
#[test]
fn test_polar_cardioid_with_observations() {
let n = 360usize;
let theta_line: Vec<f64> = (0..=n).map(|i| i as f64).collect();
let r_line: Vec<f64> = theta_line
.iter()
.map(|&t| 1.0 + (t * PI / 180.0).cos())
.collect();
let mut state: u64 = 77777;
let mut lcg = || -> f64 {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(state >> 33) as f64 / (u64::MAX >> 33) as f64
};
let theta_obs: Vec<f64> = (0..24).map(|i| i as f64 * 15.0).collect();
let r_obs: Vec<f64> = theta_obs
.iter()
.map(|&t| {
let ideal = 1.0 + (t * PI / 180.0).cos();
(ideal + (lcg() - 0.5) * 0.25).max(0.0)
})
.collect();
let plot = PolarPlot::new()
.with_series_labeled(r_line, theta_line, "Cardioid", PolarMode::Line)
.with_color("#2171b5")
.with_series_labeled(r_obs, theta_obs, "Observations", PolarMode::Scatter)
.with_color("#d94801")
.with_r_max(2.0)
.with_r_grid_lines(4)
.with_theta_divisions(12)
.with_legend(true);
let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots)
.with_title("Cardioid r = 1 + cos(θ)");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
assert!(svg.contains("Cardioid"));
assert!(svg.contains("Observations"));
write("polar_cardioid_observations", &svg);
}
#[test]
fn test_polar_three_curves() {
let n = 720usize; let theta: Vec<f64> = (0..n).map(|i| i as f64 * 360.0 / n as f64).collect();
let r_rose: Vec<f64> = theta
.iter()
.map(|&t| (3.0 * t * PI / 180.0).cos().abs())
.collect();
let r_lemniscate: Vec<f64> = theta
.iter()
.map(|&t| (2.0 * t * PI / 180.0).cos().abs().sqrt())
.collect();
let r_circle: Vec<f64> = vec![1.0; n];
let plot = PolarPlot::new()
.with_series_labeled(r_rose, theta.clone(), "Rose |cos 3θ|", PolarMode::Line)
.with_color("#e41a1c")
.with_series_labeled(r_lemniscate, theta.clone(), "Lemniscate √|cos 2θ|", PolarMode::Line)
.with_color("#377eb8")
.with_series_labeled(r_circle, theta, "Unit circle", PolarMode::Line)
.with_color("#4daf4a")
.with_r_max(1.0)
.with_r_grid_lines(4)
.with_theta_divisions(8)
.with_legend(true);
let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots)
.with_title("Polar Curves");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
assert!(svg.contains("Rose"));
assert!(svg.contains("Lemniscate"));
assert!(svg.contains("Unit circle"));
write("polar_three_curves", &svg);
}
#[test]
fn test_polar_spiral_math_convention() {
let n = 1080usize; let theta: Vec<f64> = (0..=n).map(|i| i as f64 / 3.0).collect(); let r: Vec<f64> = theta
.iter()
.map(|&t| t / 360.0) .collect();
let plot = PolarPlot::new()
.with_series_labeled(r, theta, "Archimedean spiral", PolarMode::Line)
.with_color("#6a3d9a")
.with_theta_start(90.0)
.with_clockwise(false)
.with_r_grid_lines(3)
.with_theta_divisions(12)
.with_legend(true);
let svg = render_titled(plot, "Spiral (math convention)");
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
assert!(svg.contains("Archimedean spiral"));
write("polar_spiral", &svg);
}
#[test]
fn test_polar_wind_rose_style() {
let mut state: u64 = 31415;
let mut lcg = || -> f64 {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(state >> 33) as f64 / (u64::MAX >> 33) as f64
};
let centers = [("North", 0.0_f64), ("East", 90.0), ("South", 180.0), ("West", 270.0)];
let colors = ["#e41a1c", "#377eb8", "#4daf4a", "#ff7f00"];
let mut plot = PolarPlot::new()
.with_r_max(2.5)
.with_r_grid_lines(5)
.with_theta_divisions(16) .with_legend(true);
for (&(label, center_deg), color) in centers.iter().zip(colors.iter()) {
let mut r_vals = Vec::new();
let mut t_vals = Vec::new();
for _ in 0..20 {
let spread = (lcg() - 0.5) * 30.0; let t = center_deg + spread;
let r = 1.2 + lcg() * 1.0; t_vals.push(t);
r_vals.push(r);
}
plot = plot
.with_series_labeled(r_vals, t_vals, label, PolarMode::Scatter)
.with_color(*color);
}
let theta_ref: Vec<f64> = (0..=360).map(|i| i as f64).collect();
let r_ref: Vec<f64> = vec![1.0; 361];
plot = plot
.with_series_labeled(r_ref, theta_ref, "Calm radius", PolarMode::Line)
.with_color("#aaaaaa");
let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots)
.with_title("Wind Rose (Compass Convention)");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
assert!(svg.contains("<svg"));
assert!(svg.contains("North"));
assert!(svg.contains("East"));
assert!(svg.contains("South"));
assert!(svg.contains("West"));
assert!(svg.contains("Calm radius"));
write("polar_wind_rose", &svg);
}
#[test]
fn test_polar_custom_tick_overrides_zero_degree() {
use std::sync::Arc;
use kuva::TickFormat;
let r: Vec<f64> = vec![1.0, 2.0, 1.5, 0.5, 1.0]; let theta: Vec<f64> = vec![0.0, 90.0, 180.0, 270.0, 360.0];
let plot = PolarPlot::new()
.with_series(r, theta)
.with_theta_divisions(4);
let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots)
.with_x_tick_format(TickFormat::Custom(Arc::new(|v| {
match v as u32 {
0 => "North".to_string(),
90 => "East".to_string(),
180 => "South".to_string(),
270 => "West".to_string(),
_ => format!("{v}°"),
}
})));
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
write("polar_custom_tick_zero", &svg);
assert!(svg.contains(">North<"), "θ=0° spoke must show custom label 'North'");
assert!(svg.contains(">East<"), "θ=90° spoke must show 'East'");
assert!(svg.contains(">South<"), "θ=180° spoke must show 'South'");
assert!(svg.contains(">West<"), "θ=270° spoke must show 'West'");
assert!(!svg.contains(">0°<"),
"θ=0° spoke must not show hardcoded '0°' when a custom format is set");
}
#[test]
fn test_polar_default_degree_format() {
let r: Vec<f64> = vec![1.0; 5];
let theta: Vec<f64> = vec![0.0, 90.0, 180.0, 270.0, 360.0];
let plot = PolarPlot::new()
.with_series(r, theta)
.with_theta_divisions(4);
let svg = render(plot);
write("polar_default_degree_format", &svg);
assert!(svg.contains(">0°<"), "default polar format must show '0°' at θ=0");
assert!(svg.contains(">90°<"), "default polar format must show '90°'");
assert!(svg.contains(">180°<"), "default polar format must show '180°'");
assert!(svg.contains(">270°<"), "default polar format must show '270°'");
}
#[test]
fn test_polar_r_min_basic() {
let theta: Vec<f64> = (0..8).map(|i| i as f64 * 45.0).collect();
let r: Vec<f64> = vec![0.5, 0.75, 1.0, 1.25, 1.5, 1.25, 1.0, 0.75];
let plot = PolarPlot::new()
.with_series(r, theta)
.with_r_min(0.5)
.with_r_max(1.5)
.with_r_grid_lines(4);
let svg = render(plot);
assert!(svg.contains("<svg"));
assert!(svg.contains(">0.75<") || svg.contains(">1<") || svg.contains(">1.5<"),
"ring labels should show actual r values relative to r_min");
write("polar_r_min_basic", &svg);
}
#[test]
fn test_polar_r_min_negative() {
let theta: Vec<f64> = (0..=360).map(|i| i as f64).collect();
let r: Vec<f64> = theta.iter().map(|&t| {
let rad = t.to_radians();
-20.0 * (1.0 - rad.cos().abs())
}).collect();
let plot = PolarPlot::new()
.with_series_line(r, theta)
.with_r_min(-20.0)
.with_r_max(0.0)
.with_r_grid_lines(4);
let plots = vec![Plot::Polar(plot)];
let layout = Layout::auto_from_plots(&plots)
.with_title("Antenna Pattern (dB)");
let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"), "line series should produce a path element");
assert!(svg.contains(">-") || svg.contains(">0<"),
"ring labels should contain negative or zero r values");
write("polar_r_min_negative", &svg);
}
#[test]
fn test_polar_r_min_clamp_to_centre() {
let theta: Vec<f64> = vec![0.0, 90.0, 180.0, 270.0];
let r: Vec<f64> = vec![0.0, 1.5, 2.0, 1.5];
let plot = PolarPlot::new()
.with_series(r, theta)
.with_r_min(1.0)
.with_r_max(2.0);
let svg = render(plot);
assert!(svg.contains("<svg"));
assert!(svg.contains("<circle") || svg.contains("<path"));
write("polar_r_min_clamp", &svg);
}
#[test]
fn test_polar_r_min_auto_r_max() {
let theta: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
let r: Vec<f64> = theta.iter().map(|&t| t.to_radians().sin()).collect();
let plot = PolarPlot::new()
.with_series_line(r, theta)
.with_r_min(-1.0);
let svg = render(plot);
assert!(svg.contains("<svg"));
assert!(svg.contains("<path"));
write("polar_r_min_auto_r_max", &svg);
}
#[test]
fn test_polar_r_min_with_explicit_r_max() {
let theta: Vec<f64> = (0..12).map(|i| i as f64 * 30.0).collect();
let r: Vec<f64> = vec![-5.0, -3.0, 0.0, 3.0, 5.0, 3.0, 0.0, -3.0, -5.0, -3.0, 0.0, 3.0];
let plot = PolarPlot::new()
.with_series(r, theta)
.with_r_min(-5.0)
.with_r_max(5.0)
.with_r_grid_lines(5)
.with_legend(false);
let svg = render(plot);
assert!(svg.contains("<svg"));
write("polar_r_min_explicit_r_max", &svg);
}