#![allow(clippy::cast_precision_loss)]
use std::f64::consts::{FRAC_PI_2, TAU};
use starsight_layer_1::backends::DrawBackend;
use starsight_layer_1::errors::Result;
use starsight_layer_1::paths::{Path, PathCommand, PathStyle};
use starsight_layer_1::primitives::{Color, Point};
use starsight_layer_2::coords::Coord;
use crate::marks::{DataExtent, LegendGlyph, Mark};
#[derive(Clone, Debug)]
pub struct ArcMark {
pub thetas: Vec<f64>,
pub rs: Vec<f64>,
pub theta_half_widths: Option<Vec<f64>>,
pub r_inner: Option<Vec<f64>>,
pub colors: Vec<Color>,
pub stroke: Option<(Color, f32)>,
pub start_offset: f64,
pub wedge_labels: Option<Vec<String>>,
pub label: Option<String>,
}
impl ArcMark {
#[must_use]
pub fn new(mut thetas: Vec<f64>, mut rs: Vec<f64>) -> Self {
let n = thetas.len().min(rs.len());
thetas.truncate(n);
rs.truncate(n);
Self {
thetas,
rs,
theta_half_widths: None,
r_inner: None,
colors: Vec::new(),
stroke: None,
start_offset: 0.0,
wedge_labels: None,
label: None,
}
}
#[must_use]
pub fn wedge_labels(mut self, labels: Vec<String>) -> Self {
self.wedge_labels = Some(labels);
self
}
#[must_use]
pub fn theta_half_widths(mut self, hw: Vec<f64>) -> Self {
self.theta_half_widths = Some(hw);
self
}
#[must_use]
pub fn theta_half_width(mut self, hw: f64) -> Self {
let n = self.thetas.len();
self.theta_half_widths = Some(vec![hw; n]);
self
}
#[must_use]
pub fn r_inner(mut self, r_inner: Vec<f64>) -> Self {
self.r_inner = Some(r_inner);
self
}
#[must_use]
pub fn colors(mut self, colors: Vec<Color>) -> Self {
self.colors = colors;
self
}
#[must_use]
pub fn stroke(mut self, color: Color, width: f32) -> Self {
self.stroke = Some((color, width));
self
}
#[must_use]
pub fn start_offset(mut self, offset: f64) -> Self {
self.start_offset = offset;
self
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
fn half_width_at(&self, i: usize) -> f64 {
if let Some(hw) = &self.theta_half_widths
&& let Some(w) = hw.get(i)
{
return *w;
}
if self.thetas.len() < 2 {
return 0.5; }
let mut sorted: Vec<f64> = self.thetas.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut min_gap = f64::INFINITY;
for w in sorted.windows(2) {
let d = (w[1] - w[0]).abs();
if d > 0.0 && d < min_gap {
min_gap = d;
}
}
if min_gap.is_finite() {
min_gap * 0.5
} else {
0.5
}
}
fn r_inner_at(&self, i: usize) -> f64 {
match &self.r_inner {
Some(v) => v.get(i).copied().unwrap_or(0.0),
None => 0.0,
}
}
fn color_at(&self, i: usize) -> Color {
if self.colors.is_empty() {
crate::marks::palette::POLAR_DEFAULT[i % crate::marks::palette::POLAR_DEFAULT.len()]
} else {
self.colors[i % self.colors.len()]
}
}
}
impl Mark for ArcMark {
fn render(&self, coord: &dyn Coord, backend: &mut dyn DrawBackend) -> Result<()> {
let coord = crate::marks::require_polar(coord)?;
if self.thetas.is_empty() {
return Ok(());
}
let center = coord.center;
let radius = coord.radius;
for (i, (&theta, &r_outer)) in self.thetas.iter().zip(&self.rs).enumerate() {
let r_inner_data = self.r_inner_at(i);
let half_w_data = self.half_width_at(i);
let theta_a = coord.theta_axis.scale.map(theta - half_w_data);
let theta_b = coord.theta_axis.scale.map(theta + half_w_data);
let a_rad = theta_a * TAU + self.start_offset;
let b_rad = theta_b * TAU + self.start_offset;
let r_outer_norm = coord.r_axis.scale.map(r_outer);
let r_inner_norm = coord.r_axis.scale.map(r_inner_data);
let r_out_px = (r_outer_norm * f64::from(radius)) as f32;
let r_in_px = (r_inner_norm * f64::from(radius)) as f32;
let path = build_arc_wedge(center, r_in_px, r_out_px, a_rad, b_rad);
let color = self.color_at(i);
backend.draw_path(&path, &PathStyle::fill(color))?;
if let Some((stroke_color, stroke_width)) = self.stroke {
backend.draw_path(&path, &PathStyle::stroke(stroke_color, stroke_width))?;
}
}
Ok(())
}
fn data_extent(&self) -> Option<DataExtent> {
None
}
fn legend_color(&self) -> Option<Color> {
self.label.as_ref()?;
Some(self.color_at(0))
}
fn legend_label(&self) -> Option<&str> {
self.label.as_deref()
}
fn legend_entries(&self) -> Vec<(Color, String, LegendGlyph)> {
if let Some(labels) = &self.wedge_labels {
return self
.thetas
.iter()
.enumerate()
.filter_map(|(i, _)| {
let label = labels.get(i).filter(|s| !s.is_empty()).cloned()?;
Some((self.color_at(i), label, LegendGlyph::Bar))
})
.collect();
}
if let (Some(c), Some(l)) = (self.legend_color(), self.legend_label())
&& !l.is_empty()
{
vec![(c, l.to_string(), LegendGlyph::Bar)]
} else {
Vec::new()
}
}
fn legend_glyph(&self) -> LegendGlyph {
LegendGlyph::Bar
}
fn wants_axes(&self) -> bool {
false
}
fn wants_polar_grid(&self) -> bool {
false
}
fn prefers_outside_legend(&self) -> bool {
true
}
}
pub(crate) fn build_arc_wedge(center: Point, r_in: f32, r_out: f32, a: f64, b: f64) -> Path {
let cx = center.x;
let cy = center.y;
let outer_start = compass_point(cx, cy, r_out, a);
let mut path = Path::new();
if r_in <= 0.5 {
path = path.move_to(Point::new(cx, cy)).line_to(outer_start);
arc_compass(&mut path, cx, cy, r_out, a, b);
path.close()
} else {
let inner_end = compass_point(cx, cy, r_in, b);
path = path.move_to(outer_start);
arc_compass(&mut path, cx, cy, r_out, a, b);
path = path.line_to(inner_end);
arc_compass(&mut path, cx, cy, r_in, b, a);
path.close()
}
}
pub(crate) fn compass_point(cx: f32, cy: f32, r: f32, angle: f64) -> Point {
let s = angle.sin() as f32;
let c = angle.cos() as f32;
Point::new(cx + r * s, cy - r * c)
}
pub(crate) fn arc_compass(path: &mut Path, cx: f32, cy: f32, r: f32, start: f64, end: f64) {
let segments = ((end - start).abs() / FRAC_PI_2).ceil().max(1.0) as usize;
let step = (end - start) / segments as f64;
for s in 0..segments {
let a0 = start + s as f64 * step;
let a1 = a0 + step;
let k = (4.0 / 3.0) * ((a1 - a0) / 4.0).tan();
let (sin0, cos0) = (a0.sin(), a0.cos());
let (sin1, cos1) = (a1.sin(), a1.cos());
let p1 = Point::new(cx + r * sin1 as f32, cy - r * cos1 as f32);
let c0 = Point::new(
cx + r * (sin0 + k * cos0) as f32,
cy - r * (cos0 - k * sin0) as f32,
);
let c1 = Point::new(
cx + r * (sin1 - k * cos1) as f32,
cy - r * (cos1 + k * sin1) as f32,
);
path.commands.push(PathCommand::CubicTo(c0, c1, p1));
}
}
#[cfg(test)]
mod tests {
use super::ArcMark;
use crate::marks::{LegendGlyph, Mark};
use starsight_layer_1::primitives::Color;
#[test]
fn new_truncates_to_shorter_input() {
let mark = ArcMark::new(vec![0.0, 1.0, 2.0], vec![10.0, 20.0]);
assert_eq!(mark.thetas.len(), 2);
assert_eq!(mark.rs.len(), 2);
}
#[test]
fn default_palette_cycles_when_user_palette_empty() {
let mark = ArcMark::new(vec![0.0, 1.0], vec![10.0, 20.0]);
assert_ne!(mark.color_at(0), mark.color_at(1));
assert_eq!(
mark.color_at(0),
mark.color_at(crate::marks::palette::POLAR_DEFAULT.len())
);
}
#[test]
fn user_palette_cycles_too() {
let mark = ArcMark::new(vec![0.0, 1.0, 2.0], vec![10.0, 20.0, 30.0])
.colors(vec![Color::RED, Color::BLUE]);
assert_eq!(mark.color_at(0), Color::RED);
assert_eq!(mark.color_at(1), Color::BLUE);
assert_eq!(mark.color_at(2), Color::RED);
}
#[test]
fn r_inner_default_is_zero() {
let mark = ArcMark::new(vec![0.0], vec![10.0]);
assert_eq!(mark.r_inner_at(0), 0.0);
}
#[test]
fn r_inner_uses_provided_when_set() {
let mark = ArcMark::new(vec![0.0, 1.0], vec![10.0, 20.0]).r_inner(vec![3.0, 5.0]);
assert_eq!(mark.r_inner_at(0), 3.0);
assert_eq!(mark.r_inner_at(1), 5.0);
}
#[test]
fn half_width_uniform_builder_sets_all() {
let mark = ArcMark::new(vec![0.0, 1.0, 2.0], vec![1.0, 2.0, 3.0]).theta_half_width(0.4);
for i in 0..3 {
assert!((mark.half_width_at(i) - 0.4).abs() < 1e-9);
}
}
#[test]
fn half_width_default_uses_min_neighbor_gap() {
let mark = ArcMark::new(vec![0.0, 1.0, 3.0], vec![10.0, 20.0, 30.0]);
for i in 0..3 {
assert!((mark.half_width_at(i) - 0.5).abs() < 1e-9);
}
}
#[test]
fn legend_glyph_is_bar_and_color_uses_first_slice() {
let mark = ArcMark::new(vec![0.0, 1.0], vec![10.0, 20.0])
.colors(vec![Color::GREEN, Color::BLUE])
.label("series");
assert_eq!(mark.legend_glyph(), LegendGlyph::Bar);
assert_eq!(mark.legend_color(), Some(Color::GREEN));
}
#[test]
fn no_legend_when_unlabeled() {
let mark = ArcMark::new(vec![0.0], vec![10.0]);
assert!(mark.legend_color().is_none());
}
#[test]
fn does_not_want_axes() {
let mark = ArcMark::new(vec![0.0], vec![10.0]);
assert!(!mark.wants_axes());
}
#[test]
fn data_extent_is_none() {
let mark = ArcMark::new(vec![0.0, 1.0], vec![10.0, 20.0]);
assert!(mark.data_extent().is_none());
}
}