use crate::core::{deg_to_rad, Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::expect_bool;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const FIRST_AXIS_DEGREES: f32 = 0.0;
const PALETTE: [Color; 6] = [
Color::rgb(66, 133, 244),
Color::rgb(219, 68, 55),
Color::rgb(244, 180, 0),
Color::rgb(15, 157, 88),
Color::rgb(171, 71, 188),
Color::rgb(0, 172, 193),
];
pub struct RadarChart {
base: BaseWidget,
axes: Vec<String>,
series: Vec<Vec<f64>>,
show_grid: bool,
show_axis_labels: bool,
show_legend: bool,
hovered_axis: Option<usize>,
pub axis_hovered: Signal1<usize>,
pub series_clicked: Signal1<usize>,
}
impl RadarChart {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::RadarChart, geometry, "RadarChart"),
axes: Vec::new(),
series: Vec::new(),
show_grid: true,
show_axis_labels: true,
show_legend: true,
hovered_axis: None,
axis_hovered: Signal1::new(),
series_clicked: Signal1::new(),
}
}
pub fn set_axes(&mut self, axes: Vec<String>) -> bool {
if axes.len() < 3 {
return false;
}
self.axes = axes;
self.hovered_axis = None;
self.base.request_redraw();
true
}
pub fn axes(&self) -> &[String] {
&self.axes
}
pub fn axis_count(&self) -> usize {
self.axes.len()
}
pub fn set_series(&mut self, series: Vec<Vec<f64>>) {
self.series = series;
self.base.request_redraw();
}
pub fn series(&self) -> &[Vec<f64>] {
&self.series
}
pub fn series_count(&self) -> usize {
self.series.len()
}
pub fn add_series(&mut self, values: Vec<f64>) -> usize {
let index = self.series.len();
self.series.push(values);
self.base.request_redraw();
index
}
pub fn value_at(&self, series: usize, axis: usize) -> Option<f64> {
self.series.get(series).and_then(|values| values.get(axis)).copied()
}
pub fn show_grid(&self) -> bool {
self.show_grid
}
pub fn set_show_grid(&mut self, show: bool) {
self.show_grid = show;
self.base.request_redraw();
}
pub fn show_axis_labels(&self) -> bool {
self.show_axis_labels
}
pub fn set_show_axis_labels(&mut self, show: bool) {
self.show_axis_labels = show;
self.base.request_redraw();
}
pub fn show_legend(&self) -> bool {
self.show_legend
}
pub fn set_show_legend(&mut self, show: bool) {
self.show_legend = show;
self.base.request_redraw();
}
fn data_max(&self) -> Option<f64> {
let max = self
.series
.iter()
.flatten()
.copied()
.filter(|value| value.is_finite())
.fold(f64::NEG_INFINITY, f64::max);
if max.is_finite() && max > 0.0 {
Some(max)
} else {
None
}
}
fn geometry_center_radius(&self) -> Option<(Point, u32)> {
let rect = self.base.geometry();
if rect.width == 0 || rect.height == 0 {
return None;
}
const LABEL_MARGIN: u32 = 44;
const LEGEND_WIDTH: u32 = 96;
const BOTTOM_MARGIN: u32 = 8;
let legend = if self.show_legend && !self.series.is_empty() { LEGEND_WIDTH } else { 0 };
let usable_w = rect.width.saturating_sub(LABEL_MARGIN * 2 + legend).max(1);
let usable_h = rect.height.saturating_sub(LABEL_MARGIN + BOTTOM_MARGIN).max(1);
let radius = usable_w.min(usable_h) / 2;
if radius < 8 {
return None;
}
let center = Point::new(
rect.x + LABEL_MARGIN as i32 + radius as i32,
rect.y + LABEL_MARGIN as i32 + radius as i32,
);
Some((center, radius))
}
fn axis_angle(&self, axis: usize) -> f32 {
let count = self.axis_count().max(1) as f32;
let step = 360.0 / count;
deg_to_rad(FIRST_AXIS_DEGREES + step * axis as f32 - 90.0)
}
fn vertex(&self, center: Point, radius: u32, angle: f32, value: f64, max: f64) -> Point {
let ratio = (value / max).clamp(0.0, 1.0) as f32;
let r = radius as f32 * ratio;
Point::new(center.x + (r * angle.cos()) as i32, center.y + (r * angle.sin()) as i32)
}
fn axis_at(&self, pos: Point) -> Option<usize> {
let (center, _) = self.geometry_center_radius()?;
if self.axis_count() == 0 {
return None;
}
let dx = (pos.x - center.x) as f32;
let dy = (pos.y - center.y) as f32;
if dx == 0.0 && dy == 0.0 {
return None;
}
let pointer = dy.atan2(dx).to_degrees() + 90.0;
let step = 360.0 / self.axis_count() as f32;
let normalized = ((pointer % 360.0) + 360.0) % 360.0;
let index = ((normalized + step / 2.0) / step).floor() as usize % self.axis_count();
Some(index)
}
}
impl Widget for RadarChart {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(320, 320)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for RadarChart {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"axis_count" => Ok(CapabilityValue::UInt(self.axis_count() as u64)),
"series_count" => Ok(CapabilityValue::UInt(self.series_count() as u64)),
"show_grid" => Ok(CapabilityValue::Bool(self.show_grid())),
"show_axis_labels" => Ok(CapabilityValue::Bool(self.show_axis_labels())),
"show_legend" => Ok(CapabilityValue::Bool(self.show_legend())),
"hovered_axis" => Ok(match self.hovered_axis {
Some(axis) => CapabilityValue::UInt(axis as u64),
None => CapabilityValue::Null,
}),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"show_grid" => {
self.set_show_grid(expect_bool(value)?);
Ok(())
}
"show_axis_labels" => {
self.set_show_axis_labels(expect_bool(value)?);
Ok(())
}
"show_legend" => {
self.set_show_legend(expect_bool(value)?);
Ok(())
}
"axis_count" | "series_count" | "hovered_axis" => {
Err(CapabilityAccessError::ReadOnlyProperty)
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"axis_count",
"series_count",
"show_grid",
"show_axis_labels",
"show_legend",
"hovered_axis",
BASE_PROPERTY_NAMES
]
}
}
impl Draw for RadarChart {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.base.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
context.fill_rect(rect, Color::rgb(255, 255, 255));
context.draw_rect(rect, Color::rgb(200, 200, 200));
let axis_count = self.axis_count();
let Some(max) = self.data_max() else {
self.draw_placeholder(context, rect);
return;
};
let Some((center, radius)) = self.geometry_center_radius() else {
return;
};
if axis_count == 0 {
self.draw_placeholder(context, rect);
return;
}
if self.show_grid {
self.draw_grid(context, center, radius, axis_count);
}
if self.show_axis_labels {
self.draw_axis_labels(context, center, radius, axis_count);
}
for series_index in 0..self.series.len() {
self.draw_series(context, center, radius, max, series_index);
}
if self.show_legend && self.series.len() > 1 {
self.draw_legend(context, rect);
}
if let Some(axis) = self.hovered_axis {
self.draw_hover_spoke(context, center, radius, axis, max);
}
}
}
impl RadarChart {
fn draw_placeholder(&self, context: &mut RenderContext, rect: Rect) {
context.draw_text(
Point { x: rect.x + 8, y: rect.y + rect.height as i32 / 2 },
"No data",
&Font::simple("Sans", 12.0),
Color::rgb(180, 180, 180),
HorizontalAlignment::Left,
);
}
fn draw_grid(
&self,
context: &mut RenderContext,
center: Point,
radius: u32,
axis_count: usize,
) {
const RINGS: u32 = 4;
let grid = Color::rgb(225, 225, 225);
for ring in 1..=RINGS {
let ring_radius = radius * ring / RINGS;
for axis in 0..axis_count {
let angle = self.axis_angle(axis);
let next_angle = self.axis_angle((axis + 1) % axis_count);
let from = Point::new(
center.x + (ring_radius as f32 * angle.cos()) as i32,
center.y + (ring_radius as f32 * angle.sin()) as i32,
);
let to = Point::new(
center.x + (ring_radius as f32 * next_angle.cos()) as i32,
center.y + (ring_radius as f32 * next_angle.sin()) as i32,
);
context.draw_line_stroke(from, to, grid, 1);
}
}
for axis in 0..axis_count {
let angle = self.axis_angle(axis);
let outer = Point::new(
center.x + (radius as f32 * angle.cos()) as i32,
center.y + (radius as f32 * angle.sin()) as i32,
);
context.draw_line_stroke(center, outer, grid, 1);
}
}
fn draw_axis_labels(
&self,
context: &mut RenderContext,
center: Point,
radius: u32,
axis_count: usize,
) {
const LABEL_GAP: u32 = 10;
let font = Font::simple("Sans", 10.0);
let label_radius = radius + LABEL_GAP;
for axis in 0..axis_count {
let Some(label) = self.axes.get(axis) else {
continue;
};
let angle = self.axis_angle(axis);
let anchor_x = center.x + (label_radius as f32 * angle.cos()) as i32;
let anchor_y = center.y + (label_radius as f32 * angle.sin()) as i32;
let metrics = context.measure_text(label, &font);
let text_x = anchor_x - metrics.width as i32 / 2;
let text_y = anchor_y + metrics.ascent as i32 / 2;
context.draw_text(
Point { x: text_x, y: text_y },
label,
&font,
Color::rgb(90, 90, 90),
HorizontalAlignment::Left,
);
}
}
fn draw_series(
&self,
context: &mut RenderContext,
center: Point,
radius: u32,
max: f64,
series_index: usize,
) {
let Some(values) = self.series.get(series_index) else {
return;
};
let axis_count = self.axis_count();
if axis_count == 0 {
return;
}
let color = Self::series_color(series_index);
let vertices: Vec<Point> = (0..axis_count)
.map(|axis| {
let angle = self.axis_angle(axis);
let value = values.get(axis).copied().unwrap_or(0.0);
self.vertex(center, radius, angle, value, max)
})
.collect();
for axis in 0..axis_count {
let from = vertices[axis];
let to = vertices[(axis + 1) % axis_count];
context.draw_line(center, from, color);
context.draw_line(center, to, color);
context.draw_line(from, to, color);
}
for axis in 0..axis_count {
let from = vertices[axis];
let to = vertices[(axis + 1) % axis_count];
context.draw_line_stroke(from, to, color, 2);
}
for vertex in &vertices {
context.fill_circle(*vertex, 3, color);
}
}
fn draw_legend(&self, context: &mut RenderContext, rect: Rect) {
const SWATCH: u32 = 10;
const ROW_HEIGHT: i32 = 18;
let font = Font::simple("Sans", 10.0);
let x = rect.x + rect.width as i32 - 90;
let mut y = rect.y + 12;
for index in 0..self.series.len() {
context.fill_rect(Rect::new(x, y, SWATCH, SWATCH), Self::series_color(index));
let label = format!("Series {}", index + 1);
context.draw_text(
Point { x: x + SWATCH as i32 + 6, y: y + SWATCH as i32 },
&label,
&font,
Color::rgb(70, 70, 70),
HorizontalAlignment::Left,
);
y += ROW_HEIGHT;
}
}
fn draw_hover_spoke(
&self,
context: &mut RenderContext,
center: Point,
radius: u32,
axis: usize,
max: f64,
) {
if axis >= self.axis_count() {
return;
}
let angle = self.axis_angle(axis);
let outer = Point::new(
center.x + (radius as f32 * angle.cos()) as i32,
center.y + (radius as f32 * angle.sin()) as i32,
);
context.draw_line_stroke(center, outer, Color::rgb(120, 120, 120), 1);
let font = Font::simple("Sans", 9.0);
let mut text_y = outer.y + 4;
for series_index in 0..self.series.len() {
let Some(value) = self.value_at(series_index, axis) else {
continue;
};
let marker = self.vertex(center, radius, angle, value, max);
context.fill_circle(marker, 4, Self::series_color(series_index));
let label = format!("{value:.1}");
context.draw_text(
Point { x: outer.x + 4, y: text_y },
&label,
&font,
Color::rgb(60, 60, 60),
HorizontalAlignment::Left,
);
text_y += 12;
}
}
fn series_color(index: usize) -> Color {
PALETTE[index % PALETTE.len()]
}
}
impl EventHandler for RadarChart {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MouseMove { pos } => {
let axis = self.axis_at(*pos);
if axis != self.hovered_axis {
self.hovered_axis = axis;
if let Some(axis) = axis {
self.axis_hovered.emit(axis);
}
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
if self.hovered_axis.is_some() {
self.hovered_axis = None;
self.base.request_redraw();
}
}
Event::MousePress { pos, button } if *button == 1 => {
self.base.set_mouse_pressed(true);
if let Some(axis) = self.axis_at(*pos) {
if let Some(series) = self.nearest_series(axis, *pos) {
self.base.clicked.emit();
self.series_clicked.emit(series);
}
}
}
Event::MouseRelease { button, .. } if *button == 1 => {
self.base.set_mouse_pressed(false);
}
_ => {}
}
}
}
impl RadarChart {
fn nearest_series(&self, axis: usize, pos: Point) -> Option<usize> {
let (center, radius) = self.geometry_center_radius()?;
let max = self.data_max()?;
let angle = self.axis_angle(axis);
let mut best: Option<(usize, i64)> = None;
for series_index in 0..self.series.len() {
let value = self.value_at(series_index, axis).unwrap_or(0.0);
let vertex = self.vertex(center, radius, angle, value, max);
let dx = (vertex.x - pos.x) as i64;
let dy = (vertex.y - pos.y) as i64;
let distance = dx * dx + dy * dy;
if best.is_none_or(|(_, best_distance)| distance < best_distance) {
best = Some((series_index, distance));
}
}
best.map(|(index, _)| index)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::{PaintBackend, SoftwarePaintBackend};
fn chart() -> RadarChart {
let mut chart = RadarChart::new(Rect::new(0, 0, 320, 320));
assert!(chart.set_axes(vec![
"Speed".to_string(),
"Power".to_string(),
"Range".to_string(),
"Comfort".to_string(),
"Safety".to_string(),
]));
chart.add_series(vec![80.0, 60.0, 90.0, 70.0, 50.0]);
chart
}
fn render(chart: &mut RadarChart, size: Size) -> Vec<u8> {
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::WHITE);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
backend.end_frame();
backend.frame_rgba().to_vec()
}
fn count_near(rgba: &[u8], target: (u8, u8, u8)) -> usize {
const TOLERANCE: i32 = 24;
rgba.chunks_exact(4)
.filter(|px| {
(px[0] as i32 - target.0 as i32).abs() <= TOLERANCE
&& (px[1] as i32 - target.1 as i32).abs() <= TOLERANCE
&& (px[2] as i32 - target.2 as i32).abs() <= TOLERANCE
&& px[3] > 0
})
.count()
}
#[test]
fn radar_chart_creation_defaults() {
let chart = RadarChart::new(Rect::new(0, 0, 320, 320));
assert_eq!(chart.kind(), WidgetKind::RadarChart);
assert_eq!(chart.axis_count(), 0);
assert_eq!(chart.series_count(), 0);
assert!(chart.show_grid());
assert!(chart.show_axis_labels());
assert!(chart.show_legend());
}
#[test]
fn radar_chart_set_axes_requires_three() {
let mut chart = RadarChart::new(Rect::new(0, 0, 320, 320));
assert!(!chart.set_axes(vec!["A".to_string(), "B".to_string()]));
assert_eq!(chart.axis_count(), 0, "a refused input must not change state");
assert!(chart.set_axes(vec!["A".to_string(), "B".to_string(), "C".to_string()]));
assert_eq!(chart.axis_count(), 3);
}
#[test]
fn radar_chart_value_at_distinguishes_missing_from_zero() {
let mut chart = RadarChart::new(Rect::new(0, 0, 320, 320));
chart.set_axes(vec!["A".to_string(), "B".to_string(), "C".to_string()]);
chart.add_series(vec![1.0, 0.0]);
assert_eq!(chart.value_at(0, 1), Some(0.0));
assert_eq!(chart.value_at(0, 2), None);
assert_eq!(chart.value_at(9, 0), None);
}
#[test]
fn radar_chart_series_count_and_polygon_pixels() {
let mut chart = chart();
assert_eq!(chart.series_count(), 1);
let rgba = render(&mut chart, Size::new(320, 320));
assert!(count_near(&rgba, (66, 133, 244)) > 0);
}
#[test]
fn radar_chart_multiple_series_use_distinct_colours() {
let mut chart = chart();
chart.add_series(vec![50.0, 90.0, 60.0, 85.0, 95.0]);
assert_eq!(chart.series_count(), 2);
let rgba = render(&mut chart, Size::new(320, 320));
assert!(count_near(&rgba, (66, 133, 244)) > 0, "series zero");
assert!(count_near(&rgba, (219, 68, 55)) > 0, "series one");
}
#[test]
fn radar_chart_empty_draw_does_not_panic() {
let mut chart = RadarChart::new(Rect::new(0, 0, 320, 320));
let rgba = render(&mut chart, Size::new(320, 320));
assert!(!rgba.is_empty());
}
#[test]
fn radar_chart_zero_geometry_does_not_panic() {
let mut chart = chart();
let rgba = render(&mut chart, Size::new(4, 4));
assert!(!rgba.is_empty());
}
#[test]
fn radar_chart_axis_hover_emits_nearest_axis() {
let mut chart = chart();
let hovered = std::sync::Arc::new(std::sync::Mutex::new(Vec::<usize>::new()));
let sink = hovered.clone();
chart.axis_hovered.connect(move |axis| {
if let Ok(mut guard) = sink.lock() {
guard.push(*axis);
}
});
let (center, _) = chart.geometry_center_radius().expect("geometry");
chart.handle_event(&Event::mouse_move(center.x, center.y - 40));
assert_eq!(*hovered.lock().expect("hover lock poisoned"), vec![0]);
assert_eq!(chart.get("hovered_axis").unwrap(), CapabilityValue::UInt(0));
chart.handle_event(&Event::MouseLeave { pos: Point::new(0, 0) });
assert_eq!(chart.get("hovered_axis").unwrap(), CapabilityValue::Null);
}
#[test]
fn radar_chart_axis_at_centres_on_the_first_spoke() {
let chart = chart();
let (center, _) = chart.geometry_center_radius().expect("geometry");
assert_eq!(chart.axis_at(Point::new(center.x, center.y - 30)), Some(0));
assert_eq!(chart.axis_at(Point::new(center.x + 30, center.y)), Some(1));
assert_eq!(chart.axis_at(center), None);
}
#[test]
fn radar_chart_click_emits_nearest_series() {
let mut chart = chart();
chart.add_series(vec![20.0, 20.0, 20.0, 20.0, 20.0]);
let clicked = std::sync::Arc::new(std::sync::Mutex::new(Vec::<usize>::new()));
let sink = clicked.clone();
chart.series_clicked.connect(move |series| {
if let Ok(mut guard) = sink.lock() {
guard.push(*series);
}
});
let (center, radius) = chart.geometry_center_radius().expect("geometry");
let max = chart.data_max().expect("data");
let vertex = chart.vertex(center, radius, chart.axis_angle(0), 80.0, max);
chart.handle_event(&Event::mouse_press(vertex.x, vertex.y, 1));
assert_eq!(*clicked.lock().expect("click lock poisoned"), vec![0]);
}
#[test]
fn radar_chart_display_switches_round_trip() {
let mut chart = chart();
for name in ["show_grid", "show_axis_labels", "show_legend"] {
chart.set(name, CapabilityValue::Bool(false)).unwrap();
assert_eq!(chart.get(name).unwrap(), CapabilityValue::Bool(false));
chart.set(name, CapabilityValue::Bool(true)).unwrap();
assert_eq!(chart.get(name).unwrap(), CapabilityValue::Bool(true));
}
assert!(chart.set("show_grid", CapabilityValue::UInt(1)).is_err());
}
#[test]
fn radar_chart_derived_properties_are_read_only() {
let mut chart = chart();
for name in ["axis_count", "series_count", "hovered_axis"] {
assert_eq!(
chart.set(name, CapabilityValue::UInt(1)),
Err(CapabilityAccessError::ReadOnlyProperty),
"{name} must be read-only"
);
}
assert_eq!(chart.get("axis_count").unwrap(), CapabilityValue::UInt(5));
assert_eq!(chart.get("series_count").unwrap(), CapabilityValue::UInt(1));
}
#[test]
fn radar_chart_hidden_grid_removes_grid_pixels() {
let size = Size::new(320, 320);
let mut with_grid = chart();
with_grid.set_show_axis_labels(false);
let grid_pixels = count_near(&render(&mut with_grid, size), (225, 225, 225));
let mut without_grid = chart();
without_grid.set_show_axis_labels(false);
without_grid.set_show_grid(false);
let bare_pixels = count_near(&render(&mut without_grid, size), (225, 225, 225));
assert!(grid_pixels > bare_pixels, "the grid must paint: {grid_pixels} vs {bare_pixels}");
}
#[test]
fn radar_chart_series_shorter_than_axes_reads_as_zero() {
let mut chart = RadarChart::new(Rect::new(0, 0, 320, 320));
chart.set_axes(vec!["A".to_string(), "B".to_string(), "C".to_string()]);
chart.add_series(vec![10.0, 20.0]);
let rgba = render(&mut chart, Size::new(320, 320));
assert!(!rgba.is_empty());
assert_eq!(chart.value_at(0, 2), None);
}
}