use super::text::format_tick_value;
use super::types::{AxisOrientation, AxisPosition, Chart, DataPoint, LegendPosition};
use egui::{Align2, FontId, Response, Sense, Ui, Widget};
pub struct ChartWidget<'a> {
chart: &'a mut Chart,
min_size: egui::Vec2,
hit_test_distance: f32,
}
impl<'a> ChartWidget<'a> {
pub fn new(chart: &'a mut Chart) -> Self {
Self {
chart,
min_size: egui::Vec2::new(200.0, 150.0),
hit_test_distance: 10.0,
}
}
pub fn min_size(mut self, size: egui::Vec2) -> Self {
self.min_size = size;
self
}
pub fn hit_test_distance(mut self, distance: f32) -> Self {
self.hit_test_distance = distance;
self
}
fn data_to_screen(&self, plot_rect: &egui::Rect, point: DataPoint) -> egui::Pos2 {
let (x_min, x_max) = self.chart.x_range();
let (y_min, y_max) = self.chart.y_range();
let x = plot_rect.min.x + ((point.x - x_min) / (x_max - x_min)) as f32 * plot_rect.width();
let y = plot_rect.max.y - ((point.y - y_min) / (y_max - y_min)) as f32 * plot_rect.height();
egui::pos2(x, y)
}
fn hit_test(&self, plot_rect: &egui::Rect, pos: egui::Pos2) -> Option<(usize, usize, f32)> {
let mut best: Option<(usize, usize, f32)> = None;
for (series_idx, series) in self.chart.series.iter().enumerate() {
for (point_idx, point) in series.data.iter().enumerate() {
let screen_pos = self.data_to_screen(plot_rect, *point);
let dist = pos.distance(screen_pos);
if dist <= self.hit_test_distance {
if best.map_or(true, |(_, _, d)| dist < d) {
best = Some((series_idx, point_idx, dist));
}
}
}
}
best
}
}
impl<'a> Widget for ChartWidget<'a> {
fn ui(self, ui: &mut Ui) -> Response {
let desired_size = ui.available_size().max(self.min_size);
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click_and_drag());
if !ui.is_rect_visible(rect) {
return response;
}
let padding = self.chart.padding;
let plot_rect = egui::Rect::from_min_max(
egui::pos2(rect.min.x + padding, rect.min.y + padding),
egui::pos2(rect.max.x - padding, rect.max.y - padding),
);
let response = self.handle_interactions(ui, &response, &plot_rect);
self.draw(ui, &rect, &plot_rect);
response
}
}
impl<'a> ChartWidget<'a> {
fn handle_interactions(
&self,
ui: &mut Ui,
response: &Response,
plot_rect: &egui::Rect,
) -> Response {
let mut response = response.clone();
if let Some(hover_pos) = response.hover_pos() {
if plot_rect.contains(hover_pos) {
if let Some((series_idx, point_idx, _)) = self.hit_test(plot_rect, hover_pos) {
if let Some(series) = self.chart.series.get(series_idx) {
if let Some(point) = series.data.get(point_idx) {
response = response.on_hover_ui(|ui| {
ui.label(format!("{}", series.name));
ui.label(format!("x: {:.4}", point.x));
ui.label(format!("y: {:.4}", point.y));
});
}
}
}
}
}
if response.hovered() && self.chart.interactive.zoom_enabled {
let scroll = ui.input(|i| i.raw_scroll_delta.y);
if scroll.abs() > 0.0 {
let _factor = 1.0 + scroll * 0.001;
}
}
response
}
fn draw(&self, ui: &mut Ui, rect: &egui::Rect, plot_rect: &egui::Rect) {
let painter = ui.painter();
let bg_color = egui::Color32::from_rgba_unmultiplied(
(self.chart.background_color.r * 255.0) as u8,
(self.chart.background_color.g * 255.0) as u8,
(self.chart.background_color.b * 255.0) as u8,
(self.chart.background_color.a * 255.0) as u8,
);
painter.rect_filled(*rect, 0.0, bg_color);
self.draw_title(painter, rect);
self.draw_grid(painter, plot_rect);
self.draw_axes(painter, plot_rect);
self.draw_tick_labels(painter, plot_rect);
self.draw_axis_labels(painter, plot_rect);
self.draw_fill_regions(painter, plot_rect);
self.draw_line_annotations(painter, plot_rect);
self.draw_series(painter, plot_rect);
self.draw_legend(painter, plot_rect);
}
fn draw_title(&self, painter: &egui::Painter, rect: &egui::Rect) {
let mut y_offset = rect.min.y + 8.0;
if let Some(title) = &self.chart.title {
let title_color = egui::Color32::from_rgba_unmultiplied(
(title.color.r * 255.0) as u8,
(title.color.g * 255.0) as u8,
(title.color.b * 255.0) as u8,
(title.color.a * 255.0) as u8,
);
let font = FontId::proportional(title.font_size);
let center_x = rect.center().x;
painter.text(
egui::pos2(center_x, y_offset),
Align2::CENTER_TOP,
&title.text,
font,
title_color,
);
y_offset += title.font_size + 4.0;
}
if let Some(subtitle) = &self.chart.subtitle {
let subtitle_color = egui::Color32::from_rgba_unmultiplied(
(subtitle.color.r * 255.0) as u8,
(subtitle.color.g * 255.0) as u8,
(subtitle.color.b * 255.0) as u8,
(subtitle.color.a * 255.0) as u8,
);
let font = FontId::proportional(subtitle.font_size);
let center_x = rect.center().x;
painter.text(
egui::pos2(center_x, y_offset),
Align2::CENTER_TOP,
&subtitle.text,
font,
subtitle_color,
);
}
}
fn draw_tick_labels(&self, painter: &egui::Painter, plot_rect: &egui::Rect) {
let tick_font = FontId::proportional(11.0);
let label_color = egui::Color32::from_gray(200);
for axis in &self.chart.axes {
if !axis.visible {
continue;
}
let (data_min, data_max) = self.chart.axis_range(axis.id);
let tick_count = axis.tick_count;
for i in 0..=tick_count {
let t = i as f64 / tick_count as f64;
let value = data_min + t * (data_max - data_min);
let label = format_tick_value(value);
match (axis.orientation, axis.position) {
(AxisOrientation::Horizontal, AxisPosition::Bottom) => {
let x = plot_rect.min.x + t as f32 * plot_rect.width();
let y = plot_rect.max.y + 4.0;
painter.text(
egui::pos2(x, y),
Align2::CENTER_TOP,
&label,
tick_font.clone(),
label_color,
);
}
(AxisOrientation::Horizontal, AxisPosition::Top) => {
let x = plot_rect.min.x + t as f32 * plot_rect.width();
let y = plot_rect.min.y - 4.0;
painter.text(
egui::pos2(x, y),
Align2::CENTER_BOTTOM,
&label,
tick_font.clone(),
label_color,
);
}
(AxisOrientation::Vertical, AxisPosition::Left) => {
let y = plot_rect.min.y + (1.0 - t as f32) * plot_rect.height();
let x = plot_rect.min.x - 4.0;
painter.text(
egui::pos2(x, y),
Align2::RIGHT_CENTER,
&label,
tick_font.clone(),
label_color,
);
}
(AxisOrientation::Vertical, AxisPosition::Right) => {
let y = plot_rect.min.y + (1.0 - t as f32) * plot_rect.height();
let x = plot_rect.max.x + 4.0;
painter.text(
egui::pos2(x, y),
Align2::LEFT_CENTER,
&label,
tick_font.clone(),
label_color,
);
}
_ => {}
}
}
}
}
fn draw_axis_labels(&self, painter: &egui::Painter, plot_rect: &egui::Rect) {
let label_font = FontId::proportional(13.0);
let label_color = egui::Color32::from_gray(220);
for axis in &self.chart.axes {
if !axis.visible {
continue;
}
let Some(label) = &axis.label else {
continue;
};
match (axis.orientation, axis.position) {
(AxisOrientation::Horizontal, AxisPosition::Bottom) => {
let x = plot_rect.center().x;
let y = plot_rect.max.y + 24.0;
painter.text(
egui::pos2(x, y),
Align2::CENTER_TOP,
label,
label_font.clone(),
label_color,
);
}
(AxisOrientation::Horizontal, AxisPosition::Top) => {
let x = plot_rect.center().x;
let y = plot_rect.min.y - 24.0;
painter.text(
egui::pos2(x, y),
Align2::CENTER_BOTTOM,
label,
label_font.clone(),
label_color,
);
}
(AxisOrientation::Vertical, AxisPosition::Left) => {
let x = plot_rect.min.x - 40.0;
let y = plot_rect.min.y - 8.0;
painter.text(
egui::pos2(x, y),
Align2::RIGHT_BOTTOM,
label,
label_font.clone(),
label_color,
);
}
(AxisOrientation::Vertical, AxisPosition::Right) => {
let x = plot_rect.max.x + 40.0;
let y = plot_rect.min.y - 8.0;
painter.text(
egui::pos2(x, y),
Align2::LEFT_BOTTOM,
label,
label_font.clone(),
label_color,
);
}
_ => {}
}
}
}
fn draw_legend(&self, painter: &egui::Painter, plot_rect: &egui::Rect) {
let Some(legend) = &self.chart.legend else {
return;
};
if legend.position == LegendPosition::None {
return;
}
let visible_series: Vec<_> = self
.chart
.series
.iter()
.filter(|s| s.style.show_in_legend && s.style.visible)
.collect();
if visible_series.is_empty() {
return;
}
let swatch_size = 12.0;
let entry_height = 18.0;
let padding = legend.padding;
let legend_font = FontId::proportional(12.0);
let max_name_width = visible_series
.iter()
.map(|s| {
painter
.layout_no_wrap(s.name.clone(), legend_font.clone(), egui::Color32::WHITE)
.rect
.width()
})
.fold(0.0_f32, |a, b| a.max(b));
let width = swatch_size + 8.0 + max_name_width + padding * 2.0;
let height = entry_height * visible_series.len() as f32 + padding * 2.0;
let (x, y) = match legend.position {
LegendPosition::TopRight => (plot_rect.max.x - width - 8.0, plot_rect.min.y + 8.0),
LegendPosition::TopLeft => (plot_rect.min.x + 8.0, plot_rect.min.y + 8.0),
LegendPosition::BottomRight => (
plot_rect.max.x - width - 8.0,
plot_rect.max.y - height - 8.0,
),
LegendPosition::BottomLeft => (plot_rect.min.x + 8.0, plot_rect.max.y - height - 8.0),
LegendPosition::None => return,
};
let bg_rect = egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(width, height));
painter.rect_filled(
bg_rect,
4.0,
egui::Color32::from_rgba_unmultiplied(25, 25, 30, 230),
);
painter.rect_stroke(
bg_rect,
4.0,
egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
egui::StrokeKind::Outside,
);
for (i, series) in visible_series.iter().enumerate() {
let entry_y = y + padding + i as f32 * entry_height;
let swatch_color = egui::Color32::from_rgba_unmultiplied(
(series.style.color.r * 255.0) as u8,
(series.style.color.g * 255.0) as u8,
(series.style.color.b * 255.0) as u8,
(series.style.color.a * 255.0) as u8,
);
let swatch_rect = egui::Rect::from_min_size(
egui::pos2(x + padding, entry_y + 3.0),
egui::vec2(swatch_size, swatch_size),
);
painter.rect_filled(swatch_rect, 2.0, swatch_color);
painter.text(
egui::pos2(
x + padding + swatch_size + 8.0,
entry_y + entry_height * 0.5,
),
Align2::LEFT_CENTER,
&series.name,
legend_font.clone(),
egui::Color32::from_gray(220),
);
}
}
fn draw_grid(&self, painter: &egui::Painter, plot_rect: &egui::Rect) {
for axis in &self.chart.axes {
if !axis.grid_lines || !axis.visible {
continue;
}
let grid_color = egui::Color32::from_rgba_unmultiplied(
(axis.style.grid_color.r * 255.0) as u8,
(axis.style.grid_color.g * 255.0) as u8,
(axis.style.grid_color.b * 255.0) as u8,
(axis.style.grid_color.a * 255.0) as u8,
);
let tick_count = axis.tick_count;
match axis.orientation {
super::types::AxisOrientation::Horizontal => {
for i in 0..=tick_count {
let t = i as f32 / tick_count as f32;
let x = plot_rect.min.x + t * plot_rect.width();
painter.line_segment(
[
egui::pos2(x, plot_rect.min.y),
egui::pos2(x, plot_rect.max.y),
],
egui::Stroke::new(axis.style.grid_width, grid_color),
);
}
}
super::types::AxisOrientation::Vertical => {
for i in 0..=tick_count {
let t = i as f32 / tick_count as f32;
let y = plot_rect.min.y + t * plot_rect.height();
painter.line_segment(
[
egui::pos2(plot_rect.min.x, y),
egui::pos2(plot_rect.max.x, y),
],
egui::Stroke::new(axis.style.grid_width, grid_color),
);
}
}
}
}
}
fn draw_axes(&self, painter: &egui::Painter, plot_rect: &egui::Rect) {
for axis in &self.chart.axes {
if !axis.visible {
continue;
}
let line_color = egui::Color32::from_rgba_unmultiplied(
(axis.style.line_color.r * 255.0) as u8,
(axis.style.line_color.g * 255.0) as u8,
(axis.style.line_color.b * 255.0) as u8,
(axis.style.line_color.a * 255.0) as u8,
);
let tick_color = egui::Color32::from_rgba_unmultiplied(
(axis.style.tick_color.r * 255.0) as u8,
(axis.style.tick_color.g * 255.0) as u8,
(axis.style.tick_color.b * 255.0) as u8,
(axis.style.tick_color.a * 255.0) as u8,
);
match (axis.orientation, axis.position) {
(super::types::AxisOrientation::Horizontal, super::types::AxisPosition::Bottom) => {
painter.line_segment(
[
egui::pos2(plot_rect.min.x, plot_rect.max.y),
egui::pos2(plot_rect.max.x, plot_rect.max.y),
],
egui::Stroke::new(axis.style.line_width, line_color),
);
for i in 0..=axis.tick_count {
let t = i as f32 / axis.tick_count as f32;
let x = plot_rect.min.x + t * plot_rect.width();
painter.line_segment(
[
egui::pos2(x, plot_rect.max.y),
egui::pos2(x, plot_rect.max.y + axis.style.tick_length),
],
egui::Stroke::new(axis.style.line_width, tick_color),
);
}
}
(super::types::AxisOrientation::Vertical, super::types::AxisPosition::Left) => {
painter.line_segment(
[
egui::pos2(plot_rect.min.x, plot_rect.min.y),
egui::pos2(plot_rect.min.x, plot_rect.max.y),
],
egui::Stroke::new(axis.style.line_width, line_color),
);
for i in 0..=axis.tick_count {
let t = i as f32 / axis.tick_count as f32;
let y = plot_rect.min.y + t * plot_rect.height();
painter.line_segment(
[
egui::pos2(plot_rect.min.x - axis.style.tick_length, y),
egui::pos2(plot_rect.min.x, y),
],
egui::Stroke::new(axis.style.line_width, tick_color),
);
}
}
(super::types::AxisOrientation::Vertical, super::types::AxisPosition::Right) => {
painter.line_segment(
[
egui::pos2(plot_rect.max.x, plot_rect.min.y),
egui::pos2(plot_rect.max.x, plot_rect.max.y),
],
egui::Stroke::new(axis.style.line_width, line_color),
);
for i in 0..=axis.tick_count {
let t = i as f32 / axis.tick_count as f32;
let y = plot_rect.min.y + t * plot_rect.height();
painter.line_segment(
[
egui::pos2(plot_rect.max.x, y),
egui::pos2(plot_rect.max.x + axis.style.tick_length, y),
],
egui::Stroke::new(axis.style.line_width, tick_color),
);
}
}
_ => {}
}
}
}
fn draw_fill_regions(&self, painter: &egui::Painter, plot_rect: &egui::Rect) {
use super::types::FillRegionKind;
for region in &self.chart.fill_regions {
let fill_color = egui::Color32::from_rgba_unmultiplied(
(region.color.r * 255.0) as u8,
(region.color.g * 255.0) as u8,
(region.color.b * 255.0) as u8,
(region.color.a * 255.0) as u8,
);
match ®ion.kind {
FillRegionKind::HorizontalBand { y_min, y_max } => {
let (x_range_min, x_range_max) = self.chart.x_range();
let top_left =
self.data_to_screen(plot_rect, DataPoint::new(x_range_min, *y_max));
let bottom_right =
self.data_to_screen(plot_rect, DataPoint::new(x_range_max, *y_min));
painter.rect_filled(
egui::Rect::from_min_max(top_left, bottom_right),
0.0,
fill_color,
);
}
FillRegionKind::VerticalBand { x_min, x_max } => {
let (y_range_min, y_range_max) = self.chart.y_range();
let top_left =
self.data_to_screen(plot_rect, DataPoint::new(*x_min, y_range_max));
let bottom_right =
self.data_to_screen(plot_rect, DataPoint::new(*x_max, y_range_min));
painter.rect_filled(
egui::Rect::from_min_max(top_left, bottom_right),
0.0,
fill_color,
);
}
FillRegionKind::Rectangle {
x_min,
y_min,
x_max,
y_max,
} => {
let top_left = self.data_to_screen(plot_rect, DataPoint::new(*x_min, *y_max));
let bottom_right =
self.data_to_screen(plot_rect, DataPoint::new(*x_max, *y_min));
painter.rect_filled(
egui::Rect::from_min_max(top_left, bottom_right),
0.0,
fill_color,
);
}
_ => {
}
}
}
}
fn draw_line_annotations(&self, painter: &egui::Painter, plot_rect: &egui::Rect) {
for annotation in &self.chart.line_annotations {
let color = egui::Color32::from_rgba_unmultiplied(
(annotation.color.r * 255.0) as u8,
(annotation.color.g * 255.0) as u8,
(annotation.color.b * 255.0) as u8,
(annotation.color.a * 255.0) as u8,
);
let start = self.data_to_screen(plot_rect, annotation.start);
let end = self.data_to_screen(plot_rect, annotation.end);
painter.line_segment([start, end], egui::Stroke::new(annotation.width, color));
}
}
fn draw_series(&self, painter: &egui::Painter, plot_rect: &egui::Rect) {
use super::types::ChartType;
match self.chart.chart_type {
ChartType::Line | ChartType::Area => {
for series in &self.chart.series {
if series.data.len() < 2 {
continue;
}
let color = egui::Color32::from_rgba_unmultiplied(
(series.style.color.r * 255.0) as u8,
(series.style.color.g * 255.0) as u8,
(series.style.color.b * 255.0) as u8,
(series.style.color.a * 255.0) as u8,
);
if self.chart.chart_type == ChartType::Area {
let fill_alpha = if let Some(fill) = &series.style.fill {
(fill.opacity * 255.0) as u8
} else {
(0.3 * 255.0) as u8
};
let fill_color = egui::Color32::from_rgba_unmultiplied(
(series.style.color.r * 255.0) as u8,
(series.style.color.g * 255.0) as u8,
(series.style.color.b * 255.0) as u8,
fill_alpha,
);
let (y_min, _) = self.chart.y_range();
let mut points = Vec::with_capacity(series.data.len() + 2);
points.push(
self.data_to_screen(plot_rect, DataPoint::new(series.data[0].x, y_min)),
);
for point in &series.data {
points.push(self.data_to_screen(plot_rect, *point));
}
points.push(self.data_to_screen(
plot_rect,
DataPoint::new(series.data.last().unwrap().x, y_min),
));
let shape =
egui::Shape::convex_polygon(points, fill_color, egui::Stroke::NONE);
painter.add(shape);
}
let points: Vec<egui::Pos2> = series
.data
.iter()
.map(|p| self.data_to_screen(plot_rect, *p))
.collect();
for window in points.windows(2) {
painter.line_segment(
[window[0], window[1]],
egui::Stroke::new(series.style.line_width, color),
);
}
if let Some(point_style) = &series.style.point_style {
let point_color = egui::Color32::from_rgba_unmultiplied(
(point_style.color.r * 255.0) as u8,
(point_style.color.g * 255.0) as u8,
(point_style.color.b * 255.0) as u8,
(point_style.color.a * 255.0) as u8,
);
for point in &series.data {
let pos = self.data_to_screen(plot_rect, *point);
painter.circle_filled(pos, point_style.size * 0.5, point_color);
}
}
}
}
ChartType::Scatter => {
for series in &self.chart.series {
let color = egui::Color32::from_rgba_unmultiplied(
(series.style.color.r * 255.0) as u8,
(series.style.color.g * 255.0) as u8,
(series.style.color.b * 255.0) as u8,
(series.style.color.a * 255.0) as u8,
);
let point_size = series
.style
.point_style
.as_ref()
.map(|p| p.size)
.unwrap_or(6.0);
for point in &series.data {
let pos = self.data_to_screen(plot_rect, *point);
painter.circle_filled(pos, point_size * 0.5, color);
}
}
}
ChartType::Bar => {
let bar_width = self.chart.bar_config.bar_width;
let gap = self.chart.bar_config.gap;
let series_count = self.chart.series.len() as f32;
let total_width = bar_width * series_count + gap * (series_count - 1.0);
for (series_idx, series) in self.chart.series.iter().enumerate() {
let color = egui::Color32::from_rgba_unmultiplied(
(series.style.color.r * 255.0) as u8,
(series.style.color.g * 255.0) as u8,
(series.style.color.b * 255.0) as u8,
(series.style.color.a * 255.0) as u8,
);
let (y_min, _) = self.chart.y_range();
let offset = series_idx as f32 * (bar_width + gap) - total_width * 0.5;
for point in &series.data {
let center = self.data_to_screen(plot_rect, *point);
let base = self.data_to_screen(plot_rect, DataPoint::new(point.x, y_min));
let bar_rect = egui::Rect::from_min_max(
egui::pos2(center.x + offset, center.y.min(base.y)),
egui::pos2(center.x + offset + bar_width, center.y.max(base.y)),
);
painter.rect_filled(bar_rect, 0.0, color);
}
}
}
}
}
}
pub trait ChartResponse {
fn handle_pan(&self, chart: &Chart, plot_rect: &egui::Rect) -> Option<(f64, f64)>;
fn handle_zoom(&self, ui: &Ui) -> Option<f32>;
}
impl ChartResponse for Response {
fn handle_pan(&self, chart: &Chart, plot_rect: &egui::Rect) -> Option<(f64, f64)> {
if self.dragged() && chart.interactive.pan_enabled {
let delta = self.drag_delta();
let (x_min, x_max) = chart.x_range();
let (y_min, y_max) = chart.y_range();
let data_dx = -(delta.x as f64 / plot_rect.width() as f64) * (x_max - x_min);
let data_dy = (delta.y as f64 / plot_rect.height() as f64) * (y_max - y_min);
Some((data_dx, data_dy))
} else {
None
}
}
fn handle_zoom(&self, ui: &Ui) -> Option<f32> {
if self.hovered() {
let scroll = ui.input(|i| i.raw_scroll_delta.y);
if scroll.abs() > 0.0 {
Some(1.0 + scroll * 0.001)
} else {
None
}
} else {
None
}
}
}