#![allow(clippy::cast_precision_loss)]
use std::f64::consts::TAU;
use starsight_layer_1::backends::DrawBackend;
use starsight_layer_1::errors::Result;
use starsight_layer_1::paths::PathStyle;
use starsight_layer_1::primitives::Color;
use starsight_layer_2::coords::Coord;
use crate::marks::arc::build_arc_wedge;
use crate::marks::{DataExtent, LegendGlyph, Mark};
#[derive(Clone, Debug)]
pub struct PolarBarMark {
pub thetas: Vec<f64>,
pub values: Vec<f64>,
pub r_base: Option<Vec<f64>>,
pub theta_widths: Option<Vec<f64>>,
pub colors: Vec<Color>,
pub stroke: Option<(Color, f32)>,
pub label: Option<String>,
}
impl PolarBarMark {
#[must_use]
pub fn new(mut thetas: Vec<f64>, mut values: Vec<f64>) -> Self {
let n = thetas.len().min(values.len());
thetas.truncate(n);
values.truncate(n);
Self {
thetas,
values,
r_base: None,
theta_widths: None,
colors: Vec::new(),
stroke: None,
label: None,
}
}
#[must_use]
pub fn r_base(mut self, base: Vec<f64>) -> Self {
self.r_base = Some(base);
self
}
#[must_use]
pub fn theta_width(mut self, width: f64) -> Self {
let n = self.thetas.len();
self.theta_widths = Some(vec![width; n]);
self
}
#[must_use]
pub fn theta_widths(mut self, widths: Vec<f64>) -> Self {
self.theta_widths = Some(widths);
self
}
#[must_use]
pub fn color(mut self, c: Color) -> Self {
self.colors = vec![c];
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 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(widths) = &self.theta_widths
&& !widths.is_empty()
{
return widths[i % widths.len()] * 0.5;
}
if self.thetas.len() < 2 {
return 0.5;
}
let mut sorted = 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_base_at(&self, i: usize) -> f64 {
match &self.r_base {
Some(v) if !v.is_empty() => v.get(i).copied().unwrap_or(0.0),
_ => 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 PolarBarMark {
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, &value)) in self.thetas.iter().zip(&self.values).enumerate() {
let half = self.half_width_at(i);
let r_in_data = self.r_base_at(i);
let r_out_data = r_in_data + value;
let theta_a = coord.theta_axis.scale.map(theta - half);
let theta_b = coord.theta_axis.scale.map(theta + half);
let a_rad = theta_a * TAU;
let b_rad = theta_b * TAU;
let r_in_norm = coord.r_axis.scale.map(r_in_data);
let r_out_norm = coord.r_axis.scale.map(r_out_data);
let r_in_px = (r_in_norm * f64::from(radius)) as f32;
let r_out_px = (r_out_norm * f64::from(radius)) as f32;
let path = build_arc_wedge(center, r_in_px, r_out_px, a_rad, b_rad);
backend.draw_path(&path, &PathStyle::fill(self.color_at(i)))?;
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_glyph(&self) -> LegendGlyph {
LegendGlyph::Bar
}
fn wants_axes(&self) -> bool {
false
}
fn prefers_outside_legend(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::PolarBarMark;
use crate::marks::{LegendGlyph, Mark};
use starsight_layer_1::primitives::Color;
#[test]
fn new_truncates_to_shorter_input() {
let mark = PolarBarMark::new(vec![0.0, 1.0, 2.0], vec![10.0, 20.0]);
assert_eq!(mark.thetas.len(), 2);
assert_eq!(mark.values.len(), 2);
}
#[test]
fn default_palette_cycles() {
let mark = PolarBarMark::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() {
let mark = PolarBarMark::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_base_default_is_zero() {
let mark = PolarBarMark::new(vec![0.0], vec![10.0]);
assert_eq!(mark.r_base_at(0), 0.0);
}
#[test]
fn r_base_uses_provided_when_set() {
let mark = PolarBarMark::new(vec![0.0, 1.0], vec![10.0, 20.0]).r_base(vec![3.0, 5.0]);
assert_eq!(mark.r_base_at(0), 3.0);
assert_eq!(mark.r_base_at(1), 5.0);
}
#[test]
fn theta_width_uniform_builder_sets_all() {
let mark = PolarBarMark::new(vec![0.0, 1.0, 2.0], vec![1.0, 2.0, 3.0]).theta_width(0.4);
for i in 0..3 {
assert!((mark.half_width_at(i) - 0.2).abs() < 1e-9);
}
}
#[test]
fn half_width_default_uses_min_neighbor_gap() {
let mark = PolarBarMark::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() {
let mark = PolarBarMark::new(vec![0.0], vec![10.0])
.color(Color::GREEN)
.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 = PolarBarMark::new(vec![0.0], vec![10.0]);
assert!(mark.legend_color().is_none());
}
#[test]
fn does_not_want_axes() {
let mark = PolarBarMark::new(vec![0.0], vec![10.0]);
assert!(!mark.wants_axes());
}
#[test]
fn data_extent_is_none() {
let mark = PolarBarMark::new(vec![0.0, 1.0], vec![10.0, 20.0]);
assert!(mark.data_extent().is_none());
}
}