use crate::core::Result;
use crate::core::style_utils::StyleResolver;
use crate::plots::traits::{PlotArea, PlotCompute, PlotConfig, PlotData, PlotRender};
use crate::render::skia::SkiaRenderer;
use crate::render::{Color, LineStyle, MarkerStyle, Theme};
use std::f64::consts::PI;
#[derive(Debug, Clone)]
pub struct RadarConfig {
pub labels: Vec<String>,
pub colors: Option<Vec<Color>>,
pub fill: bool,
pub fill_alpha: f32,
pub line_width: f32,
pub marker_size: f32,
pub show_grid: bool,
pub grid_rings: usize,
pub start_angle: f64,
pub value_range: Option<(f64, f64)>,
pub show_axis_labels: bool,
pub label_font_size: f32,
pub series_labels: Vec<String>,
pub per_series_fill_alphas: Vec<Option<f32>>,
pub per_series_line_widths: Vec<Option<f32>>,
pub(crate) current_series_idx: Option<usize>,
}
impl Default for RadarConfig {
fn default() -> Self {
Self {
labels: vec![],
colors: None,
fill: true,
fill_alpha: 0.25,
line_width: 2.0,
marker_size: 4.0,
show_grid: true,
grid_rings: 5,
start_angle: PI / 2.0, value_range: None,
show_axis_labels: true,
label_font_size: 10.0,
series_labels: vec![],
per_series_fill_alphas: vec![],
per_series_line_widths: vec![],
current_series_idx: None,
}
}
}
impl RadarConfig {
pub fn new() -> Self {
Self::default()
}
pub fn labels(mut self, labels: Vec<String>) -> Self {
self.labels = labels;
self
}
pub fn colors(mut self, colors: Vec<Color>) -> Self {
self.colors = Some(colors);
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 line_width(mut self, width: f32) -> Self {
self.line_width = width.max(0.1);
self
}
pub fn value_range(mut self, min: f64, max: f64) -> Self {
self.value_range = Some((min, max));
self
}
pub fn start_angle(mut self, angle: f64) -> Self {
self.start_angle = angle;
self
}
pub fn show_axis_labels(mut self, show: bool) -> Self {
self.show_axis_labels = show;
self
}
pub fn label_font_size(mut self, size: f32) -> Self {
self.label_font_size = size.max(1.0);
self
}
}
impl PlotConfig for RadarConfig {}
pub struct Radar;
#[derive(Debug, Clone)]
pub struct RadarSeries {
pub values: Vec<f64>,
pub polygon: Vec<(f64, f64)>,
pub markers: Vec<(f64, f64)>,
pub label: String,
}
pub struct RadarInput<'a> {
pub data: &'a [Vec<f64>],
}
impl<'a> RadarInput<'a> {
pub fn new(data: &'a [Vec<f64>]) -> Self {
Self { data }
}
}
#[derive(Debug, Clone)]
pub struct RadarPlotData {
pub series: Vec<RadarSeries>,
pub axes: Vec<((f64, f64), (f64, f64))>,
pub grid_rings: Vec<Vec<(f64, f64)>>,
pub axis_labels: Vec<(String, f64, f64)>,
pub value_range: (f64, f64),
pub(crate) config: RadarConfig,
}
pub fn compute_radar_chart_with_labels(
data: &[Vec<f64>],
config: &RadarConfig,
series_labels: Option<&[String]>,
) -> RadarPlotData {
if data.is_empty() {
return RadarPlotData {
series: vec![],
axes: vec![],
grid_rings: vec![],
axis_labels: vec![],
value_range: (0.0, 1.0),
config: config.clone(),
};
}
let n_axes = data.iter().map(|s| s.len()).max().unwrap_or(0);
if n_axes == 0 {
return RadarPlotData {
series: vec![],
axes: vec![],
grid_rings: vec![],
axis_labels: vec![],
value_range: (0.0, 1.0),
config: config.clone(),
};
}
let (v_min, v_max) = config.value_range.unwrap_or_else(|| {
let mut min_val = f64::INFINITY;
let mut max_val = f64::NEG_INFINITY;
for series in data {
for &v in series {
min_val = min_val.min(v);
max_val = max_val.max(v);
}
}
let min_val = if min_val.is_finite() {
min_val.min(0.0)
} else {
0.0
};
let max_val = if max_val.is_finite() {
max_val.max(1.0)
} else {
1.0
};
(min_val, max_val)
});
let range = if (v_max - v_min).abs() < 1e-10 {
1.0
} else {
v_max - v_min
};
let angle_step = 2.0 * PI / n_axes as f64;
let angles: Vec<f64> = (0..n_axes)
.map(|i| config.start_angle - i as f64 * angle_step)
.collect();
let axes: Vec<((f64, f64), (f64, f64))> = angles
.iter()
.map(|&theta| ((0.0, 0.0), (theta.cos(), theta.sin())))
.collect();
let grid_rings: Vec<Vec<(f64, f64)>> = (1..=config.grid_rings)
.map(|ring| {
let r = ring as f64 / config.grid_rings as f64;
angles
.iter()
.map(|&theta| (r * theta.cos(), r * theta.sin()))
.collect()
})
.collect();
let axis_labels: Vec<(String, f64, f64)> = angles
.iter()
.enumerate()
.map(|(i, &theta)| {
let label = config
.labels
.get(i)
.cloned()
.unwrap_or_else(|| format!("Axis {}", i + 1));
let label_r = 1.15; (label, label_r * theta.cos(), label_r * theta.sin())
})
.collect();
let series: Vec<RadarSeries> = data
.iter()
.enumerate()
.map(|(series_idx, values)| {
let normalized: Vec<f64> = values.iter().map(|&v| (v - v_min) / range).collect();
let polygon: Vec<(f64, f64)> = normalized
.iter()
.zip(angles.iter())
.map(|(&r, &theta)| (r * theta.cos(), r * theta.sin()))
.collect();
let markers = polygon.clone();
let label = series_labels
.and_then(|labels| labels.get(series_idx).cloned())
.unwrap_or_else(|| format!("Series {}", series_idx + 1));
RadarSeries {
values: normalized,
polygon,
markers,
label,
}
})
.collect();
RadarPlotData {
series,
axes,
grid_rings,
axis_labels,
value_range: (v_min, v_max),
config: config.clone(),
}
}
pub fn compute_radar_chart(data: &[Vec<f64>], config: &RadarConfig) -> RadarPlotData {
compute_radar_chart_with_labels(data, config, None)
}
impl PlotCompute for Radar {
type Input<'a> = RadarInput<'a>;
type Config = RadarConfig;
type Output = RadarPlotData;
fn compute(input: Self::Input<'_>, config: &Self::Config) -> Result<Self::Output> {
if input.data.is_empty() {
return Err(crate::core::PlottingError::EmptyDataSet);
}
Ok(compute_radar_chart(input.data, config))
}
}
impl PlotData for RadarPlotData {
fn data_bounds(&self) -> ((f64, f64), (f64, f64)) {
((-1.1, 1.1), (-1.1, 1.1))
}
fn is_empty(&self) -> bool {
self.series.is_empty()
}
}
impl PlotRender for RadarPlotData {
fn render(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
) -> Result<()> {
if self.series.is_empty() {
return Ok(());
}
let config = &self.config;
if config.show_grid {
let grid_color = theme.grid_color;
let render_scale = renderer.render_scale();
let grid_line_width = render_scale.logical_pixels_to_pixels(0.5);
for ring in &self.grid_rings {
if ring.len() < 2 {
continue;
}
for i in 0..ring.len() {
let (x1, y1) = ring[i];
let (x2, y2) = ring[(i + 1) % ring.len()];
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,
LineStyle::Solid,
)?;
}
}
for &((x1, y1), (x2, y2)) in &self.axes {
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,
LineStyle::Solid,
)?;
}
}
if config.show_axis_labels {
let label_color = theme.foreground;
for (label, x, y) in &self.axis_labels {
let (sx, sy) = area.data_to_screen(*x, *y);
renderer.draw_text_centered(label, sx, sy, config.label_font_size, label_color)?;
}
}
let render_scale = renderer.render_scale();
let scaled_line_width = render_scale.points_to_pixels(config.line_width);
let scaled_marker_size = render_scale.points_to_pixels(config.marker_size);
for (series_idx, series) in self.series.iter().enumerate() {
let series_color = config
.colors
.as_ref()
.and_then(|colors| colors.get(series_idx).copied())
.unwrap_or_else(|| theme.get_color(series_idx));
if config.fill && !series.polygon.is_empty() {
let fill_color = series_color.with_alpha(config.fill_alpha);
let screen_polygon: Vec<(f32, f32)> = series
.polygon
.iter()
.map(|(x, y)| area.data_to_screen(*x, *y))
.collect();
renderer.draw_filled_polygon(&screen_polygon, fill_color)?;
}
if series.polygon.len() > 1 {
let n = series.polygon.len();
for i in 0..n {
let (x1, y1) = series.polygon[i];
let (x2, y2) = series.polygon[(i + 1) % n];
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,
series_color,
scaled_line_width,
LineStyle::Solid,
)?;
}
}
if config.marker_size > 0.0 {
for (x, y) in &series.markers {
let (sx, sy) = area.data_to_screen(*x, *y);
renderer.draw_marker(
sx,
sy,
scaled_marker_size,
MarkerStyle::Circle,
series_color,
)?;
}
}
}
Ok(())
}
fn render_styled(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
theme: &Theme,
color: Color,
alpha: f32,
line_width: Option<f32>,
) -> Result<()> {
if self.series.is_empty() {
return Ok(());
}
let config = &self.config;
let resolver = StyleResolver::new(theme);
let render_scale = renderer.render_scale();
let effective_line_width = render_scale.points_to_pixels(
line_width.unwrap_or_else(|| resolver.line_width(Some(config.line_width))),
);
let scaled_marker_size = render_scale.points_to_pixels(config.marker_size);
if config.show_grid {
let grid_color = theme.grid_color;
let grid_line_width = render_scale.logical_pixels_to_pixels(0.5);
for ring in &self.grid_rings {
if ring.len() < 2 {
continue;
}
for i in 0..ring.len() {
let (x1, y1) = ring[i];
let (x2, y2) = ring[(i + 1) % ring.len()];
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,
LineStyle::Solid,
)?;
}
}
for &((x1, y1), (x2, y2)) in &self.axes {
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,
LineStyle::Solid,
)?;
}
}
if config.show_axis_labels {
let label_color = theme.foreground;
for (label, x, y) in &self.axis_labels {
let (sx, sy) = area.data_to_screen(*x, *y);
renderer.draw_text_centered(label, sx, sy, config.label_font_size, label_color)?;
}
}
for (series_idx, series) in self.series.iter().enumerate() {
let series_color = config
.colors
.as_ref()
.and_then(|colors| colors.get(series_idx).copied())
.unwrap_or_else(|| theme.get_color(series_idx));
if config.fill && !series.polygon.is_empty() {
let fill_alpha = if alpha != 1.0 {
alpha * config.fill_alpha
} else {
config.fill_alpha
};
let fill_color = series_color.with_alpha(fill_alpha);
let screen_polygon: Vec<(f32, f32)> = series
.polygon
.iter()
.map(|(x, y)| area.data_to_screen(*x, *y))
.collect();
renderer.draw_filled_polygon(&screen_polygon, fill_color)?;
}
if series.polygon.len() > 1 {
let n = series.polygon.len();
for i in 0..n {
let (x1, y1) = series.polygon[i];
let (x2, y2) = series.polygon[(i + 1) % n];
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,
series_color,
effective_line_width,
LineStyle::Solid,
)?;
}
}
if config.marker_size > 0.0 {
for (x, y) in &series.markers {
let (sx, sy) = area.data_to_screen(*x, *y);
renderer.draw_marker(
sx,
sy,
scaled_marker_size,
MarkerStyle::Circle,
series_color,
)?;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_radar_basic() {
let data = vec![vec![1.0, 2.0, 3.0, 4.0, 5.0], vec![5.0, 4.0, 3.0, 2.0, 1.0]];
let config = RadarConfig::default();
let plot = compute_radar_chart(&data, &config);
assert_eq!(plot.series.len(), 2);
assert_eq!(plot.axes.len(), 5);
assert_eq!(plot.grid_rings.len(), 5);
}
#[test]
fn test_radar_with_labels() {
let data = vec![vec![1.0, 2.0, 3.0]];
let config =
RadarConfig::default().labels(vec!["A".to_string(), "B".to_string(), "C".to_string()]);
let plot = compute_radar_chart(&data, &config);
assert_eq!(plot.axis_labels[0].0, "A");
assert_eq!(plot.axis_labels[1].0, "B");
assert_eq!(plot.axis_labels[2].0, "C");
}
#[test]
fn test_radar_normalization() {
let data = vec![vec![0.0, 50.0, 100.0]];
let config = RadarConfig::default().value_range(0.0, 100.0);
let plot = compute_radar_chart(&data, &config);
assert!((plot.series[0].values[0] - 0.0).abs() < 1e-10);
assert!((plot.series[0].values[1] - 0.5).abs() < 1e-10);
assert!((plot.series[0].values[2] - 1.0).abs() < 1e-10);
}
#[test]
fn test_radar_empty() {
let data: Vec<Vec<f64>> = vec![];
let config = RadarConfig::default();
let plot = compute_radar_chart(&data, &config);
assert!(plot.series.is_empty());
}
#[test]
fn test_radar_config_implements_plot_config() {
fn assert_plot_config<T: PlotConfig>() {}
assert_plot_config::<RadarConfig>();
}
#[test]
fn test_radar_compute_trait() {
use crate::plots::traits::PlotCompute;
let data = vec![vec![1.0, 2.0, 3.0, 4.0, 5.0]];
let config = RadarConfig::default();
let input = RadarInput::new(&data);
let result = Radar::compute(input, &config);
assert!(result.is_ok());
let radar_data = result.unwrap();
assert_eq!(radar_data.series.len(), 1);
assert_eq!(radar_data.axes.len(), 5);
}
#[test]
fn test_radar_compute_empty() {
use crate::plots::traits::PlotCompute;
let data: Vec<Vec<f64>> = vec![];
let config = RadarConfig::default();
let input = RadarInput::new(&data);
let result = Radar::compute(input, &config);
assert!(result.is_err());
}
#[test]
fn test_radar_data_trait() {
use crate::plots::traits::{PlotCompute, PlotData};
let data = vec![vec![1.0, 2.0, 3.0]];
let config = RadarConfig::default();
let input = RadarInput::new(&data);
let radar_data = Radar::compute(input, &config).unwrap();
let ((x_min, x_max), (y_min, y_max)) = radar_data.data_bounds();
assert!((x_min - (-1.1)).abs() < 1e-10);
assert!((x_max - 1.1).abs() < 1e-10);
assert!((y_min - (-1.1)).abs() < 1e-10);
assert!((y_max - 1.1).abs() < 1e-10);
assert!(!radar_data.is_empty());
}
#[test]
fn test_radar_per_series_config() {
let config = RadarConfig {
series_labels: vec!["Series A".to_string(), "Series B".to_string()],
colors: Some(vec![Color::RED, Color::BLUE]),
per_series_fill_alphas: vec![Some(0.3), Some(0.5)],
per_series_line_widths: vec![Some(2.0), None],
..Default::default()
};
assert_eq!(config.series_labels.len(), 2);
assert_eq!(config.series_labels[0], "Series A");
assert_eq!(config.series_labels[1], "Series B");
assert!(config.colors.is_some());
let colors = config.colors.as_ref().unwrap();
assert_eq!(colors.len(), 2);
assert_eq!(config.per_series_fill_alphas[0], Some(0.3));
assert_eq!(config.per_series_fill_alphas[1], Some(0.5));
assert_eq!(config.per_series_line_widths[0], Some(2.0));
assert_eq!(config.per_series_line_widths[1], None);
}
#[test]
fn test_radar_with_series_labels() {
let data = vec![vec![1.0, 2.0, 3.0], vec![3.0, 2.0, 1.0]];
let series_labels = vec!["Team A".to_string(), "Team B".to_string()];
let config = RadarConfig::default();
let plot = compute_radar_chart_with_labels(&data, &config, Some(&series_labels));
assert_eq!(plot.series.len(), 2);
assert_eq!(plot.series[0].label, "Team A");
assert_eq!(plot.series[1].label, "Team B");
}
}