use crate::core::color::{DEFAULT_COLOR_CYCLE, Rgba};
use crate::core::{Affine2D, Path};
use crate::render::{GraphicsContext, Renderer};
use crate::skia::SkiaRenderer;
use crate::text::FontSource;
#[derive(Debug, Clone)]
struct PolarLine {
points: Vec<(f64, f64)>,
color: Rgba,
width: f64,
}
#[derive(Debug, Clone)]
struct PolarScatter {
points: Vec<(f64, f64)>,
color: Rgba,
}
#[derive(Debug, Clone)]
struct PolarFill {
points: Vec<(f64, f64)>,
color: Rgba,
}
const SCATTER_MARKER_RADIUS: f64 = 4.0;
const FILL_ALPHA: f64 = 0.4;
const GRID_COLOR: Rgba = Rgba::new(0.7, 0.7, 0.7, 1.0);
const SPINE_COLOR: Rgba = Rgba::new(0.4, 0.4, 0.4, 1.0);
const MARGIN_FRAC: f64 = 0.16;
const RING_SEGMENTS: usize = 180;
const TITLE_SIZE: f64 = 12.0;
const TITLE_PAD: f64 = 6.0;
#[derive(Debug, Clone)]
pub struct PolarAxes {
lines: Vec<PolarLine>,
scatters: Vec<PolarScatter>,
fills: Vec<PolarFill>,
data_rmax: f64,
rmax_override: Option<f64>,
title: Option<String>,
cycle: usize,
}
impl PolarAxes {
#[must_use]
pub fn new() -> Self {
Self {
lines: Vec::new(),
scatters: Vec::new(),
fills: Vec::new(),
data_rmax: 0.0,
rmax_override: None,
title: None,
cycle: 0,
}
}
pub fn set_title(&mut self, title: impl Into<String>) -> &mut Self {
self.title = Some(title.into());
self
}
pub fn set_rmax(&mut self, rmax: f64) -> &mut Self {
if rmax.is_finite() && rmax > 0.0 {
self.rmax_override = Some(rmax);
}
self
}
fn next_color(&mut self) -> Rgba {
let hex = DEFAULT_COLOR_CYCLE[self.cycle % DEFAULT_COLOR_CYCLE.len()];
self.cycle += 1;
Rgba::from_hex(hex).unwrap_or(Rgba::new(0.121, 0.466, 0.705, 1.0))
}
pub fn plot(&mut self, theta: &[f64], r: &[f64]) -> &mut Self {
let n = theta.len().min(r.len());
let mut points = Vec::with_capacity(n);
for i in 0..n {
let ri = if r[i].is_finite() { r[i].max(0.0) } else { 0.0 };
if theta[i].is_finite() {
self.data_rmax = self.data_rmax.max(ri);
points.push((theta[i], ri));
}
}
let color = self.next_color();
self.lines.push(PolarLine {
points,
color,
width: 1.5,
});
self
}
pub fn scatter(&mut self, theta: &[f64], r: &[f64]) -> &mut Self {
let n = theta.len().min(r.len());
let mut points = Vec::with_capacity(n);
for i in 0..n {
let ri = if r[i].is_finite() { r[i].max(0.0) } else { 0.0 };
if theta[i].is_finite() {
self.data_rmax = self.data_rmax.max(ri);
points.push((theta[i], ri));
}
}
if points.is_empty() {
return self;
}
let color = self.next_color();
self.scatters.push(PolarScatter { points, color });
self
}
pub fn fill(&mut self, theta: &[f64], r: &[f64]) -> &mut Self {
let n = theta.len().min(r.len());
let mut points = Vec::with_capacity(n);
for i in 0..n {
let ri = if r[i].is_finite() { r[i].max(0.0) } else { 0.0 };
if theta[i].is_finite() {
self.data_rmax = self.data_rmax.max(ri);
points.push((theta[i], ri));
}
}
if points.len() < 3 {
return self;
}
let color = self.next_color();
self.fills.push(PolarFill { points, color });
self
}
#[must_use]
pub fn rmax(&self) -> f64 {
if let Some(rmax) = self.rmax_override {
return rmax;
}
if self.data_rmax.is_finite() && self.data_rmax > 0.0 {
self.data_rmax
} else {
1.0
}
}
fn geometry(&self, width: f64, height: f64) -> (f64, f64, f64) {
let cx = 0.5 * width;
let cy = 0.5 * height;
let side = width.min(height);
let radius = 0.5 * side * (1.0 - 2.0 * MARGIN_FRAC);
(cx, cy, radius)
}
#[must_use]
pub fn to_pixel(&self, theta: f64, r: f64, width: f64, height: f64) -> (f64, f64) {
let (cx, cy, radius) = self.geometry(width, height);
let frac = (r / self.rmax()).clamp(0.0, 1.0);
let pr = frac * radius;
let px = cx + pr * theta.cos();
let py = cy + pr * theta.sin();
(px, py)
}
fn radial_ticks(&self) -> Vec<f64> {
let rmax = self.rmax();
let step = nice_step(rmax, 5);
if step <= 0.0 {
return vec![rmax];
}
let mut ticks = Vec::new();
let mut v = step;
while v < rmax - step * 1e-6 {
ticks.push(v);
v += step;
}
ticks.push(rmax);
ticks
}
fn stroke_polyline(renderer: &mut dyn Renderer, points: &[[f64; 2]], color: Rgba, width: f64) {
if points.len() < 2 {
return;
}
let path = Path::from_polyline(points);
let gc = GraphicsContext::new()
.with_stroke(color)
.with_line_width(width);
renderer.draw_path(&gc, &path, &Affine2D::identity(), None);
}
fn draw_label(
renderer: &mut dyn Renderer,
font: &FontSource,
text: &str,
anchor_x: f64,
anchor_y: f64,
size: f64,
) {
let extent = font.measure(text, size);
let x = anchor_x - 0.5 * extent.width;
let y = anchor_y - 0.5 * (extent.ascent - extent.descent);
let path = font.text_to_path(text, size, [x, y]);
renderer.draw_path(
&GraphicsContext::new(),
&path,
&Affine2D::identity(),
Some(Rgba::BLACK),
);
}
pub fn draw(&self, renderer: &mut dyn Renderer, font: &FontSource) {
let (width, height) = renderer.canvas_size();
let (cx, cy, radius) = self.geometry(width, height);
let rmax = self.rmax();
for &tick in &self.radial_ticks() {
if (tick - rmax).abs() <= f64::EPSILON {
continue;
}
let frac = (tick / rmax).clamp(0.0, 1.0);
let pr = frac * radius;
let ring: Vec<[f64; 2]> = (0..=RING_SEGMENTS)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / RING_SEGMENTS as f64;
[cx + pr * a.cos(), cy + pr * a.sin()]
})
.collect();
Self::stroke_polyline(renderer, &ring, GRID_COLOR, 0.8);
}
for k in 0..8 {
let a = std::f64::consts::FRAC_PI_4 * k as f64;
let spoke = [[cx, cy], [cx + radius * a.cos(), cy + radius * a.sin()]];
Self::stroke_polyline(renderer, &spoke, GRID_COLOR, 0.8);
}
let perimeter: Vec<[f64; 2]> = (0..=RING_SEGMENTS)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / RING_SEGMENTS as f64;
[cx + radius * a.cos(), cy + radius * a.sin()]
})
.collect();
Self::stroke_polyline(renderer, &perimeter, SPINE_COLOR, 1.2);
for fill in &self.fills {
let mut verts: Vec<[f64; 2]> = fill
.points
.iter()
.map(|&(t, r)| {
let (px, py) = self.to_pixel(t, r, width, height);
[px, py]
})
.collect();
if verts.len() < 3 {
continue;
}
if verts.first() != verts.last() {
verts.push(verts[0]);
}
let path = Path::from_polyline(&verts);
let face = fill.color.with_alpha(fill.color.a * FILL_ALPHA);
let gc = GraphicsContext::new()
.with_stroke(fill.color)
.with_line_width(1.5);
renderer.draw_path(&gc, &path, &Affine2D::identity(), Some(face));
}
for line in &self.lines {
let pts: Vec<[f64; 2]> = line
.points
.iter()
.map(|&(t, r)| {
let (px, py) = self.to_pixel(t, r, width, height);
[px, py]
})
.collect();
Self::stroke_polyline(renderer, &pts, line.color, line.width);
}
let s = renderer.decoration_scale();
let marker = Path::unit_circle();
let marker_r = SCATTER_MARKER_RADIUS * s;
let marker_transform = Affine2D::from_scale(marker_r, marker_r);
for sc in &self.scatters {
let offsets: Vec<[f64; 2]> = sc
.points
.iter()
.map(|&(t, r)| {
let (px, py) = self.to_pixel(t, r, width, height);
[px, py]
})
.collect();
let offset_path = Path::from_polyline(&offsets);
let gc = GraphicsContext::new().with_stroke(sc.color);
renderer.draw_markers(
&gc,
&marker,
&marker_transform,
&offset_path,
&Affine2D::identity(),
Some(sc.color),
);
}
let label_r = radius * 1.08;
let label_size = 11.0 * s;
for k in 0..8 {
let deg = 45 * k;
let a = std::f64::consts::FRAC_PI_4 * k as f64;
let lx = cx + label_r * a.cos();
let ly = cy + label_r * a.sin();
Self::draw_label(renderer, font, &format!("{deg}\u{b0}"), lx, ly, label_size);
}
let radial_size = 9.0 * s;
for &tick in &self.radial_ticks() {
let frac = (tick / rmax).clamp(0.0, 1.0);
let pr = frac * radius;
let lx = cx + pr;
let ly = cy + 0.04 * radius;
Self::draw_label(renderer, font, &format_tick(tick), lx, ly, radial_size);
}
if let Some(title) = &self.title {
let size = TITLE_SIZE * s;
let extent = font.measure(title, size);
let anchor_y = height - TITLE_PAD * s - 0.5 * (extent.ascent + extent.descent);
Self::draw_label(renderer, font, title, 0.5 * width, anchor_y, size);
}
}
#[must_use]
pub fn render_png(&self, width_px: u32, height_px: u32, dpi: f64) -> SkiaRenderer {
let mut renderer = SkiaRenderer::new(width_px, height_px, dpi);
let bg = Path::from_polyline(&[
[0.0, 0.0],
[f64::from(width_px), 0.0],
[f64::from(width_px), f64::from(height_px)],
[0.0, f64::from(height_px)],
[0.0, 0.0],
]);
renderer.draw_path(
&GraphicsContext::new(),
&bg,
&Affine2D::identity(),
Some(Rgba::WHITE),
);
let font = FontSource::dejavu_sans();
self.draw(&mut renderer, &font);
renderer
}
pub fn save_png<P: AsRef<std::path::Path>>(
&self,
path: P,
width_px: u32,
height_px: u32,
dpi: f64,
) -> Result<(), crate::skia::PngError> {
self.render_png(width_px, height_px, dpi).save_png(path)
}
}
impl Default for PolarAxes {
fn default() -> Self {
Self::new()
}
}
fn nice_step(rmax: f64, target: usize) -> f64 {
if !rmax.is_finite() || rmax <= 0.0 || target == 0 {
return 0.0;
}
let raw = rmax / target as f64;
let mag = 10_f64.powf(raw.log10().floor());
let norm = raw / mag;
let nice = if norm <= 1.0 {
1.0
} else if norm <= 2.0 {
2.0
} else if norm <= 5.0 {
5.0
} else {
10.0
};
nice * mag
}
fn format_tick(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
format!("{}", v.round() as i64)
} else {
let s = format!("{v:.2}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::{FRAC_PI_2, TAU};
const W: f64 = 400.0;
const H: f64 = 400.0;
#[test]
fn theta_zero_rmax_maps_to_right_edge() {
let mut ax = PolarAxes::new();
ax.plot(&[0.0, FRAC_PI_2], &[1.0, 1.0]);
let (cx, cy, radius) = ax.geometry(W, H);
let (px, py) = ax.to_pixel(0.0, ax.rmax(), W, H);
assert!((px - (cx + radius)).abs() < 1e-9, "px={px}");
assert!((py - cy).abs() < 1e-9, "py={py}");
}
#[test]
fn theta_half_pi_rmax_maps_to_top() {
let mut ax = PolarAxes::new();
ax.plot(&[0.0], &[2.0]);
let (cx, cy, radius) = ax.geometry(W, H);
let (px, py) = ax.to_pixel(FRAC_PI_2, ax.rmax(), W, H);
assert!((px - cx).abs() < 1e-9, "px={px}");
assert!((py - (cy + radius)).abs() < 1e-9, "py={py}");
}
#[test]
fn grid_produces_expected_ring_and_spoke_counts() {
let mut ax = PolarAxes::new();
ax.plot(&[0.0, FRAC_PI_2, TAU], &[0.0, 0.5, 1.0]);
let ticks = ax.radial_ticks();
assert!(ticks.len() >= 4, "expected ~5 rings, got {}", ticks.len());
assert!(
(*ticks.last().unwrap() - ax.rmax()).abs() < 1e-9,
"outer ring must be rmax"
);
let spokes: Vec<f64> = (0..8)
.map(|k| std::f64::consts::FRAC_PI_4 * k as f64)
.collect();
assert_eq!(spokes.len(), 8);
}
#[test]
fn empty_data_draws_grid_only() {
let ax = PolarAxes::new();
assert!(ax.lines.is_empty());
assert_eq!(ax.rmax(), 1.0);
let r = ax.render_png(128, 128, 72.0);
assert_eq!(r.pixmap().width(), 128);
}
#[test]
fn ragged_input_and_negative_r() {
let mut ax = PolarAxes::new();
ax.plot(&[0.0, 1.0, 2.0], &[-1.0, 0.5]);
assert_eq!(ax.lines[0].points.len(), 2);
assert_eq!(ax.lines[0].points[0].1, 0.0, "negative r clamps to 0");
assert_eq!(ax.data_rmax, 0.5);
}
#[test]
fn set_rmax_overrides_data() {
let mut ax = PolarAxes::new();
ax.set_rmax(10.0);
ax.plot(&[0.0], &[1.0]);
assert_eq!(ax.rmax(), 10.0);
}
#[test]
fn scatter_stores_markers_and_expands_rmax() {
let mut ax = PolarAxes::new();
ax.plot(&[0.0], &[1.0]);
ax.scatter(&[0.0, FRAC_PI_2], &[2.0, 3.0]);
assert_eq!(ax.scatters.len(), 1);
assert_eq!(ax.scatters[0].points.len(), 2);
assert_eq!(ax.rmax(), 3.0, "scatter must grow rmax to its data");
}
#[test]
fn fill_stores_region_and_expands_rmax() {
let mut ax = PolarAxes::new();
ax.fill(&[0.0, FRAC_PI_2, std::f64::consts::PI], &[1.0, 2.0, 4.0]);
assert_eq!(ax.fills.len(), 1);
assert_eq!(ax.rmax(), 4.0, "fill must grow rmax to its data");
let mut ax2 = PolarAxes::new();
ax2.fill(&[0.0, FRAC_PI_2], &[1.0, 1.0]);
assert!(ax2.fills.is_empty());
}
#[test]
fn empty_scatter_and_fill_draw_grid_only() {
let mut ax = PolarAxes::new();
ax.scatter(&[], &[]);
ax.fill(&[], &[]);
assert!(ax.scatters.is_empty());
assert!(ax.fills.is_empty());
let r = ax.render_png(128, 128, 72.0);
assert_eq!(r.pixmap().width(), 128);
}
#[test]
fn scatter_and_fill_render() {
let theta: Vec<f64> = (0..=36).map(|i| i as f64 * TAU / 36.0).collect();
let r: Vec<f64> = theta.iter().map(|t| (2.0 * t).cos().abs()).collect();
let mut ax = PolarAxes::new();
ax.fill(&theta, &r);
ax.scatter(&theta, &r);
let rend = ax.render_png(256, 256, 72.0);
assert_eq!(rend.pixmap().width(), 256);
}
#[test]
fn populated_axes_renders() {
let theta: Vec<f64> = (0..=360).map(|i| i as f64 * TAU / 360.0).collect();
let r: Vec<f64> = theta.iter().map(|t| (2.0 * t).cos().abs()).collect();
let mut ax = PolarAxes::new();
ax.plot(&theta, &r);
let rend = ax.render_png(256, 256, 72.0);
assert_eq!(rend.pixmap().width(), 256);
}
#[test]
fn title_renders_ink_in_the_top_band() {
let mut ax = PolarAxes::new();
ax.plot(&[0.0, FRAC_PI_2], &[1.0, 1.0]);
ax.set_title("polar");
let rend = ax.render_png(256, 256, 100.0);
let px = rend.pixmap();
let mut ink = 0;
for y in 0..18u32 {
for x in 0..px.width() {
let d = px.pixel(x, y).unwrap().demultiply();
if (d.red(), d.green(), d.blue()) != (255, 255, 255) {
ink += 1;
}
}
}
assert!(ink > 20, "expected title ink in the top band, got {ink} px");
}
}