use crate::event::Rect;
use crate::types::{Color, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Chart {
pub id: WidgetId,
pub kind: ChartKind,
pub series: Vec<Series>,
#[serde(default)]
pub x_label: Option<String>,
#[serde(default)]
pub y_label: Option<String>,
#[serde(default)]
pub y_range: Option<(f64, f64)>,
#[serde(default)]
pub x_range: Option<(f64, f64)>,
#[serde(default)]
pub show_legend: bool,
#[serde(default)]
pub y_ticks: Option<usize>,
#[serde(default)]
pub x_ticks: Option<usize>,
#[serde(default)]
pub show_grid: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ChartKind {
Sparkline,
#[default]
Line,
Bar,
BarGrouped,
}
impl ChartKind {
pub fn is_bar(self) -> bool {
matches!(self, ChartKind::Bar | ChartKind::BarGrouped)
}
pub fn is_stacked_bar(self) -> bool {
matches!(self, ChartKind::Bar)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Series {
pub label: String,
pub data: Vec<f64>,
#[serde(default)]
pub color: Option<Color>,
#[serde(default)]
pub fill: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChartEvent {
Clicked { id: WidgetId },
DataPointClicked {
id: WidgetId,
series_idx: usize,
data_idx: usize,
},
LegendClicked { id: WidgetId, series_idx: usize },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChartMeasure {
pub width: f32,
pub height: f32,
pub char_width: f32,
pub line_height: f32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChartHit {
DataPoint(WidgetId, usize, usize),
Body(WidgetId),
Legend(WidgetId, usize),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChartLayout {
pub bounds: Rect,
pub plot_area: Rect,
pub legend_bounds: Option<Rect>,
pub hit_regions: Vec<(Rect, ChartHit)>,
pub data_point_positions: Vec<(usize, usize, f32, f32)>,
pub y_tick_positions: Vec<(f32, f64)>,
pub x_tick_positions: Vec<(f32, f64)>,
}
impl ChartLayout {
pub fn hit_test(&self, x: f32, y: f32) -> ChartHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
ChartHit::Empty
}
pub fn nearest_point(&self, x: f32, y: f32, snap_distance: f32) -> Option<(usize, usize)> {
let mut best: Option<(usize, usize, f32)> = None;
let snap_sq = snap_distance * snap_distance;
for &(si, di, px, py) in &self.data_point_positions {
let dx = x - px;
let dy = y - py;
let dist_sq = dx * dx + dy * dy;
if dist_sq <= snap_sq && (best.is_none() || dist_sq < best.unwrap().2) {
best = Some((si, di, dist_sq));
}
}
best.map(|(si, di, _)| (si, di))
}
pub fn screen_to_data_x(&self, screen_x: f32, data_len: usize) -> f64 {
if data_len <= 1 || self.plot_area.width <= 0.0 {
return 0.0;
}
let frac = ((screen_x - self.plot_area.x) / self.plot_area.width).clamp(0.0, 1.0);
frac as f64 * (data_len - 1) as f64
}
pub fn data_to_screen_x(&self, data_x: f64, data_len: usize) -> f32 {
if data_len <= 1 {
return self.plot_area.x;
}
let frac = (data_x / (data_len - 1) as f64).clamp(0.0, 1.0) as f32;
self.plot_area.x + frac * self.plot_area.width
}
}
impl Chart {
pub fn effective_y_range(&self) -> (f64, f64) {
if let Some(range) = self.y_range {
return range;
}
if self.kind.is_stacked_bar() && self.series.len() > 1 {
let mut min = 0.0_f64;
let mut max = 0.0_f64;
for data_idx in 0..self.max_data_len() {
let mut cum = 0.0;
for s in &self.series {
cum += s.data.get(data_idx).copied().unwrap_or(0.0);
if cum < min {
min = cum;
}
if cum > max {
max = cum;
}
}
}
return if (max - min).abs() < f64::EPSILON {
(min - 1.0, max + 1.0)
} else {
(min, max)
};
}
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for s in &self.series {
for &v in &s.data {
if v < min {
min = v;
}
if v > max {
max = v;
}
}
}
if min > max {
(0.0, 1.0)
} else if (max - min).abs() < f64::EPSILON {
(min - 1.0, max + 1.0)
} else {
(min, max)
}
}
pub fn max_data_len(&self) -> usize {
self.series.iter().map(|s| s.data.len()).max().unwrap_or(0)
}
pub fn column_totals(&self) -> Vec<f64> {
(0..self.max_data_len())
.map(|i| {
self.series
.iter()
.map(|s| s.data.get(i).copied().unwrap_or(0.0))
.sum()
})
.collect()
}
pub fn bar_column_spans(&self, data_idx: usize) -> Vec<(usize, f64, f64)> {
if !self.kind.is_bar() {
return Vec::new();
}
self.bar_column_spans_in(data_idx, self.effective_y_range())
}
pub fn bar_column_spans_all(&self) -> Vec<Vec<(usize, f64, f64)>> {
if !self.kind.is_bar() {
return Vec::new();
}
let y_range = self.effective_y_range();
(0..self.max_data_len())
.map(|di| self.bar_column_spans_in(di, y_range))
.collect()
}
fn bar_column_spans_in(
&self,
data_idx: usize,
(y_min, y_max): (f64, f64),
) -> Vec<(usize, f64, f64)> {
let range = y_max - y_min;
let norm = |v: f64| {
if range > 0.0 {
((v - y_min) / range).clamp(0.0, 1.0)
} else {
0.5
}
};
let mut spans = Vec::with_capacity(self.series.len());
if self.kind.is_stacked_bar() && self.series.len() > 1 {
let mut cum = 0.0;
for (si, s) in self.series.iter().enumerate() {
let next = cum + s.data.get(data_idx).copied().unwrap_or(0.0);
let (a, b) = (norm(cum), norm(next));
spans.push((si, a.min(b), a.max(b)));
cum = next;
}
} else {
for (si, s) in self.series.iter().enumerate() {
let top = s.data.get(data_idx).map(|&v| norm(v)).unwrap_or(0.0);
spans.push((si, 0.0, top));
}
}
spans
}
pub fn layout(&self, origin_x: f32, origin_y: f32, measure: ChartMeasure) -> ChartLayout {
let bounds = Rect::new(origin_x, origin_y, measure.width, measure.height);
match self.kind {
ChartKind::Sparkline => {
let hit_regions = vec![(bounds, ChartHit::Body(self.id))];
let mut data_point_positions = Vec::new();
if let Some(s) = self.series.first() {
let (y_min, y_max) = self.effective_y_range();
let range = y_max - y_min;
let pw = measure.width;
let n = s.data.len();
let start = n.saturating_sub(pw as usize);
for (i, &val) in s.data[start..].iter().enumerate() {
let norm = if range > 0.0 {
((val - y_min) / range).clamp(0.0, 1.0)
} else {
0.5
};
let sx = origin_x + i as f32;
let sy = origin_y + (1.0 - norm as f32) * measure.height;
data_point_positions.push((0, start + i, sx, sy));
}
}
ChartLayout {
bounds,
plot_area: bounds,
legend_bounds: None,
hit_regions,
data_point_positions,
y_tick_positions: Vec::new(),
x_tick_positions: Vec::new(),
}
}
ChartKind::Line | ChartKind::Bar | ChartKind::BarGrouped => {
let (y_min, y_max) = self.effective_y_range();
let range = y_max - y_min;
let y_tick_count = self.y_ticks.unwrap_or(5);
let x_tick_count = self.x_ticks.unwrap_or(0);
let x_ticks_height = if x_tick_count > 0 {
measure.line_height
} else {
0.0
};
let x_label_height = if self.x_label.is_some() {
measure.line_height
} else {
0.0
};
let legend_height = if self.show_legend && !self.series.is_empty() {
measure.line_height
} else {
0.0
};
let plot_h_avail =
(measure.height - x_ticks_height - x_label_height - legend_height).max(0.0);
let y_label_width = if y_tick_count > 0 || self.y_label.is_some() {
let mut max_label_len = if y_tick_count > 0 && plot_h_avail > 0.0 && range > 0.0
{
(0..=y_tick_count)
.map(|i| {
let frac = i as f64 / y_tick_count as f64;
format_tick_value(y_min + frac * range).len()
})
.max()
.unwrap_or(0)
} else {
format_tick_value(y_max)
.len()
.max(format_tick_value(y_min).len())
};
if let Some(label) = &self.y_label {
max_label_len = max_label_len.max(label.len());
}
measure.char_width * (max_label_len as f32 + 1.0)
} else {
0.0
};
let plot_x = origin_x + y_label_width;
let plot_y = origin_y + legend_height;
let plot_w = (measure.width - y_label_width).max(0.0);
let plot_h = plot_h_avail;
let plot_area = Rect::new(plot_x, plot_y, plot_w, plot_h);
let legend_bounds = if legend_height > 0.0 {
Some(Rect::new(plot_x, origin_y, plot_w, legend_height))
} else {
None
};
let mut hit_regions = Vec::new();
if let Some(lb) = legend_bounds {
let entry_w = if self.series.is_empty() {
0.0
} else {
(plot_w / self.series.len() as f32).max(1.0)
};
for (i, _) in self.series.iter().enumerate() {
let ex = lb.x + entry_w * i as f32;
let ew = if i + 1 == self.series.len() {
lb.x + lb.width - ex
} else {
entry_w
};
hit_regions.push((
Rect::new(ex, lb.y, ew, lb.height),
ChartHit::Legend(self.id, i),
));
}
}
hit_regions.push((plot_area, ChartHit::Body(self.id)));
let mut data_point_positions = Vec::new();
if self.kind.is_bar() {
let n = self.max_data_len();
let slot_w = if n == 0 { 0.0 } else { plot_w / n as f32 };
let series_count = self.series.len().max(1) as f32;
let stacked = self.kind.is_stacked_bar();
for (di, column) in self.bar_column_spans_all().into_iter().enumerate() {
for (si, bottom, top) in column {
let (sx, sy) = if stacked {
let mid = (bottom + top) / 2.0;
(
plot_x + slot_w * (di as f32 + 0.5),
plot_y + plot_h - mid as f32 * plot_h,
)
} else {
let sub_w = slot_w / series_count;
(
plot_x + slot_w * di as f32 + sub_w * (si as f32 + 0.5),
plot_y + plot_h - top as f32 * plot_h,
)
};
data_point_positions.push((si, di, sx, sy));
}
}
} else {
for (si, s) in self.series.iter().enumerate() {
let n = s.data.len();
for (di, &val) in s.data.iter().enumerate() {
let norm = if range > 0.0 {
((val - y_min) / range).clamp(0.0, 1.0)
} else {
0.5
};
let sx = if n <= 1 {
plot_x
} else {
plot_x + (di as f32 / (n - 1) as f32) * plot_w
};
let sy = plot_y + plot_h - norm as f32 * plot_h;
data_point_positions.push((si, di, sx, sy));
}
}
}
let mut y_tick_positions = Vec::new();
if y_tick_count > 0 && plot_h > 0.0 && range > 0.0 {
for i in 0..=y_tick_count {
let frac = i as f64 / y_tick_count as f64;
let val = y_min + frac * range;
let sy = plot_y + plot_h - frac as f32 * plot_h;
y_tick_positions.push((sy, val));
}
}
let data_len = self.max_data_len();
let mut x_tick_positions = Vec::new();
if x_tick_count > 0 && plot_w > 0.0 && data_len > 1 {
let (x_min, x_max) = self.x_range.unwrap_or((0.0, (data_len - 1) as f64));
let x_span = x_max - x_min;
for i in 0..=x_tick_count {
let frac = i as f64 / x_tick_count as f64;
let val = x_min + frac * x_span;
let sx = plot_x + frac as f32 * plot_w;
x_tick_positions.push((sx, val));
}
}
ChartLayout {
bounds,
plot_area,
legend_bounds,
hit_regions,
data_point_positions,
y_tick_positions,
x_tick_positions,
}
}
}
}
}
pub fn format_tick_value(v: f64) -> String {
if (v - v.round()).abs() < 0.01 {
format!("{}", v as i64)
} else {
format!("{:.1}", v)
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
mod native_surface_paint {
use super::{Chart, ChartKind, ChartLayout};
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
use crate::types::Color;
use crate::Rect;
pub(crate) const SERIES_COLORS: [Color; 6] = crate::theme::CHART_SERIES;
fn series_color(chart: &Chart, idx: usize, theme: &Theme) -> Color {
let palette = theme.chart_series();
chart
.series
.get(idx)
.and_then(|s| s.color)
.unwrap_or(palette[idx % palette.len()])
}
fn stroke_polyline(
surface: &mut dyn PaintSurface,
points: &[(f32, f32)],
color: Color,
stroke_width: f32,
) {
for pair in points.windows(2) {
let (from, to) = (pair[0], pair[1]);
surface.surface_draw_line(
crate::Point::new(from.0, from.1),
crate::Point::new(to.0, to.1),
color,
stroke_width,
);
}
}
pub(crate) fn paint(
chart: &Chart,
layout: &ChartLayout,
surface: &mut dyn PaintSurface,
theme: &Theme,
hovered_point: Option<(usize, usize)>,
crosshair_x: Option<f64>,
) {
let b = layout.bounds;
if b.width <= 0.0 || b.height <= 0.0 {
return;
}
surface.surface_push_clip(b);
match chart.kind {
ChartKind::Sparkline => paint_sparkline(surface, layout, chart, theme),
ChartKind::Line => paint_line(surface, layout, chart, theme),
ChartKind::Bar | ChartKind::BarGrouped => paint_bar(surface, layout, chart, theme),
}
if let Some(data_x) = crosshair_x {
paint_crosshair(surface, layout, chart, theme, data_x);
}
if let Some((si, di)) = hovered_point {
paint_hover_marker(surface, layout, si, di, chart, theme);
}
surface.surface_pop_clip();
}
fn paint_sparkline(
surface: &mut dyn PaintSurface,
layout: &ChartLayout,
chart: &Chart,
theme: &Theme,
) {
let pa = layout.plot_area;
surface.surface_fill_rect(pa, theme.background);
let Some(s) = chart.series.first() else {
return;
};
if s.data.is_empty() || pa.width <= 0.0 || pa.height <= 0.0 {
return;
}
let (y_min, y_max) = chart.effective_y_range();
let range = y_max - y_min;
let color = series_color(chart, 0, theme);
let n = s.data.len();
let points: Vec<(f32, f32)> = s
.data
.iter()
.enumerate()
.map(|(i, &val)| {
let norm = if range > 0.0 {
((val - y_min) / range).clamp(0.0, 1.0)
} else {
0.5
};
let sx = pa.x
+ if n <= 1 {
0.0
} else {
(i as f32 / (n - 1) as f32) * pa.width
};
let sy = pa.y + pa.height - norm as f32 * pa.height;
(sx, sy)
})
.collect();
stroke_polyline(surface, &points, color, 1.5);
}
fn paint_line(
surface: &mut dyn PaintSurface,
layout: &ChartLayout,
chart: &Chart,
theme: &Theme,
) {
let pa = layout.plot_area;
surface.surface_fill_rect(pa, theme.background);
if pa.width <= 0.0 || pa.height <= 0.0 {
return;
}
surface.surface_draw_line(
crate::Point::new(pa.x, pa.y),
crate::Point::new(pa.x, pa.y + pa.height),
theme.muted_fg,
1.0,
);
surface.surface_draw_line(
crate::Point::new(pa.x, pa.y + pa.height),
crate::Point::new(pa.x + pa.width, pa.y + pa.height),
theme.muted_fg,
1.0,
);
for (si, s) in chart.series.iter().enumerate() {
if s.data.is_empty() {
continue;
}
let color = series_color(chart, si, theme);
let points: Vec<(f32, f32)> = layout
.data_point_positions
.iter()
.filter_map(|&(pt_si, _, x, y)| if pt_si == si { Some((x, y)) } else { None })
.collect();
stroke_polyline(surface, &points, color, 2.0);
}
paint_legend(surface, layout, chart, theme);
paint_axis_labels(surface, layout, chart, theme);
}
fn paint_bar(
surface: &mut dyn PaintSurface,
layout: &ChartLayout,
chart: &Chart,
theme: &Theme,
) {
let pa = layout.plot_area;
surface.surface_fill_rect(pa, theme.background);
if pa.width <= 0.0 || pa.height <= 0.0 {
return;
}
let n = chart.max_data_len();
if n > 0 {
let slot_w = pa.width / n as f32;
let gap = (slot_w * 0.15).max(1.0);
let bar_w = (slot_w - gap).max(1.0);
let stacked = chart.kind.is_stacked_bar();
let series_count = chart.series.len().max(1) as f32;
let baseline = pa.y + pa.height;
for (i, column) in chart.bar_column_spans_all().into_iter().enumerate() {
let slot_x = pa.x + i as f32 * slot_w + gap / 2.0;
for (si, bottom, top) in column {
let (bx, seg_w) = if stacked {
(slot_x, bar_w)
} else {
let sub_w = (bar_w / series_count).max(1.0);
(slot_x + si as f32 * sub_w, sub_w)
};
let seg_h = (top - bottom) as f32 * pa.height;
if seg_h <= 0.0 {
continue;
}
let by = baseline - top as f32 * pa.height;
surface.surface_fill_rect(
Rect::new(bx, by, seg_w, seg_h),
series_color(chart, si, theme),
);
}
}
surface.surface_draw_line(
crate::Point::new(pa.x, baseline),
crate::Point::new(pa.x + pa.width, baseline),
theme.muted_fg,
1.0,
);
}
paint_legend(surface, layout, chart, theme);
paint_axis_labels(surface, layout, chart, theme);
}
fn paint_legend(
surface: &mut dyn PaintSurface,
layout: &ChartLayout,
chart: &Chart,
theme: &Theme,
) {
let Some(lb) = layout.legend_bounds else {
return;
};
surface.surface_fill_rect(lb, theme.background);
let mut cx = lb.x + 2.0;
for (i, s) in chart.series.iter().enumerate() {
let color = series_color(chart, i, theme);
let swatch = lb.height * 0.6;
let sy = lb.y + (lb.height - swatch) / 2.0;
surface.surface_fill_rect(Rect::new(cx, sy, swatch, swatch), color);
cx += swatch + 4.0;
let (tw, th) = surface.surface_measure_text(&s.label);
surface.surface_draw_text_run(
Rect::new(cx, lb.y, tw, th.max(lb.height)),
&s.label,
theme.foreground,
);
cx += tw + 12.0;
}
}
fn paint_axis_labels(
surface: &mut dyn PaintSurface,
layout: &ChartLayout,
chart: &Chart,
theme: &Theme,
) {
let pa = layout.plot_area;
for &(sy, val) in &layout.y_tick_positions {
let label = super::format_tick_value(val);
let (tw, th) = surface.surface_measure_text(&label);
surface.surface_draw_text_run(
Rect::new(pa.x - tw - 4.0, sy - th / 2.0, tw, th),
&label,
theme.muted_fg,
);
if chart.show_grid && sy > pa.y && sy < pa.y + pa.height {
surface.surface_draw_line(
crate::Point::new(pa.x, sy),
crate::Point::new(pa.x + pa.width, sy),
theme.separator,
0.5,
);
}
}
let bounds = layout.bounds;
let x_ticks_height = if !layout.x_tick_positions.is_empty() {
surface.surface_line_height()
} else {
0.0
};
let mut prev_label_end: Option<f32> = None;
for &(sx, val) in &layout.x_tick_positions {
let label = super::format_tick_value(val);
let (tw, th) = surface.surface_measure_text(&label);
if tw > bounds.width {
continue;
}
let max_x = bounds.x + bounds.width - tw;
let lx = (sx - tw / 2.0).clamp(bounds.x, max_x);
if let Some(prev_end) = prev_label_end {
if lx < prev_end + 4.0 {
continue;
}
}
surface.surface_draw_text_run(
Rect::new(lx, pa.y + pa.height, tw, th),
&label,
theme.muted_fg,
);
prev_label_end = Some(lx + tw);
}
if let Some(label) = &chart.x_label {
let (tw, th) = surface.surface_measure_text(label);
let cx = pa.x + (pa.width - tw) / 2.0;
let cy = pa.y + pa.height + x_ticks_height;
surface.surface_draw_text_run(Rect::new(cx, cy, tw, th), label, theme.foreground);
}
if let Some(label) = &chart.y_label {
let (tw, th) = surface.surface_measure_text(label);
surface.surface_draw_text_run(
Rect::new(layout.bounds.x, pa.y, tw, th),
label,
theme.foreground,
);
}
}
fn paint_crosshair(
surface: &mut dyn PaintSurface,
layout: &ChartLayout,
chart: &Chart,
theme: &Theme,
data_x: f64,
) {
let data_len = chart.max_data_len();
let screen_x = layout.data_to_screen_x(data_x, data_len);
let pa = layout.plot_area;
if screen_x <= pa.x || screen_x >= pa.x + pa.width {
return;
}
let line_color = theme.background.blend(theme.muted_fg, 0.5);
surface.surface_draw_line(
crate::Point::new(screen_x, pa.y),
crate::Point::new(screen_x, pa.y + pa.height),
line_color,
1.0,
);
let (y_min, y_max) = chart.effective_y_range();
let range = y_max - y_min;
for (si, s) in chart.series.iter().enumerate() {
if s.data.is_empty() {
continue;
}
let idx = data_x.round() as usize;
let Some(&val) = s.data.get(idx) else {
continue;
};
let label = super::format_tick_value(val);
let color = series_color(chart, si, theme);
let norm = if range > 0.0 {
((val - y_min) / range).clamp(0.0, 1.0)
} else {
0.5
};
let sy = pa.y + pa.height - norm as f32 * pa.height;
let (tw, th) = surface.surface_measure_text(&label);
surface.surface_draw_text_run(
Rect::new(screen_x + 4.0, sy - 8.0, tw, th),
&label,
color,
);
}
}
fn paint_hover_marker(
surface: &mut dyn PaintSurface,
layout: &ChartLayout,
series_idx: usize,
data_idx: usize,
chart: &Chart,
theme: &Theme,
) {
let Some(&(_, _, sx, sy)) = layout
.data_point_positions
.iter()
.find(|&&(si, di, _, _)| si == series_idx && di == data_idx)
else {
return;
};
let color = series_color(chart, series_idx, theme);
let inner = 5.0_f32;
surface.surface_fill_rect(
Rect::new(sx - inner, sy - inner, inner * 2.0, inner * 2.0),
color,
);
let outer_color = theme.background.blend(color, 0.3);
let outer = 8.0_f32;
surface.surface_fill_rect(
Rect::new(sx - outer, sy - outer, outer * 2.0, outer * 2.0),
outer_color,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::chart::Series;
use crate::types::WidgetId;
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
lines: Vec<(crate::Point, crate::Point, Color, f32)>,
texts: Vec<(Rect, String, Color)>,
clip_pushes: Vec<Rect>,
clip_pops: usize,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.texts.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
from: crate::Point,
to: crate::Point,
color: Color,
stroke_width: f32,
) {
self.lines.push((from, to, color, stroke_width));
}
fn surface_push_clip(&mut self, rect: Rect) {
self.clip_pushes.push(rect);
}
fn surface_pop_clip(&mut self) {
self.clip_pops += 1;
}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn line_chart(data: Vec<f64>) -> Chart {
Chart {
id: WidgetId::new("chart"),
kind: ChartKind::Line,
series: vec![Series {
label: "a".into(),
data,
color: None,
fill: false,
}],
x_label: None,
y_label: None,
y_range: None,
x_range: None,
show_legend: false,
y_ticks: Some(0),
x_ticks: Some(0),
show_grid: false,
}
}
fn layout_for(chart: &Chart) -> ChartLayout {
chart.layout(
0.0,
0.0,
crate::primitives::chart::ChartMeasure {
width: 100.0,
height: 50.0,
char_width: 8.0,
line_height: 16.0,
},
)
}
#[test]
fn paint_clips_to_the_charts_own_bounds() {
let chart = line_chart(vec![1.0, 2.0, 3.0]);
let layout = layout_for(&chart);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&chart, &layout, &mut surface, &theme, None, None);
assert_eq!(
surface.clip_pushes,
vec![layout.bounds],
"paint must push exactly one clip, at the chart's own bounds"
);
assert_eq!(
surface.clip_pops, 1,
"every surface_push_clip must be balanced by surface_pop_clip"
);
}
#[test]
fn zero_size_chart_paints_nothing_and_never_clips() {
let chart = line_chart(vec![1.0, 2.0]);
let layout = ChartLayout {
bounds: Rect::new(0.0, 0.0, 0.0, 0.0),
plot_area: Rect::new(0.0, 0.0, 0.0, 0.0),
legend_bounds: None,
hit_regions: Vec::new(),
data_point_positions: Vec::new(),
y_tick_positions: Vec::new(),
x_tick_positions: Vec::new(),
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&chart, &layout, &mut surface, &theme, None, None);
assert!(surface.clip_pushes.is_empty());
assert_eq!(surface.clip_pops, 0);
assert!(surface.fills.is_empty());
}
fn line_chart_with_x_ticks(
data: Vec<f64>,
x_range: Option<(f64, f64)>,
x_ticks: usize,
) -> Chart {
Chart {
id: WidgetId::new("chart"),
kind: ChartKind::Line,
series: vec![Series {
label: "a".into(),
data,
color: None,
fill: false,
}],
x_label: None,
y_label: None,
y_range: None,
x_range,
show_legend: false,
y_ticks: Some(0),
x_ticks: Some(x_ticks),
show_grid: false,
}
}
#[test]
fn x_axis_ticks_paint_text_runs_with_their_values() {
let chart = line_chart_with_x_ticks(vec![1.0, 2.0, 3.0], None, 1);
let layout = layout_for(&chart);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&chart, &layout, &mut surface, &theme, None, None);
let labels: Vec<&str> = surface.texts.iter().map(|(_, t, _)| t.as_str()).collect();
assert!(
labels.contains(&"0") && labels.contains(&"2"),
"expected x-tick labels for data indices 0 and 2, got {labels:?}"
);
}
#[test]
fn x_axis_tick_values_honour_x_range() {
let chart = line_chart_with_x_ticks(vec![1.0, 2.0, 3.0], Some((100.0, 300.0)), 1);
let layout = layout_for(&chart);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&chart, &layout, &mut surface, &theme, None, None);
let labels: Vec<&str> = surface.texts.iter().map(|(_, t, _)| t.as_str()).collect();
assert!(
labels.contains(&"100") && labels.contains(&"300"),
"expected x_range-derived labels, got {labels:?}"
);
assert!(
!labels.contains(&"0") && !labels.contains(&"2"),
"index-based labels should not appear once x_range is set: {labels:?}"
);
}
#[test]
fn line_series_paints_with_its_resolved_color() {
let chart = line_chart(vec![1.0, 4.0, 2.0]);
let layout = layout_for(&chart);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&chart, &layout, &mut surface, &theme, None, None);
assert!(
surface
.lines
.iter()
.any(|&(_, _, c, _)| c == SERIES_COLORS[0]),
"expected at least one line segment in the series' resolved colour, got {:?}",
surface.lines,
);
}
fn uncoloured_multi_series_chart(n: usize) -> Chart {
Chart {
id: WidgetId::new("chart"),
kind: ChartKind::Line,
series: (0..n)
.map(|i| Series {
label: format!("s{i}"),
data: vec![1.0, 4.0, 2.0],
color: None,
fill: false,
})
.collect(),
x_label: None,
y_label: None,
y_range: None,
x_range: None,
show_legend: false,
y_ticks: Some(0),
x_ticks: Some(0),
show_grid: false,
}
}
#[test]
fn uncoloured_series_cycle_through_the_themes_chart_series_palette() {
let chart = uncoloured_multi_series_chart(8);
let layout = layout_for(&chart);
let theme = Theme::default();
let palette = theme.chart_series();
let mut surface = RecordingSurface::default();
paint(&chart, &layout, &mut surface, &theme, None, None);
let painted: Vec<Color> = surface.lines.iter().map(|&(_, _, c, _)| c).collect();
assert!(
!painted.is_empty(),
"fixture painted no line segments at all"
);
for expected in &palette {
assert!(
painted.contains(expected),
"8 uncoloured series should cover all 6 palette entries; {expected:?} never appeared in {painted:?}",
);
}
}
#[test]
fn explicit_series_color_wins_over_the_theme_palette() {
let mut chart = uncoloured_multi_series_chart(1);
let custom = Color::rgb(1, 2, 3);
assert!(
!Theme::default().chart_series().contains(&custom),
"test fixture invalid: custom colour must not be in the palette"
);
chart.series[0].color = Some(custom);
let layout = layout_for(&chart);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&chart, &layout, &mut surface, &theme, None, None);
assert!(
surface.lines.iter().any(|&(_, _, c, _)| c == custom),
"expected the line to paint with the series' own colour, got {:?}",
surface.lines,
);
assert!(
surface
.lines
.iter()
.all(|&(_, _, c, _)| c != theme.chart_series()[0]),
"palette entry 0 must not be used once the series sets its own colour, got {:?}",
surface.lines,
);
}
#[test]
fn hover_marker_paints_two_nested_fills_at_the_data_point() {
let chart = line_chart(vec![1.0, 4.0, 2.0]);
let layout = layout_for(&chart);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&chart, &layout, &mut surface, &theme, Some((0, 1)), None);
let (_, _, sx, sy) = layout.data_point_positions[1];
let fills_at_point = surface
.fills
.iter()
.filter(|(r, _)| {
r.width <= 16.0
&& r.height <= 16.0
&& r.x <= sx
&& sx <= r.x + r.width
&& r.y <= sy
&& sy <= r.y + r.height
})
.count();
assert_eq!(
fills_at_point, 2,
"hover marker should paint two nested fills (inner + outer ring) centred on the data point"
);
}
#[test]
fn hover_marker_outer_ring_blends_against_the_live_theme_background() {
let chart = line_chart(vec![1.0, 4.0, 2.0]);
let layout = layout_for(&chart);
let light_background = Color::rgb(240, 240, 240);
let theme = Theme {
background: light_background,
..Theme::default()
};
let mut surface = RecordingSurface::default();
paint(&chart, &layout, &mut surface, &theme, Some((0, 1)), None);
let (_, _, sx, sy) = layout.data_point_positions[1];
let series_color = SERIES_COLORS[0];
let expected_outer = light_background.blend(series_color, 0.3);
let wrong_outer_from_default_theme =
Theme::default().background.blend(series_color, 0.3);
assert_ne!(
expected_outer, wrong_outer_from_default_theme,
"test fixture invalid: light and default-theme backgrounds must blend to different colours"
);
let outer_ring_fill = surface
.fills
.iter()
.filter(|(r, _)| {
r.width <= 16.0
&& r.height <= 16.0
&& r.width > 10.0
&& r.x <= sx
&& sx <= r.x + r.width
&& r.y <= sy
&& sy <= r.y + r.height
})
.map(|(_, c)| *c)
.next();
assert_eq!(
outer_ring_fill,
Some(expected_outer),
"outer ring should blend the series colour against the live theme's background, not Theme::default()'s"
);
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(unused_imports)]
pub(crate) use native_surface_paint::{paint, SERIES_COLORS};
#[cfg(test)]
mod tests {
use super::*;
use crate::types::WidgetId;
fn sparkline_chart(data: Vec<f64>) -> Chart {
Chart {
id: WidgetId::new("chart"),
kind: ChartKind::Sparkline,
series: vec![Series {
label: String::new(),
data,
color: None,
fill: false,
}],
x_label: None,
y_label: None,
y_range: None,
x_range: None,
show_legend: false,
y_ticks: None,
x_ticks: None,
show_grid: false,
}
}
fn line_chart(data: Vec<f64>) -> Chart {
Chart {
id: WidgetId::new("chart"),
kind: ChartKind::Line,
series: vec![Series {
label: "Series A".into(),
data,
color: None,
fill: false,
}],
x_label: Some("Time".into()),
y_label: Some("Value".into()),
y_range: None,
x_range: None,
show_legend: true,
y_ticks: None,
x_ticks: None,
show_grid: false,
}
}
#[test]
fn sparkline_layout_fills_bounds() {
let chart = sparkline_chart(vec![1.0, 2.0, 3.0]);
let m = ChartMeasure {
width: 20.0,
height: 1.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
assert_eq!(layout.plot_area, layout.bounds);
assert!(layout.legend_bounds.is_none());
}
#[test]
fn sparkline_hit_test_body() {
let chart = sparkline_chart(vec![1.0, 2.0]);
let m = ChartMeasure {
width: 10.0,
height: 1.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
assert_eq!(
layout.hit_test(5.0, 0.5),
ChartHit::Body(WidgetId::new("chart"))
);
assert_eq!(layout.hit_test(15.0, 0.5), ChartHit::Empty);
}
#[test]
fn line_layout_subtracts_axes_and_legend() {
let chart = line_chart(vec![1.0, 2.0, 3.0]);
let m = ChartMeasure {
width: 40.0,
height: 20.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
assert!(layout.plot_area.x > 0.0, "y-label shifts plot right");
assert!(
layout.plot_area.height < 20.0,
"x-label + legend reduce height"
);
assert!(layout.legend_bounds.is_some());
}
fn line_chart_with_ticks(y_range: (f64, f64), y_ticks: usize) -> Chart {
Chart {
id: WidgetId::new("chart"),
kind: ChartKind::Line,
series: vec![Series {
label: "S".into(),
data: vec![1.0, 2.0],
color: None,
fill: false,
}],
x_label: None,
y_label: None,
y_range: Some(y_range),
x_range: None,
show_legend: false,
y_ticks: Some(y_ticks),
x_ticks: None,
show_grid: false,
}
}
#[test]
fn y_gutter_sizes_from_interior_tick_labels_not_just_endpoints() {
let chart = line_chart_with_ticks((0.0, 18.0), 5);
let m = ChartMeasure {
width: 40.0,
height: 20.0,
char_width: 1.0,
line_height: 1.0,
};
let bounds_x = 2.0;
let layout = chart.layout(bounds_x, 0.0, m);
assert!(
layout.plot_area.x - bounds_x >= 5.0,
"gutter should reserve the 4-char interior label + 1 padding: \
plot_area.x={}, bounds.x={bounds_x}",
layout.plot_area.x
);
}
#[test]
fn y_label_longer_than_tick_labels_widens_the_gutter() {
let mut chart = line_chart_with_ticks((0.0, 18.0), 5);
let long_label = "Merges per bucket"; chart.y_label = Some(long_label.into());
let m = ChartMeasure {
width: 60.0,
height: 20.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
let expected_min = long_label.len() as f32 + 1.0;
assert!(
layout.plot_area.x >= expected_min,
"gutter should widen to fit the y_label in full: \
plot_area.x={}, expected >= {expected_min}",
layout.plot_area.x
);
}
fn line_chart_with_x_ticks(
data: Vec<f64>,
x_range: Option<(f64, f64)>,
x_ticks: usize,
) -> Chart {
Chart {
id: WidgetId::new("chart"),
kind: ChartKind::Line,
series: vec![Series {
label: "S".into(),
data,
color: None,
fill: false,
}],
x_label: None,
y_label: None,
y_range: None,
x_range,
show_legend: false,
y_ticks: Some(0),
x_ticks: Some(x_ticks),
show_grid: false,
}
}
#[test]
fn x_ticks_default_to_data_index_when_x_range_is_none() {
let chart = line_chart_with_x_ticks(vec![1.0, 2.0, 3.0, 4.0, 5.0], None, 2);
let m = ChartMeasure {
width: 30.0,
height: 20.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
let values: Vec<f64> = layout.x_tick_positions.iter().map(|&(_, v)| v).collect();
assert_eq!(values, vec![0.0, 2.0, 4.0]);
}
#[test]
fn x_ticks_use_x_range_when_set() {
let chart = line_chart_with_x_ticks(vec![1.0, 2.0, 3.0], Some((100.0, 300.0)), 1);
let m = ChartMeasure {
width: 30.0,
height: 20.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
let values: Vec<f64> = layout.x_tick_positions.iter().map(|&(_, v)| v).collect();
assert_eq!(
values,
vec![100.0, 300.0],
"x_range must drive tick values instead of the data index"
);
}
#[test]
fn x_ticks_are_empty_when_x_ticks_field_is_none() {
let mut chart = line_chart_with_x_ticks(vec![1.0, 2.0, 3.0], None, 0);
chart.x_ticks = None;
let m = ChartMeasure {
width: 30.0,
height: 20.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
assert!(layout.x_tick_positions.is_empty());
}
#[test]
fn x_ticks_reserve_a_plot_height_row_when_enabled() {
let with_ticks = line_chart_with_x_ticks(vec![1.0, 2.0, 3.0], None, 2);
let mut without_ticks = with_ticks.clone();
without_ticks.x_ticks = None;
let m = ChartMeasure {
width: 30.0,
height: 20.0,
char_width: 1.0,
line_height: 1.0,
};
let layout_with = with_ticks.layout(0.0, 0.0, m);
let layout_without = without_ticks.layout(0.0, 0.0, m);
assert_eq!(
layout_without.plot_area.height - layout_with.plot_area.height,
m.line_height,
"enabling x_ticks should shrink the plot by exactly one tick row"
);
}
#[test]
fn line_legend_hit_test() {
let mut chart = line_chart(vec![1.0, 2.0]);
chart.series.push(Series {
label: "Series B".into(),
data: vec![3.0, 4.0],
color: None,
fill: false,
});
let m = ChartMeasure {
width: 40.0,
height: 20.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
let lb = layout.legend_bounds.unwrap();
let mid_x = lb.x + lb.width / 4.0;
assert_eq!(
layout.hit_test(mid_x, lb.y + 0.5),
ChartHit::Legend(WidgetId::new("chart"), 0)
);
let mid_x2 = lb.x + lb.width * 3.0 / 4.0;
assert_eq!(
layout.hit_test(mid_x2, lb.y + 0.5),
ChartHit::Legend(WidgetId::new("chart"), 1)
);
}
#[test]
fn effective_y_range_auto() {
let chart = sparkline_chart(vec![2.0, 5.0, 3.0]);
assert_eq!(chart.effective_y_range(), (2.0, 5.0));
}
#[test]
fn effective_y_range_explicit() {
let mut chart = sparkline_chart(vec![2.0, 5.0]);
chart.y_range = Some((0.0, 10.0));
assert_eq!(chart.effective_y_range(), (0.0, 10.0));
}
#[test]
fn effective_y_range_empty() {
let chart = sparkline_chart(vec![]);
assert_eq!(chart.effective_y_range(), (0.0, 1.0));
}
#[test]
fn effective_y_range_flat() {
let chart = sparkline_chart(vec![5.0, 5.0, 5.0]);
let (lo, hi) = chart.effective_y_range();
assert!(lo < 5.0 && hi > 5.0);
}
fn bar_chart(kind: ChartKind, data: Vec<Vec<f64>>) -> Chart {
Chart {
id: WidgetId::new("chart"),
kind,
series: data
.into_iter()
.enumerate()
.map(|(i, d)| Series {
label: format!("S{i}"),
data: d,
color: None,
fill: false,
})
.collect(),
x_label: None,
y_label: None,
y_range: None,
x_range: None,
show_legend: false,
y_ticks: None,
x_ticks: None,
show_grid: false,
}
}
#[test]
fn stacked_bar_y_ceiling_is_max_column_total() {
let chart = bar_chart(
ChartKind::Bar,
vec![vec![1.0, 5.0], vec![2.0, 1.0], vec![3.0, 0.0]],
);
assert_eq!(chart.column_totals(), vec![6.0, 6.0]);
assert_eq!(chart.effective_y_range(), (0.0, 6.0));
}
#[test]
fn grouped_bar_y_ceiling_is_max_single_value() {
let chart = bar_chart(
ChartKind::BarGrouped,
vec![vec![1.0, 5.0], vec![2.0, 1.0], vec![3.0, 0.0]],
);
assert_eq!(chart.effective_y_range(), (0.0, 5.0));
}
#[test]
fn single_series_bar_y_range_is_unchanged_by_stacking() {
let chart = bar_chart(ChartKind::Bar, vec![vec![2.0, 5.0, 3.0]]);
assert_eq!(chart.effective_y_range(), (2.0, 5.0));
}
#[test]
fn stacked_bar_floor_anchors_at_zero_when_min_value_is_nonzero() {
let chart = bar_chart(ChartKind::Bar, vec![vec![10.0], vec![5.0]]);
assert_eq!(chart.effective_y_range(), (0.0, 15.0));
let spans = chart.bar_column_spans(0);
assert!((spans[0].1 - 0.0).abs() < 1e-9);
assert!((spans[0].2 - 2.0 / 3.0).abs() < 1e-9, "A span: {spans:?}");
assert!((spans[1].1 - 2.0 / 3.0).abs() < 1e-9);
assert!((spans[1].2 - 1.0).abs() < 1e-9, "B span: {spans:?}");
}
#[test]
fn stacked_bar_floor_tracks_negative_partial_sums_not_just_totals() {
let chart = bar_chart(ChartKind::Bar, vec![vec![10.0], vec![-15.0], vec![20.0]]);
assert_eq!(chart.effective_y_range(), (-5.0, 15.0));
}
#[test]
fn bar_column_spans_baseline_tracks_a_nonzero_explicit_floor() {
let mut chart = bar_chart(ChartKind::Bar, vec![vec![10.0], vec![10.0]]);
chart.y_range = Some((-10.0, 30.0));
let spans = chart.bar_column_spans(0);
assert!((spans[0].1 - 0.25).abs() < 1e-9, "spans: {spans:?}");
}
#[test]
fn single_series_bar_spans_rise_from_the_plot_floor_for_negative_data() {
let chart = bar_chart(ChartKind::Bar, vec![vec![-5.0, -3.0, -1.0]]);
assert_eq!(chart.effective_y_range(), (-5.0, -1.0));
for (di, expected_top) in [0.0, 0.5, 1.0].into_iter().enumerate() {
let spans = chart.bar_column_spans(di);
assert_eq!(spans.len(), 1);
assert!((spans[0].1 - 0.0).abs() < 1e-9, "col {di}: {spans:?}");
assert!(
(spans[0].2 - expected_top).abs() < 1e-9,
"col {di}: {spans:?}"
);
}
}
#[test]
fn single_series_bar_spans_are_unshifted_for_mixed_sign_data() {
let chart = bar_chart(ChartKind::Bar, vec![vec![-2.0, 3.0]]);
assert_eq!(chart.effective_y_range(), (-2.0, 3.0));
assert_eq!(chart.bar_column_spans(0), vec![(0, 0.0, 0.0)]);
assert_eq!(chart.bar_column_spans(1), vec![(0, 0.0, 1.0)]);
}
#[test]
fn stacked_spans_stay_visible_when_an_explicit_range_excludes_zero() {
let mut chart = bar_chart(ChartKind::Bar, vec![vec![-3.0], vec![-4.0]]);
chart.y_range = Some((-10.0, -2.0));
let spans = chart.bar_column_spans(0);
assert!((spans[0].1 - 0.875).abs() < 1e-9, "spans: {spans:?}");
assert!((spans[0].2 - 1.0).abs() < 1e-9, "spans: {spans:?}");
assert!((spans[1].1 - 0.375).abs() < 1e-9, "spans: {spans:?}");
assert!((spans[1].2 - 0.875).abs() < 1e-9, "spans: {spans:?}");
assert!(spans.iter().all(|(_, b, t)| t > b), "spans: {spans:?}");
}
#[test]
fn stacked_span_of_a_negative_series_walks_the_stack_back_down() {
let chart = bar_chart(ChartKind::Bar, vec![vec![10.0], vec![-15.0], vec![20.0]]);
assert_eq!(chart.effective_y_range(), (-5.0, 15.0));
let spans = chart.bar_column_spans(0);
assert_eq!(
spans,
vec![(0, 0.25, 0.75), (1, 0.0, 0.75), (2, 0.0, 1.0)],
"spans: {spans:?}"
);
}
#[test]
fn stacked_spans_accumulate_bottom_up() {
let mut chart = bar_chart(ChartKind::Bar, vec![vec![1.0], vec![1.0], vec![1.0]]);
chart.y_range = Some((0.0, 3.0));
let spans = chart.bar_column_spans(0);
assert_eq!(spans.len(), 3);
assert!((spans[0].1 - 0.0).abs() < 1e-9 && (spans[0].2 - 1.0 / 3.0).abs() < 1e-9);
assert!((spans[1].1 - 1.0 / 3.0).abs() < 1e-9 && (spans[1].2 - 2.0 / 3.0).abs() < 1e-9);
assert!((spans[2].1 - 2.0 / 3.0).abs() < 1e-9 && (spans[2].2 - 1.0).abs() < 1e-9);
}
#[test]
fn stacked_all_zero_series_does_not_shift_the_others() {
let mut with_zeros = bar_chart(
ChartKind::Bar,
vec![vec![1.0], vec![0.0], vec![1.0], vec![0.0]],
);
with_zeros.y_range = Some((0.0, 2.0));
let spans = with_zeros.bar_column_spans(0);
assert_eq!((spans[1].1, spans[1].2), (0.5, 0.5));
assert_eq!((spans[3].1, spans[3].2), (1.0, 1.0));
assert_eq!((spans[0].1, spans[0].2), (0.0, 0.5));
assert_eq!((spans[2].1, spans[2].2), (0.5, 1.0));
}
#[test]
fn grouped_spans_all_start_at_the_floor() {
let mut chart = bar_chart(ChartKind::BarGrouped, vec![vec![1.0], vec![2.0], vec![4.0]]);
chart.y_range = Some((0.0, 4.0));
let spans = chart.bar_column_spans(0);
assert_eq!(spans, vec![(0, 0.0, 0.25), (1, 0.0, 0.5), (2, 0.0, 1.0)]);
}
#[test]
fn bar_column_spans_empty_for_non_bar_kinds() {
let chart = sparkline_chart(vec![1.0, 2.0]);
assert!(chart.bar_column_spans(0).is_empty());
assert!(chart.bar_column_spans_all().is_empty());
}
#[test]
fn bar_column_spans_all_matches_the_per_column_helper() {
for kind in [ChartKind::Bar, ChartKind::BarGrouped] {
let chart = bar_chart(kind, vec![vec![1.0, 5.0, -2.0], vec![2.0, 0.0, 3.0]]);
let all = chart.bar_column_spans_all();
assert_eq!(all.len(), chart.max_data_len(), "{kind:?}");
for (di, column) in all.iter().enumerate() {
assert_eq!(*column, chart.bar_column_spans(di), "{kind:?} col {di}");
}
}
}
#[test]
fn stacked_bar_nearest_point_resolves_series_and_index() {
let mut chart = bar_chart(
ChartKind::Bar,
vec![vec![1.0, 1.0], vec![1.0, 1.0], vec![1.0, 1.0]],
);
chart.y_range = Some((0.0, 3.0));
let m = ChartMeasure {
width: 30.0,
height: 30.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
assert_eq!(layout.data_point_positions.len(), 6);
let pa = layout.plot_area;
let slot_w = pa.width / 2.0;
let x = pa.x + slot_w * 1.5;
let y = pa.y + pa.height / 6.0;
assert_eq!(layout.nearest_point(x, y, slot_w), Some((2, 1)));
let y_bottom = pa.y + pa.height * 5.0 / 6.0;
assert_eq!(
layout.nearest_point(pa.x + slot_w * 0.5, y_bottom, slot_w),
Some((0, 0))
);
}
#[test]
fn grouped_bar_anchors_sit_side_by_side() {
let mut chart = bar_chart(ChartKind::BarGrouped, vec![vec![1.0], vec![2.0], vec![3.0]]);
chart.y_range = Some((0.0, 3.0));
let m = ChartMeasure {
width: 30.0,
height: 30.0,
char_width: 1.0,
line_height: 1.0,
};
let layout = chart.layout(0.0, 0.0, m);
let xs: Vec<f32> = layout.data_point_positions.iter().map(|p| p.2).collect();
assert_eq!(xs.len(), 3);
assert!(xs[0] < xs[1] && xs[1] < xs[2], "sub-bars advance: {xs:?}");
let ys: Vec<f32> = layout.data_point_positions.iter().map(|p| p.3).collect();
assert!(ys[0] > ys[1] && ys[1] > ys[2], "bar tops rise: {ys:?}");
}
#[test]
fn bar_kind_predicates() {
assert!(ChartKind::Bar.is_bar() && ChartKind::Bar.is_stacked_bar());
assert!(ChartKind::BarGrouped.is_bar() && !ChartKind::BarGrouped.is_stacked_bar());
assert!(!ChartKind::Line.is_bar() && !ChartKind::Sparkline.is_bar());
}
#[test]
fn bar_grouped_round_trips_through_serde() {
let json = serde_json::to_string(&ChartKind::BarGrouped).unwrap();
assert_eq!(json, "\"BarGrouped\"");
let back: ChartKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, ChartKind::BarGrouped);
let old: ChartKind = serde_json::from_str("\"Bar\"").unwrap();
assert_eq!(old, ChartKind::Bar);
}
}