use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::style::{MotionSlot, PropertyDriver};
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};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChartType {
#[default]
Bar,
Line,
Area,
Pie,
Scatter,
Waterfall,
Funnel,
Candlestick,
BoxPlot,
}
impl ChartType {
pub fn as_str(self) -> &'static str {
match self {
ChartType::Bar => "bar",
ChartType::Line => "line",
ChartType::Area => "area",
ChartType::Pie => "pie",
ChartType::Scatter => "scatter",
ChartType::Waterfall => "waterfall",
ChartType::Funnel => "funnel",
ChartType::Candlestick => "candlestick",
ChartType::BoxPlot => "box_plot",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"bar" => ChartType::Bar,
"line" => ChartType::Line,
"area" => ChartType::Area,
"pie" => ChartType::Pie,
"scatter" => ChartType::Scatter,
"waterfall" => ChartType::Waterfall,
"funnel" => ChartType::Funnel,
"candlestick" => ChartType::Candlestick,
"box_plot" => ChartType::BoxPlot,
_ => return None,
})
}
pub fn values_per_point(self) -> usize {
match self {
ChartType::Candlestick => 4,
ChartType::BoxPlot => 5,
_ => 1,
}
}
}
pub struct ChartWidget {
base: BaseWidget,
chart_type: ChartType,
series: Vec<Vec<f64>>,
labels: Vec<String>,
hovered_index: Option<usize>,
reveal: PropertyDriver,
pub data_point_clicked: Signal1<usize>,
pub data_point_hovered: Signal1<usize>,
pub data_point_unhovered: Signal1<usize>,
}
impl ChartWidget {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Chart, geometry, "ChartWidget"),
chart_type: ChartType::default(),
series: Vec::new(),
labels: Vec::new(),
hovered_index: None,
reveal: PropertyDriver::at(1.0, MotionSlot::Normal),
data_point_clicked: Signal1::new(),
data_point_hovered: Signal1::new(),
data_point_unhovered: Signal1::new(),
}
}
pub fn chart_type(&self) -> ChartType {
self.chart_type
}
pub fn data(&self) -> &[f64] {
self.series.first().map_or(&[], |series| series.as_slice())
}
pub fn series(&self) -> &[Vec<f64>] {
&self.series
}
pub fn labels(&self) -> &[String] {
&self.labels
}
pub fn hovered_index(&self) -> Option<usize> {
self.hovered_index
}
pub fn hovered_value(&self) -> Option<f64> {
let index = self.hovered_index?;
self.data().get(index).copied()
}
pub fn set_chart_type(&mut self, chart_type: ChartType) {
self.chart_type = chart_type;
self.base.request_redraw();
}
pub fn set_data(&mut self, data: Vec<f64>) {
self.series = if data.is_empty() { Vec::new() } else { vec![data] };
self.restart_reveal();
self.revalidate_hover();
self.base.request_redraw();
}
pub fn set_series(&mut self, series: Vec<Vec<f64>>) {
self.series = series;
self.restart_reveal();
self.revalidate_hover();
self.base.request_redraw();
}
pub fn set_labels(&mut self, labels: Vec<String>) {
self.labels = labels;
self.base.request_redraw();
}
fn revalidate_hover(&mut self) {
let points = self.data().len();
if self.hovered_index.is_some_and(|index| index >= points) {
self.hovered_index = None;
self.data_point_unhovered.emit(0);
}
}
fn data_index_at(&self, pos: Point) -> Option<usize> {
let point_count = point_count_for(self.chart_type, self.data().len());
if point_count == 0 {
return None;
}
let rect = self.base.geometry();
if !rect.contains_point(pos) || rect.width == 0 {
return None;
}
let local_x = (pos.x - rect.x).max(0) as u32;
let width = rect.width.max(1);
let mut idx = ((local_x as u64) * (point_count as u64) / (width as u64)) as usize;
if idx >= point_count {
idx = point_count - 1;
}
Some(idx)
}
fn restart_reveal(&mut self) {
self.reveal.jump_to(0.0);
self.reveal.set_target(1.0);
}
pub fn reveal_progress(&self) -> f32 {
self.reveal.value()
}
fn grown_from_baseline(&self, value: f64, baseline: f64) -> f64 {
let progress = self.reveal.value() as f64;
if progress >= 1.0 {
return value;
}
baseline + (value - baseline) * progress
}
fn value_range(&self) -> Option<(f64, f64)> {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for value in self.series.iter().flatten() {
if value.is_finite() {
min = min.min(*value);
max = max.max(*value);
}
}
if !min.is_finite() || !max.is_finite() {
return None;
}
Some((min, max))
}
}
fn point_count_for(chart_type: ChartType, len: usize) -> usize {
let per_point = chart_type.values_per_point();
if per_point <= 1 {
len
} else {
len / per_point
}
}
impl Widget for ChartWidget {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(400, 300)
}
fn tick(&mut self, delta_ms: u32) -> bool {
self.reveal.tick(delta_ms)
}
fn is_animating(&self) -> bool {
self.reveal.is_moving()
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for ChartWidget {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"chart_type" => {
Ok(CapabilityValue::String(chart_type_to_str(self.chart_type()).to_string()))
}
"point_count" => Ok(CapabilityValue::UInt(point_count_for(
self.chart_type,
self.data().len(),
) as u64)),
"label_count" => Ok(CapabilityValue::UInt(self.labels().len() as u64)),
"series_count" => Ok(CapabilityValue::UInt(self.series().len() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"chart_type" => {
self.set_chart_type(expect_chart_type(value)?);
Ok(())
}
"point_count" | "label_count" | "series_count" => {
Err(CapabilityAccessError::ReadOnlyProperty)
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"chart_type",
"point_count",
"label_count",
"series_count",
BASE_PROPERTY_NAMES
]
}
}
fn chart_type_to_str(chart_type: ChartType) -> &'static str {
chart_type.as_str()
}
fn expect_chart_type(value: CapabilityValue) -> Result<ChartType, CapabilityAccessError> {
match value {
CapabilityValue::String(token) => {
ChartType::from_name(&token).ok_or(CapabilityAccessError::TypeMismatch)
}
_ => Err(CapabilityAccessError::TypeMismatch),
}
}
impl Draw for ChartWidget {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.base.geometry();
use crate::core::Font;
let style = self.base.style().clone();
let (surface, ink) = Self::panel_colors_with(Some(&style));
let plot = PlotArea::of(rect);
let border = style
.border_color
.or_else(|| crate::style::resolved_theme_style("chart").and_then(|t| t.border_color))
.unwrap_or_else(|| surface.blend(&ink, 0.2));
context.fill_rect(rect, surface);
context.draw_rect(rect, border);
if self.series.is_empty() {
self.draw_value_axis(context, &plot, ink, surface);
let font = Font::simple("Sans", 12.0);
let line = context.text_line(rect, &font);
context.draw_text_fitted(
line,
"No data",
&font,
ink.legible_on(surface, 4.5).with_alpha(160),
HorizontalAlignment::Center,
);
return;
}
match self.chart_type {
ChartType::Bar => {
self.draw_value_axis(context, &plot, ink, surface);
for index in 0..self.series.len() {
self.draw_bar_chart(context, rect, index);
}
}
ChartType::Line => {
for index in 0..self.series.len() {
self.draw_line_chart(context, rect, index);
}
}
ChartType::Area => {
for index in 0..self.series.len() {
self.draw_area_chart(context, rect, index);
}
}
ChartType::Scatter => {
for index in 0..self.series.len() {
self.draw_scatter_chart(context, rect, index);
}
}
ChartType::Pie => self.draw_pie_chart(context, rect),
ChartType::Waterfall => self.draw_waterfall_chart(context, rect),
ChartType::Funnel => self.draw_funnel_chart(context, rect),
ChartType::Candlestick => self.draw_candlestick_chart(context, rect),
ChartType::BoxPlot => self.draw_box_plot_chart(context, rect),
}
}
}
struct PlotArea {
left: i32,
right: i32,
baseline_y: i32,
top_y: i32,
outer_left: i32,
}
const LABEL_ROW_TOP: i32 = 12;
const LABEL_ROW_HEIGHT: i32 = 10;
const LABEL_ROW_BOTTOM_GUARD: i32 = 1;
const AXIS_LABEL_COLUMN: i32 = 34;
impl PlotArea {
fn of(rect: Rect) -> Self {
const PADDING: i32 = 8;
const BOTTOM_MARGIN: i32 = LABEL_ROW_TOP + LABEL_ROW_HEIGHT + LABEL_ROW_BOTTOM_GUARD;
let left = rect.x.saturating_add(AXIS_LABEL_COLUMN);
let right = rect.x.saturating_add(rect.width as i32).saturating_sub(PADDING);
let baseline_y = rect.y.saturating_add(rect.height as i32).saturating_sub(BOTTOM_MARGIN);
let top_y = rect.y.saturating_add(PADDING);
Self { left, right, baseline_y, top_y, outer_left: rect.x }
}
fn axis_margin_left(&self) -> i32 {
self.outer_left + 2
}
fn height_range(&self) -> f64 {
(self.baseline_y.saturating_sub(self.top_y)).max(1) as f64
}
fn y_for(&self, value: f64, min: f64, max: f64) -> i32 {
let span = max - min;
if !span.is_finite() || span <= 0.0 {
return self.baseline_y;
}
let ratio = (value - min) / span;
self.baseline_y - (ratio * self.height_range()) as i32
}
fn x_for(&self, index: usize, count: usize, inset: i32) -> i32 {
if count <= 1 {
return self.left + (self.right - self.left) / 2;
}
let span = (self.right - self.left).saturating_sub(inset * 2).max(1);
self.left + inset + (index as i32 * span) / (count as i32 - 1).max(1)
}
}
fn format_axis_value(value: f64) -> String {
if !value.is_finite() {
return "—".to_string();
}
if (value - value.round()).abs() < 1e-9 {
format!("{}", value.round() as i64)
} else {
format!("{value:.1}")
}
}
fn draw_truncated_label(
context: &mut RenderContext,
x: i32,
baseline_y: i32,
label: &str,
right_bound: i32,
) {
use crate::core::Font;
const BUDGET: usize = 6;
if label.is_empty() {
return;
}
let text = if label.chars().count() > BUDGET {
let kept: String = label.chars().take(BUDGET - 2).collect();
format!("{kept}..")
} else {
label.to_string()
};
let font = Font::simple("Sans", 10.0);
let width = context.measure_text(&text, &font).width as i32;
let origin_x = (x - width / 2).min(right_bound - width).max(0);
let (surface, ink) = ChartWidget::panel_colors();
context.draw_text(
crate::core::Point { x: origin_x, y: baseline_y + LABEL_ROW_TOP },
&text,
&font,
ink.legible_on(surface, 4.5).with_alpha(191),
HorizontalAlignment::Left,
);
}
impl ChartWidget {
pub(crate) fn panel_colors_with(
style: Option<&crate::style::WidgetStyle>,
) -> (crate::core::Color, crate::core::Color) {
let theme = crate::style::resolved_theme_style("chart");
let surface = style
.and_then(|s| s.background_color)
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(crate::core::Color::rgb(255, 255, 255));
let ink = style
.and_then(|s| s.text_color)
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or_else(|| surface.contrast_color());
(surface, ink)
}
pub(crate) fn panel_colors() -> (crate::core::Color, crate::core::Color) {
Self::panel_colors_with(None)
}
const PALETTE: [crate::core::Color; 6] = [
crate::core::Color::rgb(66, 133, 244),
crate::core::Color::rgb(219, 68, 55),
crate::core::Color::rgb(244, 180, 0),
crate::core::Color::rgb(15, 157, 88),
crate::core::Color::rgb(171, 71, 188),
crate::core::Color::rgb(0, 172, 193),
];
fn series_color(index: usize) -> crate::core::Color {
Self::PALETTE[index % Self::PALETTE.len()]
}
fn plot_range(&self) -> (f64, f64) {
let Some((min, max)) = self.value_range() else {
return (0.0, 1.0);
};
if min >= 0.0 {
(0.0, if max > 0.0 { max } else { 1.0 })
} else {
(min, max)
}
}
fn draw_value_axis(
&self,
context: &mut RenderContext,
area: &PlotArea,
ink: Color,
surface: Color,
) {
const TICKS: i32 = 4;
let (min, max) = self.plot_range();
let axis_ink = ink.legible_on(surface, 4.5).with_alpha(190);
let grid_ink = surface.blend(&axis_ink, 0.18);
let font = Font::simple("Sans", 10.0);
for step in 0..TICKS {
let fraction = step as f64 / (TICKS - 1) as f64;
let value = min + (max - min) * fraction;
let y = area.y_for(value, min, max);
context.draw_line(Point::new(area.left, y), Point::new(area.right, y), grid_ink);
let band = Rect {
x: area.axis_margin_left(),
y: y - LABEL_ROW_HEIGHT / 2,
width: (area.left - area.axis_margin_left()).max(0) as u32,
height: LABEL_ROW_HEIGHT as u32,
};
let line = context.text_line(band, &font);
context.draw_text_fitted(
line,
&format_axis_value(value),
&font,
axis_ink,
HorizontalAlignment::Right,
);
}
}
fn draw_bar_chart(&self, context: &mut RenderContext, rect: Rect, series_index: usize) {
let data = match self.series.get(series_index) {
Some(data) if !data.is_empty() => data,
_ => return,
};
let area = PlotArea::of(rect);
let (min, max) = self.plot_range();
let series_total = self.series.len().max(1);
let slot = (area.right - area.left).max(1) / data.len() as i32;
let bar_width = (slot / series_total as i32).max(1);
let gap = if series_total > 1 { 1 } else { 2 };
for (i, &val) in data.iter().enumerate() {
let slot_x = area.left + (i as i32) * slot;
let x = slot_x + (series_index as i32) * bar_width;
let val = self.grown_from_baseline(val, min);
let top = area.y_for(val, min, max);
let color = Self::series_color(series_index);
context.fill_rect(
Rect {
x,
y: top,
width: bar_width.saturating_sub(gap).max(1) as u32,
height: area.baseline_y.saturating_sub(top).max(1) as u32,
},
color,
);
if series_index == 0 {
if let Some(label) = self.labels.get(i) {
let label_x = slot_x + slot / 2;
draw_truncated_label(
context,
label_x,
area.baseline_y,
label,
rect.x + rect.width as i32,
);
}
}
}
}
fn draw_line_chart(&self, context: &mut RenderContext, rect: Rect, series_index: usize) {
let data = match self.series.get(series_index) {
Some(data) if data.len() >= 2 => data,
_ => return,
};
let area = PlotArea::of(rect);
let (min, max) = self.plot_range();
let color = Self::series_color(series_index);
let points: Vec<Point> = data
.iter()
.enumerate()
.map(|(i, &val)| Point {
x: area.x_for(i, data.len(), 0),
y: area.y_for(self.grown_from_baseline(val, min), min, max),
})
.collect();
for pair in points.windows(2) {
context.draw_line_stroke(pair[0], pair[1], color, 2);
}
for (i, point) in points.iter().enumerate() {
context.fill_circle(*point, 3, color);
if series_index == 0 {
if let Some(label) = self.labels.get(i) {
draw_truncated_label(
context,
point.x,
area.baseline_y,
label,
rect.x + rect.width as i32,
);
}
}
}
}
fn draw_area_chart(&self, context: &mut RenderContext, rect: Rect, series_index: usize) {
let data = match self.series.get(series_index) {
Some(data) if data.len() >= 2 => data,
_ => return,
};
let area = PlotArea::of(rect);
let (min, max) = self.plot_range();
let color = Self::series_color(series_index);
let points: Vec<Point> = data
.iter()
.enumerate()
.map(|(i, &val)| Point {
x: area.x_for(i, data.len(), 0),
y: area.y_for(self.grown_from_baseline(val, min), min, max),
})
.collect();
for column in points.windows(2) {
let (from, to) = (column[0], column[1]);
let span = (to.x - from.x).max(1);
for step in 0..=span {
let x = from.x + step;
let ratio = step as f64 / span as f64;
let y = (from.y as f64 + (to.y as f64 - from.y as f64) * ratio) as i32;
context.fill_rect(
Rect {
x,
y,
width: 1,
height: area.baseline_y.saturating_sub(y).max(1) as u32,
},
color,
);
}
}
for pair in points.windows(2) {
context.draw_line_stroke(pair[0], pair[1], color, 2);
}
if series_index == 0 {
for (i, point) in points.iter().enumerate() {
if let Some(label) = self.labels.get(i) {
draw_truncated_label(
context,
point.x,
area.baseline_y,
label,
rect.x + rect.width as i32,
);
}
}
}
}
fn draw_waterfall_chart(&self, context: &mut RenderContext, rect: Rect) {
let data = match self.series.first() {
Some(data) if !data.is_empty() => data,
_ => return,
};
let area = PlotArea::of(rect);
let mut cumulative = 0.0f64;
let totals: Vec<(f64, f64)> = data
.iter()
.map(|&value| {
let from = cumulative;
cumulative += value;
(from, cumulative)
})
.collect();
let mut min = 0.0f64;
let mut max = 0.0f64;
for (from, to) in &totals {
min = min.min(*from).min(*to);
max = max.max(*from).max(*to);
}
let span = max - min;
if !span.is_finite() || span <= 0.0 {
return;
}
let slot = (area.right - area.left).max(1) / data.len() as i32;
let bar_width = slot.saturating_sub(2).max(1);
for (i, &(from, to)) in totals.iter().enumerate() {
let x = area.left + (i as i32) * slot;
let y_from = area.baseline_y - (((from - min) / span) * area.height_range()) as i32;
let y_to = area.baseline_y - (((to - min) / span) * area.height_range()) as i32;
let top = y_from.min(y_to);
let height = (y_from - y_to).unsigned_abs().max(1);
let color = if to >= from { Self::series_color(3) } else { Self::series_color(1) };
context.fill_rect(Rect { x, y: top, width: bar_width as u32, height }, color);
if i + 1 < totals.len() {
let connector_y = y_to;
context.draw_line_stroke(
Point { x: x + bar_width, y: connector_y },
Point { x: area.left + ((i + 1) as i32) * slot, y: connector_y },
crate::core::Color::rgb(150, 150, 150),
1,
);
}
if let Some(label) = self.labels.get(i) {
draw_truncated_label(
context,
x + bar_width / 2,
area.baseline_y,
label,
rect.x + rect.width as i32,
);
}
}
}
fn draw_funnel_chart(&self, context: &mut RenderContext, rect: Rect) {
let data = match self.series.first() {
Some(data) if !data.is_empty() => data,
_ => return,
};
let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if !max.is_finite() || max <= 0.0 {
return;
}
let area = PlotArea::of(rect);
let available_height = area.baseline_y.saturating_sub(area.top_y).max(1);
let stage_height = (available_height / data.len() as i32).max(1);
let available_width = (area.right - area.left).max(1);
for (i, &value) in data.iter().enumerate() {
let ratio = (value / max).clamp(0.0, 1.0);
let width = ((ratio * available_width as f64).round() as i32).max(1);
let y = area.top_y + (i as i32) * stage_height;
let x = area.left + (available_width - width) / 2;
context.fill_rect(
Rect {
x,
y,
width: width as u32,
height: stage_height.saturating_sub(2).max(1) as u32,
},
Self::series_color(i),
);
if let Some(label) = self.labels.get(i) {
draw_truncated_label(
context,
x + 2,
y + stage_height - 4,
label,
rect.x + rect.width as i32,
);
}
}
}
fn draw_candlestick_chart(&self, context: &mut RenderContext, rect: Rect) {
const VALUES_PER_BAR: usize = 4;
let data = match self.series.first() {
Some(data) => data,
_ => return,
};
let bar_count = data.len() / VALUES_PER_BAR;
if bar_count == 0 {
return;
}
let area = PlotArea::of(rect);
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for bar in data.chunks_exact(VALUES_PER_BAR).take(bar_count) {
for value in bar {
if value.is_finite() {
min = min.min(*value);
max = max.max(*value);
}
}
}
if !min.is_finite() || !max.is_finite() || max <= min {
return;
}
let slot = (area.right - area.left).max(1) / bar_count as i32;
let body_width = slot.saturating_sub(2).max(3);
let wick_x_offset = body_width / 2;
for (i, bar) in data.chunks_exact(VALUES_PER_BAR).take(bar_count).enumerate() {
let (open, high, low, close) = (bar[0], bar[1], bar[2], bar[3]);
let x = area.left + (i as i32) * slot;
let y_open = area.y_for(open, min, max);
let y_close = area.y_for(close, min, max);
let y_high = area.y_for(high, min, max);
let y_low = area.y_for(low, min, max);
let rising = close >= open;
let color = if rising { Self::series_color(3) } else { Self::series_color(1) };
let wick_x = x + wick_x_offset;
context.draw_line_stroke(
Point { x: wick_x, y: y_high },
Point { x: wick_x, y: y_low },
color,
1,
);
let body_top = y_open.min(y_close);
context.fill_rect(
Rect {
x,
y: body_top,
width: body_width as u32,
height: (y_open - y_close).unsigned_abs().max(1),
},
color,
);
}
for i in 0..bar_count {
if let Some(label) = self.labels.get(i) {
let x = area.left + (i as i32) * slot + slot / 2;
draw_truncated_label(
context,
x,
area.baseline_y,
label,
rect.x + rect.width as i32,
);
}
}
}
fn draw_box_plot_chart(&self, context: &mut RenderContext, rect: Rect) {
const VALUES_PER_BOX: usize = 5;
let data = match self.series.first() {
Some(data) => data,
_ => return,
};
let box_count = data.len() / VALUES_PER_BOX;
if box_count == 0 {
return;
}
let area = PlotArea::of(rect);
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for group in data.chunks_exact(VALUES_PER_BOX).take(box_count) {
for value in group {
if value.is_finite() {
min = min.min(*value);
max = max.max(*value);
}
}
}
if !min.is_finite() || !max.is_finite() || max <= min {
return;
}
let slot = (area.right - area.left).max(1) / box_count as i32;
let box_width = slot.saturating_sub(4).max(4);
let center_offset = box_width / 2;
let color = Self::series_color(0);
for (i, group) in data.chunks_exact(VALUES_PER_BOX).take(box_count).enumerate() {
let (low, q1, median, q3, high) = (group[0], group[1], group[2], group[3], group[4]);
let x = area.left + (i as i32) * slot + 2;
let cx = x + center_offset;
let y_low = area.y_for(low, min, max);
let y_high = area.y_for(high, min, max);
let y_q1 = area.y_for(q1, min, max);
let y_q3 = area.y_for(q3, min, max);
context.draw_line_stroke(
Point { x: cx, y: y_high },
Point { x: cx, y: y_low },
color,
1,
);
let box_top = y_q1.min(y_q3);
context.fill_rect(
Rect {
x,
y: box_top,
width: box_width as u32,
height: (y_q1 - y_q3).unsigned_abs().max(1),
},
color,
);
let y_median = area.y_for(median, min, max);
context.fill_rect(
Rect { x, y: y_median, width: box_width as u32, height: 2 },
crate::core::Color::rgb(255, 255, 255),
);
if let Some(label) = self.labels.get(i) {
draw_truncated_label(
context,
cx,
area.baseline_y,
label,
rect.x + rect.width as i32,
);
}
}
}
fn draw_pie_chart(&self, context: &mut RenderContext, rect: Rect) {
let data = match self.series.first() {
Some(data) if !data.is_empty() => data,
_ => return,
};
let total: f64 = data.iter().filter(|value| **value > 0.0).sum();
if total <= 0.0 {
return;
}
let cx = rect.x + rect.width as i32 / 2;
let cy = rect.y + rect.height as i32 / 2;
let radius = (rect.width.min(rect.height) as i32 / 2).saturating_sub(10).max(10);
let mut start_angle = -std::f64::consts::FRAC_PI_2;
for (i, &val) in data.iter().enumerate() {
if val <= 0.0 {
continue;
}
let slice_angle = 2.0 * std::f64::consts::PI * (val / total);
let mid_angle = start_angle + slice_angle / 2.0;
let end_angle = start_angle + slice_angle;
let color = Self::series_color(i);
let segments = ((radius as f64 * slice_angle * 0.4).ceil() as i32).clamp(1, 120);
for s in 0..segments {
let t = start_angle + slice_angle * (s as f64 + 0.5) / segments as f64;
let ex = cx + (radius as f64 * t.cos()) as i32;
let ey = cy + (radius as f64 * t.sin()) as i32;
context.draw_line(Point { x: cx, y: cy }, Point { x: ex, y: ey }, color);
}
if let Some(label) = self.labels.get(i) {
let label_radius = radius.saturating_add(14) as f64;
let lx = cx + (label_radius * mid_angle.cos()) as i32;
let ly = cy + (label_radius * mid_angle.sin()) as i32;
let pct = val / total * 100.0;
let text = if pct >= 1.0 {
format!("{label}:{pct:.0}%")
} else {
format!("{label}:{pct:.1}%")
};
context.draw_text(
Point { x: lx, y: ly },
&text,
&crate::core::Font::simple("Sans", 9.0),
crate::core::Color::rgb(60, 60, 60),
HorizontalAlignment::Left,
);
}
start_angle = end_angle;
}
}
fn draw_scatter_chart(&self, context: &mut RenderContext, rect: Rect, series_index: usize) {
let data = match self.series.get(series_index) {
Some(data) if !data.is_empty() => data,
_ => return,
};
let area = PlotArea::of(rect);
let (min, max) = self.plot_range();
let color = Self::series_color(series_index);
let slot = (area.right - area.left).max(1) / data.len() as i32;
for (i, &val) in data.iter().enumerate() {
let x = area.left + (i as i32) * slot + slot / 2;
let y = area.y_for(val, min, max);
context.fill_circle(Point { x, y }, 3, color);
if series_index == 0 {
if let Some(label) = self.labels.get(i) {
draw_truncated_label(
context,
x,
area.baseline_y,
label,
rect.x + rect.width as i32,
);
}
}
}
}
}
impl EventHandler for ChartWidget {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MouseMove { pos } => {
let next = self.data_index_at(*pos);
if next != self.hovered_index {
if let Some(previous) = self.hovered_index {
self.data_point_unhovered.emit(previous);
}
if let Some(index) = next {
self.hovered_index = Some(index);
self.data_point_hovered.emit(index);
} else {
self.hovered_index = None;
}
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
if let Some(previous) = self.hovered_index.take() {
self.data_point_unhovered.emit(previous);
self.base.request_redraw();
}
}
Event::MousePress { pos, button } if *button == 1 => {
self.base.set_mouse_pressed(true);
if let Some(index) = self.data_index_at(*pos) {
self.base.clicked.emit();
self.data_point_clicked.emit(index);
}
}
Event::MouseRelease { pos: _, button } if *button == 1 => {
self.base.set_mouse_pressed(false);
}
_ => { }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn chart_mouse_interaction_emits_data_index_signals() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 100));
chart.set_data(vec![10.0, 20.0, 30.0, 40.0]);
let clicked = Arc::new(Mutex::new(Vec::<usize>::new()));
let hovered = Arc::new(Mutex::new(Vec::<usize>::new()));
let clicked_sink = clicked.clone();
chart.data_point_clicked.connect(move |index| {
if let Ok(mut guard) = clicked_sink.lock() {
guard.push(*index);
}
});
let hovered_sink = hovered.clone();
chart.data_point_hovered.connect(move |index| {
if let Ok(mut guard) = hovered_sink.lock() {
guard.push(*index);
}
});
chart.handle_event(&Event::mouse_move(120, 50));
chart.handle_event(&Event::mouse_press(120, 50, 1));
let clicked_values = clicked.lock().expect("clicked lock poisoned").clone();
let hovered_values = hovered.lock().expect("hovered lock poisoned").clone();
assert_eq!(hovered_values, vec![2]);
assert_eq!(clicked_values, vec![2]);
}
fn render(chart: &mut ChartWidget, width: u32, height: u32) -> Vec<u8> {
use crate::core::{Color, Size};
use crate::render::{PaintBackend, SoftwarePaintBackend};
let mut backend = SoftwarePaintBackend::new(Size::new(width, height), 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 painted_pixels(rgba: &[u8]) -> usize {
rgba.chunks_exact(4)
.filter(|px| {
let (r, g, b) = (px[0], px[1], px[2]);
!((r == 255 && g == 255 && b == 255) || (r == 200 && g == 200 && b == 200))
})
.count()
}
#[test]
fn chart_set_data_populates_series_zero() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 100));
chart.set_data(vec![1.0, 2.0, 3.0]);
assert_eq!(chart.data(), &[1.0, 2.0, 3.0]);
assert_eq!(chart.series().len(), 1);
assert_eq!(chart.get("series_count").unwrap(), CapabilityValue::UInt(1));
}
#[test]
fn chart_set_data_empty_clears_series() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 100));
chart.set_data(vec![1.0]);
chart.set_data(Vec::new());
assert!(chart.series().is_empty());
assert_eq!(chart.get("series_count").unwrap(), CapabilityValue::UInt(0));
}
#[test]
fn chart_set_series_keeps_every_series_and_agrees_with_data() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 100));
chart.set_series(vec![vec![1.0, 2.0], vec![3.0, 4.0, 5.0]]);
assert_eq!(chart.series().len(), 2);
assert_eq!(chart.data(), &[1.0, 2.0], "data() reports series zero");
assert_eq!(chart.get("series_count").unwrap(), CapabilityValue::UInt(2));
}
#[test]
fn chart_every_variant_paints_something() {
for variant in [
ChartType::Bar,
ChartType::Line,
ChartType::Area,
ChartType::Pie,
ChartType::Scatter,
ChartType::Waterfall,
ChartType::Funnel,
ChartType::Candlestick,
ChartType::BoxPlot,
] {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
chart.set_chart_type(variant);
chart.set_data(vec![
10.0, 30.0, 20.0, 40.0, 25.0, 35.0, 15.0, 45.0, 30.0, 50.0, 40.0, 60.0, 45.0, 55.0,
50.0, 70.0, 60.0, 80.0, 65.0, 75.0,
]);
let rgba = render(&mut chart, 200, 120);
assert!(
painted_pixels(&rgba) > 0,
"{variant:?} painted nothing; a variant that cannot draw is a name without a control"
);
}
}
#[test]
fn chart_grouped_variants_ignore_trailing_partial_group() {
let mut candle = ChartWidget::new(Rect::new(0, 0, 200, 120));
candle.set_chart_type(ChartType::Candlestick);
candle.set_data(vec![10.0, 30.0, 5.0, 20.0, 99.0, 99.0, 99.0]);
assert_eq!(
candle.get("point_count").unwrap(),
CapabilityValue::UInt(1),
"three trailing values do not make a second candlestick"
);
let mut boxes = ChartWidget::new(Rect::new(0, 0, 200, 120));
boxes.set_chart_type(ChartType::BoxPlot);
boxes.set_data(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
assert_eq!(
boxes.get("point_count").unwrap(),
CapabilityValue::UInt(1),
"one trailing value does not make a second box"
);
}
#[test]
fn chart_values_per_point_matches_the_documented_grouping() {
assert_eq!(ChartType::Candlestick.values_per_point(), 4);
assert_eq!(ChartType::BoxPlot.values_per_point(), 5);
for variant in [
ChartType::Bar,
ChartType::Line,
ChartType::Area,
ChartType::Pie,
ChartType::Scatter,
ChartType::Waterfall,
ChartType::Funnel,
] {
assert_eq!(variant.values_per_point(), 1);
}
}
#[test]
fn chart_hover_index_follows_the_grouping() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
chart.set_chart_type(ChartType::Candlestick);
chart.set_data(vec![10.0, 30.0, 5.0, 20.0, 15.0, 40.0, 10.0, 35.0]);
let hovered = Arc::new(Mutex::new(Vec::<usize>::new()));
let sink = hovered.clone();
chart.data_point_hovered.connect(move |index| {
if let Ok(mut guard) = sink.lock() {
guard.push(*index);
}
});
chart.handle_event(&Event::mouse_move(150, 50));
assert_eq!(
*hovered.lock().expect("hover lock poisoned"),
vec![1],
"the hit bucket counts bars, not raw values"
);
}
#[test]
fn chart_type_token_round_trips_for_every_variant() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
for variant in [
ChartType::Bar,
ChartType::Line,
ChartType::Area,
ChartType::Pie,
ChartType::Scatter,
ChartType::Waterfall,
ChartType::Funnel,
ChartType::Candlestick,
ChartType::BoxPlot,
] {
chart
.set("chart_type", CapabilityValue::String(variant.as_str().to_string()))
.expect("every published token must be writable");
assert_eq!(chart.chart_type(), variant);
assert_eq!(
chart.get("chart_type").unwrap(),
CapabilityValue::String(variant.as_str().to_string())
);
assert_eq!(ChartType::from_name(variant.as_str()), Some(variant));
}
assert!(chart
.set("chart_type", CapabilityValue::String("candlestick_chart".to_string()))
.is_err());
}
#[test]
fn chart_series_count_is_read_only() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
assert_eq!(
chart.set("series_count", CapabilityValue::UInt(5)),
Err(CapabilityAccessError::ReadOnlyProperty)
);
}
#[test]
fn chart_renders_negative_values_below_the_baseline() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
chart.set_chart_type(ChartType::Bar);
chart.set_data(vec![-10.0, 20.0, -5.0]);
let rgba = render(&mut chart, 200, 120);
assert!(painted_pixels(&rgba) > 0);
assert_eq!(chart.chart_type(), ChartType::Bar);
}
fn series_pixels(rgba: &[u8], series: usize) -> usize {
let color = ChartWidget::series_color(series);
rgba.chunks_exact(4)
.filter(|px| px[0] == color.r && px[1] == color.g && px[2] == color.b)
.count()
}
#[test]
fn setting_data_grows_the_marks_from_the_baseline() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
chart.set_chart_type(ChartType::Bar);
chart.set_data(vec![10.0, 20.0, 30.0, 40.0]);
assert!(chart.is_animating(), "freshly set data owes frames");
assert!(chart.tick(20), "still growing after one step");
let mid = chart.reveal_progress();
assert!(mid > 0.0 && mid < 1.0, "it passes through an interior value (got {mid})");
let mid_pixels = series_pixels(&render(&mut chart, 200, 120), 0);
while chart.tick(1000) {}
assert_eq!(chart.reveal_progress(), 1.0, "and settles fully grown");
assert!(!chart.is_animating(), "owing no further frames");
let settled_pixels = series_pixels(&render(&mut chart, 200, 120), 0);
assert!(
mid_pixels > 0 && mid_pixels < settled_pixels,
"a half-grown chart must paint fewer pixels than a settled one: \
mid={mid_pixels} settled={settled_pixels}"
);
chart.set_data(vec![5.0, 5.0, 5.0, 5.0]);
assert!(
chart.reveal_progress() < 1.0,
"replacing the data restarts the reveal: {}",
chart.reveal_progress()
);
}
#[test]
fn chart_hovered_index_is_observable() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
chart.set_data(vec![10.0, 20.0, 30.0, 40.0]);
assert_eq!(chart.hovered_index(), None, "nothing is hovered initially");
chart.handle_event(&Event::mouse_move(120, 50));
assert_eq!(chart.hovered_index(), Some(2));
assert_eq!(chart.hovered_value(), Some(30.0));
}
#[test]
fn chart_mouse_leave_clears_hover_and_reports_it() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
chart.set_data(vec![10.0, 20.0, 30.0, 40.0]);
let unhovered = Arc::new(Mutex::new(Vec::<usize>::new()));
let sink = unhovered.clone();
chart.data_point_unhovered.connect(move |index| {
if let Ok(mut guard) = sink.lock() {
guard.push(*index);
}
});
chart.handle_event(&Event::mouse_move(120, 50));
assert_eq!(chart.hovered_index(), Some(2));
chart.handle_event(&Event::mouse_leave(120, 50));
assert_eq!(chart.hovered_index(), None, "leave must clear the hover");
assert_eq!(
unhovered.lock().expect("lock poisoned").clone(),
vec![2],
"leave must report the point it left"
);
chart.handle_event(&Event::mouse_leave(120, 50));
assert_eq!(unhovered.lock().expect("lock poisoned").len(), 1);
}
#[test]
fn chart_moving_between_buckets_unhovers_the_previous() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
chart.set_data(vec![10.0, 20.0, 30.0, 40.0]);
let events = Arc::new(Mutex::new(Vec::<String>::new()));
let hover_sink = events.clone();
chart.data_point_hovered.connect(move |index| {
if let Ok(mut guard) = hover_sink.lock() {
guard.push(format!("enter {index}"));
}
});
let unhover_sink = events.clone();
chart.data_point_unhovered.connect(move |index| {
if let Ok(mut guard) = unhover_sink.lock() {
guard.push(format!("leave {index}"));
}
});
chart.handle_event(&Event::mouse_move(30, 50));
chart.handle_event(&Event::mouse_move(120, 50));
assert_eq!(
events.lock().expect("lock poisoned").clone(),
vec!["enter 0".to_string(), "leave 0".to_string(), "enter 2".to_string()],
"a bucket change must leave the old bucket before entering the new one"
);
}
#[test]
fn chart_replacing_data_drops_a_stale_hover() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
chart.set_data(vec![1.0; 40]);
chart.handle_event(&Event::mouse_move(150, 50));
let index = chart.hovered_index().expect("the pointer is over a point");
assert!(index >= 5, "expected a high index for a 40-point series, got {index}");
chart.set_data(vec![1.0; 5]);
assert_eq!(chart.hovered_index(), None, "the stale index must be dropped");
assert_eq!(chart.hovered_value(), None);
chart.handle_event(&Event::mouse_move(30, 50));
let in_range = chart.hovered_index().expect("pointer over a point");
assert!(in_range < 5);
chart.set_data(vec![1.0; 8]);
assert_eq!(chart.hovered_index(), Some(in_range), "an in-range hover is kept");
}
}