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::metrics::{effective_font, role_font};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use std::borrow::Cow;
use std::cell::RefCell;
use std::fmt::Write as _;
#[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,
show_legend: bool,
show_tooltip: bool,
series_names: Vec<String>,
plot_points: Vec<Point>,
polygon_points: Vec<Point>,
data_generation: u64,
pie_wedges: Option<(Rect, u64, Vec<Option<crate::render::RenderCommand>>)>,
zoom_x: Option<(f64, f64)>,
zoom_y: Option<(f64, f64)>,
series_visible: Vec<bool>,
visibility_generation: u64,
plot_range_cache: RefCell<Option<(u64, u64, (f64, f64))>>,
label_generation: u64,
names_generation: u64,
legend_rows: Vec<(String, u32)>,
legend_key: Option<(u64, usize, u32)>,
readout_cache: Option<(usize, u64, String, u32, u32)>,
legend_hits: Vec<(Rect, usize)>,
hovered_legend: Option<usize>,
hover_readout_external: bool,
pub data_point_clicked: Signal1<usize>,
pub data_point_hovered: Signal1<usize>,
pub data_point_unhovered: Signal1<usize>,
pub zoom_changed: Signal1<Option<(f64, f64)>>,
pub viewport_changed: Signal1<(usize, usize)>,
pub series_toggled: Signal1<(usize, bool)>,
pub zoom_y_changed: Signal1<Option<(f64, f64)>>,
}
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),
show_legend: false,
show_tooltip: false,
series_names: Vec::new(),
plot_points: Vec::new(),
polygon_points: Vec::new(),
data_generation: 0,
pie_wedges: None,
zoom_x: None,
zoom_y: None,
series_visible: Vec::new(),
visibility_generation: 0,
plot_range_cache: RefCell::new(None),
label_generation: 0,
names_generation: 0,
legend_rows: Vec::new(),
legend_key: None,
readout_cache: None,
legend_hits: Vec::new(),
hovered_legend: None,
hover_readout_external: false,
data_point_clicked: Signal1::new(),
data_point_hovered: Signal1::new(),
data_point_unhovered: Signal1::new(),
zoom_changed: Signal1::new(),
viewport_changed: Signal1::new(),
series_toggled: Signal1::new(),
zoom_y_changed: 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) {
if self.chart_type == chart_type {
return;
}
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.series_visible = vec![true; self.series.len()];
self.visibility_generation = self.visibility_generation.wrapping_add(1);
self.data_generation = self.data_generation.wrapping_add(1);
self.restart_reveal();
self.revalidate_hover();
self.base.request_redraw();
}
pub fn set_series(&mut self, series: Vec<Vec<f64>>) {
self.series = series;
self.series_visible = vec![true; self.series.len()];
self.visibility_generation = self.visibility_generation.wrapping_add(1);
self.data_generation = self.data_generation.wrapping_add(1);
self.restart_reveal();
self.revalidate_hover();
self.base.request_redraw();
}
pub fn set_labels(&mut self, labels: Vec<String>) {
self.labels = labels;
self.label_generation = self.label_generation.wrapping_add(1);
self.base.request_redraw();
}
pub fn set_series_names(&mut self, names: Vec<String>) {
if self.series_names == names {
return;
}
self.series_names = names;
self.names_generation = self.names_generation.wrapping_add(1);
self.base.request_redraw();
}
pub fn series_names(&self) -> &[String] {
&self.series_names
}
pub fn set_show_legend(&mut self, show: bool) {
if self.show_legend == show {
return;
}
self.show_legend = show;
self.base.request_redraw();
}
pub fn show_legend(&self) -> bool {
self.show_legend
}
pub fn set_show_tooltip(&mut self, show: bool) {
if self.show_tooltip == show {
return;
}
self.show_tooltip = show;
self.base.request_redraw();
}
pub fn show_tooltip(&self) -> bool {
self.show_tooltip
}
pub fn zoom_range(&self) -> Option<(f64, f64)> {
self.zoom_x
}
pub fn set_zoom_range(&mut self, range: Option<(f64, f64)>) {
let total = point_count_for(self.chart_type, self.data().len()) as f64;
let range = range.map(|(low, high)| {
let (low, high) = if low <= high { (low, high) } else { (high, low) };
(low.clamp(0.0, total.max(1.0)), high.clamp(0.0, total.max(1.0)))
});
let range = range.filter(|(low, high)| high - low > f64::EPSILON);
if self.zoom_x == range {
return;
}
self.zoom_x = range;
let (start, end) = self.zoom_bounds();
self.zoom_changed.emit(range);
self.viewport_changed.emit((start, end));
self.base.request_redraw();
}
pub fn reset_zoom(&mut self) {
self.set_zoom_range(None);
self.set_zoom_y(None);
}
pub fn zoom_y(&self) -> Option<(f64, f64)> {
self.zoom_y
}
pub fn set_zoom_y(&mut self, range: Option<(f64, f64)>) {
let range = range.map(|(low, high)| if low <= high { (low, high) } else { (high, low) });
let range = range.filter(|(low, high)| (high - low).abs() > f64::EPSILON);
if self.zoom_y == range {
return;
}
self.zoom_y = range;
self.zoom_y_changed.emit(range);
self.base.request_redraw();
}
pub fn is_series_visible(&self, index: usize) -> bool {
self.series_visible.get(index).copied().unwrap_or(true)
}
pub fn set_series_visible(&mut self, index: usize, visible: bool) -> bool {
if index >= self.series.len() {
return false;
}
if self.series_visible.len() < self.series.len() {
self.series_visible.resize(self.series.len(), true);
}
if self.series_visible[index] == visible {
return true;
}
self.series_visible[index] = visible;
self.visibility_generation = self.visibility_generation.wrapping_add(1);
self.series_toggled.emit((index, visible));
self.base.request_redraw();
true
}
pub fn hover_readout_external(&self) -> bool {
self.hover_readout_external
}
pub fn set_hover_readout_external(&mut self, external: bool) {
if self.hover_readout_external == external {
return;
}
self.hover_readout_external = external;
self.base.request_redraw();
}
pub fn hover_anchor(
&self,
) -> Option<(Point, crate::compat::Vec<crate::widget::companions::TooltipLine>)> {
use crate::widget::companions::TooltipLine;
let index = self.hovered_index?;
let value = self.hovered_value()?;
let rect = self.geometry();
let label_row = rect.height.min(12) as i32;
let plot = PlotArea::of(rect, label_row);
let points = point_count_for(self.chart_type, self.data().len()).max(1);
let (start, end) = self.zoom_bounds();
let visible = end.saturating_sub(start).max(1);
let offset = index.saturating_sub(start).min(visible - 1);
let slot = (plot.right - plot.left).max(1) / visible as i32;
let x = plot.left + offset as i32 * slot + slot / 2;
let _ = points;
let (min, max) = self.plot_range();
let y = plot.y_for(value, min, max);
let mut lines = crate::compat::Vec::new();
if let Some(label) = self.labels.get(index) {
lines.push(TooltipLine::colored(
format!("{label}: {}", format_axis_value(value)),
Self::series_color(0),
));
} else {
lines.push(TooltipLine::colored(format_axis_value(value), Self::series_color(0)));
}
Some((Point::new(x, y), lines))
}
fn zoom_bounds(&self) -> (usize, usize) {
let total = point_count_for(self.chart_type, self.data().len());
if total == 0 {
return (0, 0);
}
match self.zoom_x {
Some((low, high)) => {
let start = low.floor().clamp(0.0, (total - 1) as f64) as usize;
let end = high.ceil().clamp((start + 1) as f64, total as f64) as usize;
(start, end)
}
None => (0, total),
}
}
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 (start, end) = self.zoom_bounds();
let visible = end.saturating_sub(start).max(1);
let local_x = (pos.x - rect.x).max(0) as u32;
let width = rect.width.max(1);
let mut idx = start + ((local_x as u64) * (visible as u64) / (width as u64)) as usize;
if idx >= end {
idx = end.saturating_sub(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 {
grow_value(value, baseline, self.reveal.value() as f64)
}
fn value_range(&self) -> Option<(f64, f64)> {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for (index, series) in self.series.iter().enumerate() {
if !self.is_series_visible(index) {
continue;
}
for value in series {
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 project_y(&self, area: &PlotArea, value: f64, range: (f64, f64)) -> i32 {
project_value_y(self.zoom_y, area, value, range)
}
}
fn project_value_y(
zoom: Option<(f64, f64)>,
area: &PlotArea,
value: f64,
(min, max): (f64, f64),
) -> i32 {
match zoom {
Some((low, high)) => {
let span = (high - low).max(f64::EPSILON);
let ratio = ((value - low) / span).clamp(0.0, 1.0);
area.baseline_y - (ratio * area.height_range()).round() as i32
}
None => area.y_for(value, min, max),
}
}
fn grow_value(value: f64, baseline: f64, progress: f64) -> f64 {
if progress >= 1.0 {
value
} else {
baseline + (value - baseline) * progress
}
}
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 set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(
crate::widget::metrics::dimensions::CHART_DEFAULT_WIDTH,
crate::widget::metrics::dimensions::CHART_DEFAULT_HEIGHT,
)
}
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!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"data_point_clicked" => {
Some(EventSignalRef::mapped("data_point_clicked", &self.data_point_clicked, |v| {
CapabilityValue::UInt(*v as u64)
}))
}
"data_point_hovered" => {
Some(EventSignalRef::mapped("data_point_hovered", &self.data_point_hovered, |v| {
CapabilityValue::UInt(*v as u64)
}))
}
"data_point_unhovered" => Some(EventSignalRef::mapped(
"data_point_unhovered",
&self.data_point_unhovered,
|v| CapabilityValue::UInt(*v as u64),
)),
"zoom_changed" => {
Some(EventSignalRef::mapped("zoom_changed", &self.zoom_changed, |v| match v {
Some((low, high)) => CapabilityValue::Tuple(crate::compat::Vec::from([
CapabilityValue::Float(*low),
CapabilityValue::Float(*high),
])),
None => CapabilityValue::Null,
}))
}
"viewport_changed" => {
Some(EventSignalRef::mapped("viewport_changed", &self.viewport_changed, |v| {
CapabilityValue::Tuple(crate::compat::Vec::from([
CapabilityValue::UInt(v.0 as u64),
CapabilityValue::UInt(v.1 as u64),
]))
}))
}
"series_toggled" => {
Some(EventSignalRef::mapped("series_toggled", &self.series_toggled, |v| {
CapabilityValue::Tuple(crate::compat::Vec::from([
CapabilityValue::UInt(v.0 as u64),
CapabilityValue::Bool(v.1),
]))
}))
}
"zoom_y_changed" => {
Some(EventSignalRef::mapped("zoom_y_changed", &self.zoom_y_changed, |v| match v {
Some((low, high)) => CapabilityValue::Tuple(crate::compat::Vec::from([
CapabilityValue::Float(*low),
CapabilityValue::Float(*high),
])),
None => CapabilityValue::Null,
}))
}
_ => None,
}
}
}
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();
if self.show_legend && self.series.len() > 1 {
self.refresh_legend(context);
}
let style = self.base.style();
let (surface, ink) = Self::panel_colors_with(Some(style));
let label_row_height = self.category_label_line_height(context);
let plot = PlotArea::of(rect, label_row_height);
let border = style
.border_color
.or_else(|| {
crate::style::resolved_theme_style_shared("chart").and_then(|t| t.border_color)
})
.unwrap_or_else(|| surface.blend(&ink, 0.2));
context.face_with_gradient_corners(
rect,
surface,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().resolved_corner_radii().to_array(),
Color::BLACK,
);
crate::widget::draw::stroke_surface_border(
context,
rect,
self.style().resolved_corner_radii().to_array(),
border,
1,
);
if self.series.is_empty() {
self.draw_value_axis(context, &plot, ink, surface, (0.0, 1.0));
let font = role_font(style, 12.0);
let line = context.text_line(rect, &font);
context.draw_text_fitted_cow(
line,
"No data",
&font,
ink.legible_on(surface, 4.5).with_alpha(160),
HorizontalAlignment::Center,
);
return;
}
if self.zoom_y.is_some() {
context.push_clip(
plot.left,
plot.top_y,
(plot.right - plot.left).max(1) as u32,
(plot.baseline_y - plot.top_y).max(1) as u32,
);
}
match self.chart_type {
ChartType::Bar => {
let range = self.plot_range();
let axis_range = self.zoom_y.unwrap_or(range);
self.draw_value_axis(context, &plot, ink, surface, axis_range);
for index in 0..self.series.len() {
if !self.is_series_visible(index) {
continue;
}
self.draw_bar_chart(context, rect, &plot, index, range);
}
}
ChartType::Line => {
let range = self.plot_range();
for index in 0..self.series.len() {
if !self.is_series_visible(index) {
continue;
}
self.draw_line_chart(context, rect, &plot, index, range);
}
}
ChartType::Area => {
let range = self.plot_range();
for index in 0..self.series.len() {
if !self.is_series_visible(index) {
continue;
}
self.draw_area_chart(context, rect, &plot, index, range);
}
}
ChartType::Scatter => {
let range = self.plot_range();
for index in 0..self.series.len() {
if !self.is_series_visible(index) {
continue;
}
self.draw_scatter_chart(context, rect, &plot, index, range);
}
}
ChartType::Pie => {
if self.is_series_visible(0) {
self.draw_pie_chart(context, rect);
}
}
ChartType::Waterfall => {
if self.is_series_visible(0) {
self.draw_waterfall_chart(context, rect, &plot);
}
}
ChartType::Funnel => {
if self.is_series_visible(0) {
self.draw_funnel_chart(context, rect, &plot);
}
}
ChartType::Candlestick => {
if self.is_series_visible(0) {
self.draw_candlestick_chart(context, rect, &plot);
}
}
ChartType::BoxPlot => {
if self.is_series_visible(0) {
self.draw_box_plot_chart(context, rect, &plot);
}
}
}
if self.zoom_y.is_some() {
context.pop_clip();
}
let style = self.base.style();
self.legend_hits.clear();
if self.show_legend && self.series.len() > 1 {
let font = effective_font(style);
let line_height = context.measure_text("M", font).height.max(1) as i32 + 4;
let mut y = rect.y + 8;
for index in 0..self.legend_rows.len() {
let (name, text_width) = &self.legend_rows[index];
let swatch_x = rect.x + rect.width as i32 - *text_width as i32 - 26;
if swatch_x < rect.x {
break;
}
let visible = self.series_visible.get(index).copied().unwrap_or(true);
let row_ink = if visible {
ink
} else if self.hovered_legend == Some(index) {
ink.with_alpha(150)
} else {
ink.with_alpha(110)
};
context.fill_rect(
Rect::new(swatch_x, y + 2, 10, 10),
if visible {
Self::series_color(index)
} else {
Self::series_color(index).with_alpha(70)
},
);
context.draw_text(
Point::new(swatch_x + 14, y),
name,
font,
row_ink,
HorizontalAlignment::Left,
);
self.legend_hits.push((
Rect::new(swatch_x - 2, y - 2, (*text_width + 22).max(12), line_height as u32),
index,
));
y += line_height;
if y + line_height > rect.y + rect.height as i32 {
break;
}
}
}
if self.show_tooltip && !self.hover_readout_external {
if let Some(index) = self.hovered_index {
if let Some(value) = self.hovered_value() {
let generation = self
.data_generation
.wrapping_mul(31)
.wrapping_add(self.label_generation.rotate_left(17));
let font = effective_font(style);
let fresh = matches!(
&self.readout_cache,
Some((cached, cached_generation, _, _, _))
if *cached == index && *cached_generation == generation
);
if !fresh {
let text = match self.labels.get(index) {
Some(label) => format!("{label}: {}", format_axis_value(value)),
None => format_axis_value(value),
};
let metrics = context.measure_text(&text, font);
self.readout_cache =
Some((index, generation, text, metrics.width, metrics.height.max(1)));
}
if let Some((_, _, text, width, height)) = &self.readout_cache {
let width = *width as i32 + 8;
let height = *height as i32 + 6;
let x = (rect.x + 12).min(rect.x + rect.width as i32 - width).max(rect.x);
let y = (rect.y + 8).clamp(rect.y, rect.y + rect.height as i32 - height);
let box_rect = Rect::new(x, y, width.max(1) as u32, height.max(1) as u32);
context.fill_rect(box_rect, surface);
context.draw_rect(box_rect, border);
context.draw_text_line_cow(
box_rect,
text,
font,
ink,
HorizontalAlignment::Center,
);
}
}
}
}
}
}
struct PlotArea {
left: i32,
right: i32,
baseline_y: i32,
top_y: i32,
outer_left: i32,
}
const LABEL_ROW_TOP: i32 = 12;
const LABEL_ROW_BOTTOM_GUARD: i32 = 1;
const AXIS_LABEL_COLUMN: i32 = 34;
impl PlotArea {
fn of(rect: Rect, label_row_height: i32) -> Self {
const PADDING: i32 = 8;
let label_row_height = label_row_height.max(1);
let bottom_margin = 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)
}
}
thread_local! {
static AXIS_LABEL: RefCell<String> = const { RefCell::new(String::new()) };
}
fn format_axis_value(value: f64) -> String {
let mut text = String::new();
write_axis_value(value, &mut text);
text
}
fn write_axis_value(value: f64, out: &mut String) {
out.clear();
if !value.is_finite() {
out.push('\u{2014}');
} else if (value - value.round()).abs() < 1e-9 {
let _ = write!(out, "{}", value.round() as i64);
} else {
let _ = write!(out, "{value:.1}");
}
}
const NICE_TICKS_LIMIT: usize = 65;
fn write_nice_ticks(min: f64, max: f64, target: usize, out: &mut [f64]) -> usize {
if !min.is_finite() || !max.is_finite() || max <= min {
return 0;
}
let raw = (max - min) / target.max(1) as f64;
let magnitude = 10f64.powf(raw.log10().floor());
let normalized = raw / magnitude;
let step = if normalized <= 1.0 {
1.0
} else if normalized <= 2.0 {
2.0
} else if normalized <= 2.5 {
2.5
} else if normalized <= 5.0 {
5.0
} else {
10.0
} * magnitude;
let first = (min / step).ceil() * step;
let mut written = 0;
let mut value = first;
while value <= max + step * 1e-9 {
if written == out.len() {
break;
}
out[written] = value;
written += 1;
value += step;
if written > 64 {
break;
}
}
written
}
fn hsl_to_rgb(hue_degrees: f32, saturation: f32, lightness: f32) -> Color {
let h = (hue_degrees.rem_euclid(360.0)) / 60.0;
let c = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
let (r1, g1, b1) = match h as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = lightness - c / 2.0;
let channel = |value: f32| ((value + m) * 255.0).round().clamp(0.0, 255.0) as u8;
Color { r: channel(r1), g: channel(g1), b: channel(b1), a: 255 }
}
fn draw_truncated_label(
context: &mut RenderContext,
x: i32,
baseline_y: i32,
label: &str,
right_bound: i32,
font: &Font,
) {
const BUDGET: usize = 6;
if label.is_empty() {
return;
}
let char_width = context.measure_text("M", font).width.max(1) as i32;
let available = (right_bound - x - 4).max(char_width * 4);
let budget = ((available / char_width) as usize).clamp(4, 32).max(BUDGET.min(6));
let text = if label.chars().count() > budget {
let kept: String = label.chars().take(budget.saturating_sub(2)).collect();
Cow::Owned(format!("{kept}.."))
} else {
Cow::Borrowed(label)
};
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_shared("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 {
if index < Self::PALETTE.len() {
return Self::PALETTE[index];
}
let hue = ((index - Self::PALETTE.len()) as f32 * 137.508) % 360.0;
hsl_to_rgb(hue, 0.62, 0.50)
}
fn plot_range(&self) -> (f64, f64) {
if let Some((data, visibility, range)) = *self.plot_range_cache.borrow() {
if data == self.data_generation && visibility == self.visibility_generation {
return range;
}
}
let range = match self.value_range() {
Some((min, max)) => {
if min >= 0.0 {
(0.0, if max > 0.0 { max } else { 1.0 })
} else {
(min, max)
}
}
None => (0.0, 1.0),
};
*self.plot_range_cache.borrow_mut() =
Some((self.data_generation, self.visibility_generation, range));
range
}
fn draw_value_axis(
&self,
context: &mut RenderContext,
area: &PlotArea,
ink: Color,
surface: Color,
(min, max): (f64, f64),
) {
const TICKS: i32 = 4;
let axis_ink = ink.legible_on(surface, 4.5).with_alpha(190);
let grid_ink = surface.blend(&axis_ink, 0.18);
let font = role_font(self.style(), 10.0);
let label_height = context.measure_text("M", &font).height.max(1) as i32;
let mut tick_values = [0.0f64; NICE_TICKS_LIMIT];
let tick_count = write_nice_ticks(min, max, TICKS as usize, &mut tick_values);
for &value in &tick_values[..tick_count] {
if value < min - f64::EPSILON || value > max + f64::EPSILON {
continue;
}
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_height / 2,
width: (area.left - area.axis_margin_left()).max(0) as u32,
height: label_height as u32,
};
let line = context.text_line(band, &font);
AXIS_LABEL.with(|label| {
let mut label = label.borrow_mut();
write_axis_value(value, &mut label);
context.draw_text_fitted_cow(
line,
&label,
&font,
axis_ink,
HorizontalAlignment::Right,
);
});
}
}
fn category_label_line_height(&self, context: &mut RenderContext) -> i32 {
let font = effective_font(self.style());
context.measure_text("M", font).height.max(1) as i32
}
fn refresh_legend(&mut self, context: &mut RenderContext) {
let font = effective_font(self.base.style());
let key = (self.names_generation, self.series.len(), font.size().to_bits());
if self.legend_key == Some(key) {
return;
}
let mut rows = Vec::with_capacity(self.series.len());
let mut fallback = String::new();
for index in 0..self.series.len() {
let name = match self.series_names.get(index) {
Some(name) => name.clone(),
None => {
fallback.clear();
let _ = write!(fallback, "Series {}", index + 1);
fallback.clone()
}
};
let width = context.measure_text(&name, font).width;
rows.push((name, width));
}
self.legend_rows = rows;
self.legend_key = Some(key);
}
fn draw_bar_chart(
&self,
context: &mut RenderContext,
rect: Rect,
area: &PlotArea,
series_index: usize,
(min, max): (f64, f64),
) {
let data = match self.series.get(series_index) {
Some(data) if !data.is_empty() => data,
_ => return,
};
let (start, end) = self.zoom_bounds();
let visible_count = end.saturating_sub(start).max(1);
let series_total = self.series.len().max(1);
let slot = (area.right - area.left).max(1) / visible_count as i32;
let bar_width = (slot / series_total as i32).max(1);
let gap = if series_total > 1 { 1 } else { 2 };
for (offset, &val) in data[start..end].iter().enumerate() {
let i = start + offset;
let slot_x = area.left + (offset as i32) * slot;
let x = slot_x + (series_index as i32) * bar_width;
let val = self.grown_from_baseline(val, min);
let top = self.project_y(area, 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,
effective_font(self.style()),
);
}
}
}
}
fn draw_line_chart(
&mut self,
context: &mut RenderContext,
rect: Rect,
area: &PlotArea,
series_index: usize,
(min, max): (f64, f64),
) {
let point_count = match self.series.get(series_index) {
Some(data) if data.len() >= 2 => data.len(),
_ => return,
};
let color = Self::series_color(series_index);
let progress = self.reveal.value() as f64;
let zoom = self.zoom_y;
let (start, end) = self.zoom_bounds();
let visible_count = end.saturating_sub(start).max(1);
{
let Some(data) = self.series.get(series_index) else {
return;
};
let points = &mut self.plot_points;
points.clear();
points.extend(data[start..end].iter().enumerate().map(|(offset, &val)| Point {
x: area.x_for(offset, visible_count, 0),
y: project_value_y(zoom, area, grow_value(val, min, progress), (min, max)),
}));
}
let _ = point_count;
for pair in self.plot_points.windows(2) {
context.draw_line_stroke(pair[0], pair[1], color, 2);
}
for (offset, point) in self.plot_points.iter().enumerate() {
let i = start + offset;
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,
effective_font(self.style()),
);
}
}
}
}
fn draw_area_chart(
&mut self,
context: &mut RenderContext,
rect: Rect,
area: &PlotArea,
series_index: usize,
(min, max): (f64, f64),
) {
let point_count = match self.series.get(series_index) {
Some(data) if data.len() >= 2 => data.len(),
_ => return,
};
let color = Self::series_color(series_index);
let progress = self.reveal.value() as f64;
let zoom = self.zoom_y;
let (start, end) = self.zoom_bounds();
let visible_count = end.saturating_sub(start).max(1);
{
let Some(data) = self.series.get(series_index) else {
return;
};
let points = &mut self.plot_points;
points.clear();
points.extend(data[start..end].iter().enumerate().map(|(offset, &val)| Point {
x: area.x_for(offset, visible_count, 0),
y: project_value_y(zoom, area, grow_value(val, min, progress), (min, max)),
}));
}
let _ = point_count;
let baseline_y = area.baseline_y;
{
let (plot, polygon) = (&self.plot_points, &mut self.polygon_points);
polygon.clear();
polygon.extend_from_slice(plot);
polygon.extend(plot.iter().rev().map(|point| Point::new(point.x, baseline_y)));
}
let command = crate::render::RenderCommand::DrawPath {
points: self.polygon_points.as_slice().into(),
closed: true,
color,
filled: true,
width: 0,
};
context.execute_command_ref(&command);
for pair in self.plot_points.windows(2) {
context.draw_line_stroke(pair[0], pair[1], color, 2);
}
if series_index == 0 {
for (offset, point) in self.plot_points.iter().enumerate() {
let i = start + offset;
if let Some(label) = self.labels.get(i) {
draw_truncated_label(
context,
point.x,
area.baseline_y,
label,
rect.x + rect.width as i32,
effective_font(self.style()),
);
}
}
}
}
fn draw_waterfall_chart(&self, context: &mut RenderContext, rect: Rect, area: &PlotArea) {
let data = match self.series.first() {
Some(data) if !data.is_empty() => data,
_ => return,
};
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 (start, end) = self.zoom_bounds();
let visible_count = end.saturating_sub(start).max(1);
let slot = (area.right - area.left).max(1) / visible_count as i32;
let bar_width = slot.saturating_sub(2).max(1);
for (offset, &(from, to)) in totals[start..end].iter().enumerate() {
let i = start + offset;
let x = area.left + (offset as i32) * slot;
let y_from = self.project_y(area, from, (min, max));
let y_to = self.project_y(area, to, (min, max));
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 < end {
let connector_y = y_to;
context.draw_line_stroke(
Point { x: x + bar_width, y: connector_y },
Point { x: area.left + ((offset + 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,
effective_font(self.style()),
);
}
}
}
fn draw_funnel_chart(&self, context: &mut RenderContext, rect: Rect, area: &PlotArea) {
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 (start, end) = self.zoom_bounds();
let visible_count = end.saturating_sub(start).max(1);
let available_height = area.baseline_y.saturating_sub(area.top_y).max(1);
let stage_height = (available_height / visible_count as i32).max(1);
let available_width = (area.right - area.left).max(1);
for (offset, &value) in data[start..end].iter().enumerate() {
let i = start + offset;
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 + (offset 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,
effective_font(self.style()),
);
}
}
}
fn draw_candlestick_chart(&self, context: &mut RenderContext, rect: Rect, area: &PlotArea) {
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 mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for bar in data.as_chunks::<VALUES_PER_BAR>().0.iter().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 (start, end) = self.zoom_bounds();
let visible_count = end.saturating_sub(start).max(1);
let slot = (area.right - area.left).max(1) / visible_count as i32;
let body_width = slot.saturating_sub(2).max(3);
let wick_x_offset = body_width / 2;
for (offset, bar) in data
.as_chunks::<VALUES_PER_BAR>()
.0
.iter()
.take(bar_count)
.skip(start)
.take(end.saturating_sub(start))
.enumerate()
{
let (open, high, low, close) = (bar[0], bar[1], bar[2], bar[3]);
let x = area.left + (offset as i32) * slot;
let y_open = self.project_y(area, open, (min, max));
let y_close = self.project_y(area, close, (min, max));
let y_high = self.project_y(area, high, (min, max));
let y_low = self.project_y(area, 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 offset in 0..end.saturating_sub(start) {
let i = start + offset;
if let Some(label) = self.labels.get(i) {
let x = area.left + (offset as i32) * slot + slot / 2;
draw_truncated_label(
context,
x,
area.baseline_y,
label,
rect.x + rect.width as i32,
effective_font(self.style()),
);
}
}
}
fn draw_box_plot_chart(&self, context: &mut RenderContext, rect: Rect, area: &PlotArea) {
const VALUES_PER_BOX: usize = 5;
let (surface, _) = Self::panel_colors_with(Some(self.style()));
let data = match self.series.first() {
Some(data) => data,
_ => return,
};
let box_count = data.len() / VALUES_PER_BOX;
if box_count == 0 {
return;
}
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for group in data.as_chunks::<VALUES_PER_BOX>().0.iter().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 (start, end) = self.zoom_bounds();
let visible_count = end.saturating_sub(start).max(1);
let slot = (area.right - area.left).max(1) / visible_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 (offset, group) in data
.as_chunks::<VALUES_PER_BOX>()
.0
.iter()
.take(box_count)
.skip(start)
.take(end.saturating_sub(start))
.enumerate()
{
let i = start + offset;
let (low, q1, median, q3, high) = (group[0], group[1], group[2], group[3], group[4]);
let x = area.left + (offset as i32) * slot + 2;
let cx = x + center_offset;
let y_low = self.project_y(area, low, (min, max));
let y_high = self.project_y(area, high, (min, max));
let y_q1 = self.project_y(area, q1, (min, max));
let y_q3 = self.project_y(area, 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 = self.project_y(area, median, (min, max));
context.fill_rect(Rect { x, y: y_median, width: box_width as u32, height: 2 }, surface);
if let Some(label) = self.labels.get(i) {
draw_truncated_label(
context,
cx,
area.baseline_y,
label,
rect.x + rect.width as i32,
effective_font(self.style()),
);
}
}
}
fn draw_pie_chart(&mut self, context: &mut RenderContext, rect: Rect) {
let data_len = match self.series.first() {
Some(data) if !data.is_empty() => data.len(),
_ => return,
};
let (_, ink) = Self::panel_colors_with(Some(self.style()));
let label_font = role_font(self.style(), 9.0);
let total: f64 = self
.series
.first()
.map_or(0.0, |data| data.iter().copied().filter(|v| v.is_finite() && *v > 0.0).sum());
if !total.is_finite() || 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 fresh = matches!(
&self.pie_wedges,
Some((cached_rect, generation, commands))
if *cached_rect == rect
&& *generation == self.data_generation
&& commands.len() == data_len
);
if !fresh {
let mut commands: Vec<Option<crate::render::RenderCommand>> =
Vec::with_capacity(data_len);
let mut start_angle = -std::f64::consts::FRAC_PI_2;
if let Some(data) = self.series.first() {
for (i, &val) in data.iter().enumerate() {
if !val.is_finite() || val <= 0.0 {
commands.push(None);
continue;
}
let slice_angle = 2.0 * std::f64::consts::PI * (val / total);
let segments =
((radius as f64 * slice_angle * 0.5).ceil() as i32).clamp(3, 180);
let mut wedge = Vec::with_capacity(segments as usize + 2);
wedge.push(Point { x: cx, y: cy });
for step in 0..=segments {
let t = start_angle + slice_angle * (step as f64 / segments as f64);
wedge.push(Point {
x: cx + (radius as f64 * t.cos()) as i32,
y: cy + (radius as f64 * t.sin()) as i32,
});
}
commands.push(Some(crate::render::RenderCommand::DrawPath {
points: wedge.into(),
closed: true,
color: Self::series_color(i),
filled: true,
width: 0,
}));
start_angle += slice_angle;
}
}
self.pie_wedges = Some((rect, self.data_generation, commands));
}
let mut start_angle = -std::f64::consts::FRAC_PI_2;
let mut text = crate::compat::String::new();
let Some(data) = self.series.first() else {
return;
};
for (i, &val) in data.iter().enumerate() {
if !val.is_finite() || 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;
if let Some(Some(wedge)) =
self.pie_wedges.as_ref().and_then(|(_, _, commands)| commands.get(i))
{
context.execute_command_ref(wedge);
}
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;
text.clear();
if pct >= 1.0 {
let _ =
core::fmt::Write::write_fmt(&mut text, format_args!("{label}:{pct:.0}%"));
} else {
let _ =
core::fmt::Write::write_fmt(&mut text, format_args!("{label}:{pct:.1}%"));
}
let metrics = context.measure_text(&text, &label_font);
let label_w = metrics.width as i32;
let label_h = metrics.height as i32;
let max_x = (rect.x + rect.width as i32 - label_w).max(rect.x);
let max_y = (rect.y + rect.height as i32 - label_h).max(rect.y);
let x = (lx - label_w / 2).clamp(rect.x, max_x);
let y = (ly - label_h / 2).clamp(rect.y, max_y);
context.draw_text(
Point { x, y },
&text,
&label_font,
ink,
HorizontalAlignment::Left,
);
}
start_angle = end_angle;
}
}
fn draw_scatter_chart(
&self,
context: &mut RenderContext,
rect: Rect,
area: &PlotArea,
series_index: usize,
(min, max): (f64, f64),
) {
let data = match self.series.get(series_index) {
Some(data) if !data.is_empty() => data,
_ => return,
};
let color = Self::series_color(series_index);
let (start, end) = self.zoom_bounds();
let visible_count = end.saturating_sub(start).max(1);
let slot = (area.right - area.left).max(1) / visible_count as i32;
for (offset, &val) in data[start..end].iter().enumerate() {
let i = start + offset;
let x = area.left + (offset as i32) * slot + slot / 2;
let y = self.project_y(area, 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,
effective_font(self.style()),
);
}
}
}
}
}
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 legend = self
.legend_hits
.iter()
.find(|(cell, _)| cell.contains_point(*pos))
.map(|(_, index)| *index);
if legend != self.hovered_legend {
self.hovered_legend = legend;
self.base.request_redraw();
}
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.legend_hits.iter().find(|(cell, _)| cell.contains_point(*pos)).copied()
{
let visible = self.is_series_visible(index);
let _ = self.set_series_visible(index, !visible);
return;
}
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);
}
Event::Wheel { delta, modifiers, .. } => {
let pressed = crate::shortcut::Modifiers::from_event_bits(*modifiers);
if pressed.contains(crate::shortcut::Modifiers::ALT) {
if delta.y == 0 {
return;
}
let (low, high) = self.zoom_y.unwrap_or_else(|| self.plot_range());
let anchor = self
.hovered_value()
.map_or((low + high) / 2.0, |value| value.clamp(low, high));
let factor = if delta.y > 0 { 1.25 } else { 0.8 };
let new_low = anchor - (anchor - low) * factor;
let new_high = anchor + (high - anchor) * factor;
self.set_zoom_y(Some((new_low, new_high)));
return;
}
let points = point_count_for(self.chart_type, self.data().len()) as f64;
if points < 2.0 || delta.y == 0 {
return;
}
let (low, high) = self.zoom_x.unwrap_or((0.0, points));
let span = (high - low).max(f64::EPSILON);
let anchor = self
.hovered_index
.map_or((low + high) / 2.0, |index| low + span * (index as f64 / points));
let factor = if delta.y > 0 { 1.25 } else { 0.8 };
let new_low = (anchor - (anchor - low) * factor).max(0.0);
let new_high = (anchor + (high - anchor) * factor).min(points);
if new_high - new_low > 0.5 {
self.set_zoom_range(Some((new_low, new_high)));
}
}
_ => { }
}
}
}
pub struct ChartCrosshair {
base: BaseWidget,
position: Option<Point>,
snap_to_data_point: bool,
show_x: bool,
show_y: bool,
follow_pointer: bool,
pub position_changed: Signal1<Point>,
}
impl ChartCrosshair {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::ChartTooltip, geometry, "ChartCrosshair"),
position: None,
snap_to_data_point: true,
show_x: true,
show_y: true,
follow_pointer: true,
position_changed: Signal1::new(),
}
}
pub fn position(&self) -> Option<Point> {
self.position
}
pub fn set_position(&mut self, position: Option<Point>) {
if self.position == position {
return;
}
self.position = position;
if let Some(position) = position {
self.position_changed.emit(position);
}
self.base.request_redraw();
}
pub fn show_at(&mut self, point: Point) {
self.set_position(Some(point));
}
pub fn hide(&mut self) {
self.set_position(None);
}
pub fn is_visible(&self) -> bool {
self.position.is_some()
}
pub fn snap_to_data_point(&self) -> bool {
self.snap_to_data_point
}
pub fn set_snap_to_data_point(&mut self, snap: bool) {
self.snap_to_data_point = snap;
}
pub fn show_axes(&self) -> (bool, bool) {
(self.show_x, self.show_y)
}
pub fn set_show_axes(&mut self, show_x: bool, show_y: bool) {
if self.show_x == show_x && self.show_y == show_y {
return;
}
self.show_x = show_x;
self.show_y = show_y;
self.base.request_redraw();
}
pub fn follow_pointer(&self) -> bool {
self.follow_pointer
}
pub fn set_follow_pointer(&mut self, follow_pointer: bool) {
self.follow_pointer = follow_pointer;
}
}
impl Widget for ChartCrosshair {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(240, 160)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl EventHandler for ChartCrosshair {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
match event {
Event::MouseMove { pos } | Event::PointerMove { pos, .. } => {
if self.follow_pointer && self.position.is_some() {
self.set_position(Some(*pos));
}
}
Event::MouseLeave { .. } => {
if self.follow_pointer {
self.hide();
}
}
Event::KeyPress { key: 27, .. } => self.hide(),
_ => {}
}
}
}
impl Draw for ChartCrosshair {
fn draw(&mut self, context: &mut RenderContext) {
let Some(position) = self.position else {
return;
};
let rect = self.geometry();
if !rect.contains_point(position) {
return;
}
let color = crate::style::theme_manager()
.current_theme()
.map(|active| active.colors.outline_variant)
.unwrap_or_else(|| Color::BLACK.with_alpha(120));
if self.show_x {
context.draw_line(
Point::new(position.x, rect.y),
Point::new(position.x, rect.y + rect.height as i32),
color,
);
}
if self.show_y {
context.draw_line(
Point::new(rect.x, position.y),
Point::new(rect.x + rect.width as i32, position.y),
color,
);
}
}
}
impl WidgetProperties for ChartCrosshair {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"position" => Ok(match self.position {
Some(point) => CapabilityValue::Tuple(crate::compat::Vec::from([
CapabilityValue::Int(i64::from(point.x)),
CapabilityValue::Int(i64::from(point.y)),
])),
None => CapabilityValue::Null,
}),
"snap_to_data_point" => Ok(CapabilityValue::Bool(self.snap_to_data_point)),
"show_x" => Ok(CapabilityValue::Bool(self.show_x)),
"show_y" => Ok(CapabilityValue::Bool(self.show_y)),
"follow_pointer" => Ok(CapabilityValue::Bool(self.follow_pointer)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"position" => {
let position = match value {
CapabilityValue::Null => None,
CapabilityValue::Tuple(items) if items.len() == 2 => {
let mut items = items.into_iter();
let x = match items.next() {
Some(CapabilityValue::Int(x)) => x as i32,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
let y = match items.next() {
Some(CapabilityValue::Int(y)) => y as i32,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
Some(Point::new(x, y))
}
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_position(position);
Ok(())
}
"snap_to_data_point" => {
let snap = match value {
CapabilityValue::Bool(snap) => snap,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_snap_to_data_point(snap);
Ok(())
}
"show_x" | "show_y" => {
let CapabilityValue::Bool(flag) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
if name == "show_x" {
self.set_show_axes(flag, self.show_y);
} else {
self.set_show_axes(self.show_x, flag);
}
Ok(())
}
"follow_pointer" => {
let CapabilityValue::Bool(follow) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.set_follow_pointer(follow);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"position",
"snap_to_data_point",
"show_x",
"show_y",
"follow_pointer",
BASE_PROPERTY_NAMES
]
}
}
#[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.as_chunks::<4>()
.0
.iter()
.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 the_zoom_window_is_consumed_by_hit_testing() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 100));
chart.set_data((0..10).map(|value| value as f64).collect());
let viewports = Arc::new(Mutex::new(Vec::<(usize, usize)>::new()));
let sink = Arc::clone(&viewports);
chart.viewport_changed.connect(move |range| sink.lock().unwrap().push(*range));
chart.set_zoom_range(Some((5.0, 10.0)));
assert_eq!(chart.zoom_range(), Some((5.0, 10.0)));
assert_eq!(*viewports.lock().unwrap(), vec![(5, 10)]);
assert_eq!(chart.data_index_at(Point::new(0, 50)), Some(5));
assert_eq!(chart.data_index_at(Point::new(199, 50)), Some(9));
chart.reset_zoom();
assert_eq!(*viewports.lock().unwrap(), vec![(5, 10), (0, 10)]);
}
#[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.as_chunks::<4>()
.0
.iter()
.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");
}
#[test]
fn pie_chart_with_non_finite_slices_renders_no_nan_angles() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 200, 120));
chart.set_chart_type(ChartType::Pie);
chart.set_data(vec![f64::NAN, 1.0, f64::INFINITY, 2.0, -1.0]);
let svg = crate::widget::svg::render_widget_to_svg(&mut chart, Rect::new(0, 0, 200, 120));
assert!(svg.starts_with("<svg"));
assert!(!svg.contains("NaN"), "no NaN may reach the rendered geometry: {svg}");
assert!(!svg.contains("inf"), "no infinity may reach the rendered geometry");
}
#[test]
fn legend_and_tooltip_are_opt_in_and_paint() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 240, 160));
chart.set_series(vec![vec![1.0, 2.0], vec![3.0, 1.0]]);
chart.set_series_names(vec!["first".to_string(), "second".to_string()]);
let without = crate::widget::svg::render_to_svg(&mut chart);
chart.set_show_legend(true);
let with = crate::widget::svg::render_to_svg(&mut chart);
assert_ne!(without, with, "the legend adds visible text");
chart.set_show_tooltip(true);
chart.handle_event(&Event::MouseMove { pos: Point::new(60, 80) });
assert!(chart.hovered_index().is_some());
}
#[test]
fn the_palette_extends_past_its_declared_six_colours() {
let first = ChartWidget::series_color(6);
assert_ne!(first, ChartWidget::series_color(0));
assert_ne!(first, ChartWidget::series_color(7));
}
#[test]
fn axis_ticks_land_on_round_numbers() {
let mut buffer = [0.0f64; NICE_TICKS_LIMIT];
let written = write_nice_ticks(0.0, 100.0, 4, &mut buffer);
let ticks = &buffer[..written];
assert!(ticks.len() >= 3);
assert!(ticks.iter().all(|value| (value - value.round()).abs() < 1e-9));
let written = write_nice_ticks(0.0, 1.0, 4, &mut buffer);
let narrow = &buffer[..written];
assert!(narrow.windows(2).all(|pair| pair[1] > pair[0]));
}
}
#[cfg(test)]
mod issue_2_8_tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn hiding_a_series_rescales_the_range_and_reports_it() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 320, 200));
chart.set_series(vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
assert_eq!(chart.plot_range(), (0.0, 4.0));
let seen = Arc::new(Mutex::new(Vec::<(usize, bool)>::new()));
let sink = seen.clone();
chart.series_toggled.connect_ref(move |event| {
if let Ok(mut guard) = sink.lock() {
guard.push(*event);
}
});
assert!(chart.set_series_visible(1, false));
assert_eq!(chart.plot_range(), (0.0, 2.0));
assert_eq!(
seen.lock().ok().map(|guard| guard.clone()).unwrap_or_default(),
vec![(1, false)]
);
assert!(chart.set_series_visible(1, true));
assert_eq!(chart.plot_range(), (0.0, 4.0));
}
#[test]
fn the_vertical_zoom_window_is_stored_ordered_and_reset() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 320, 200));
chart.set_series(vec![vec![0.0, 10.0, 20.0]]);
chart.set_zoom_y(Some((15.0, 5.0)));
assert_eq!(chart.zoom_y(), Some((5.0, 15.0)));
chart.reset_zoom();
assert_eq!(chart.zoom_y(), None);
assert_eq!(chart.zoom_range(), None);
}
#[test]
fn the_crosshair_shows_hides_and_reports_its_anchor() {
let mut crosshair = ChartCrosshair::new(Rect::new(0, 0, 200, 120));
assert!(!crosshair.is_visible());
crosshair.show_at(Point::new(20, 40));
assert!(crosshair.is_visible());
assert_eq!(crosshair.position(), Some(Point::new(20, 40)));
crosshair.hide();
assert!(!crosshair.is_visible());
crosshair.set_show_axes(false, true);
assert_eq!(crosshair.show_axes(), (false, true));
}
#[test]
fn the_hover_anchor_reports_the_bucket_and_its_text() {
let mut chart = ChartWidget::new(Rect::new(0, 0, 320, 200));
chart.set_series(vec![vec![1.0, 2.0, 3.0]]);
chart.set_labels(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
assert!(chart.hover_anchor().is_none());
chart.hovered_index = Some(1);
let (anchor, lines) = chart.hover_anchor().expect("a hovered bucket has an anchor");
assert!(lines.iter().any(|line| line.text.contains("b: 2")));
assert!(anchor.x > 0);
chart.set_hover_readout_external(true);
assert!(chart.hover_readout_external());
}
}