use super::rect::Rect;
use super::renderer::{ChartRenderer, HitTestResult};
use super::types::{Chart, DataPoint};
use astrelis_winit::event::{
ElementState, Event, MouseButton, MouseScrollDelta, PanGesture, PinchGesture, TouchEvent,
TouchPhase,
};
use glam::Vec2;
pub struct InteractiveChartController {
bounds: Rect,
mouse_pos: Vec2,
is_hovered: bool,
hit_test_distance: f32,
zoom_sensitivity: f32,
pan_sensitivity: f32,
last_drag_pos: Option<Vec2>,
left_mouse_down: bool,
}
impl Default for InteractiveChartController {
fn default() -> Self {
Self::new()
}
}
impl InteractiveChartController {
pub fn new() -> Self {
Self {
bounds: Rect::new(0.0, 0.0, 100.0, 100.0),
mouse_pos: Vec2::ZERO,
is_hovered: false,
hit_test_distance: 15.0,
zoom_sensitivity: 0.1,
pan_sensitivity: 1.0,
last_drag_pos: None,
left_mouse_down: false,
}
}
pub fn set_bounds(&mut self, bounds: Rect) {
self.bounds = bounds;
}
pub fn bounds(&self) -> Rect {
self.bounds
}
pub fn set_hit_test_distance(&mut self, distance: f32) {
self.hit_test_distance = distance;
}
pub fn set_zoom_sensitivity(&mut self, sensitivity: f32) {
self.zoom_sensitivity = sensitivity;
}
pub fn set_pan_sensitivity(&mut self, sensitivity: f32) {
self.pan_sensitivity = sensitivity;
}
pub fn is_hovered(&self) -> bool {
self.is_hovered
}
pub fn mouse_position(&self) -> Vec2 {
self.mouse_pos
}
pub fn plot_area(&self, chart: &Chart) -> Rect {
self.bounds.inset(chart.padding)
}
pub fn handle_event(&mut self, chart: &mut Chart, event: &Event) -> bool {
match event {
Event::MouseMoved(pos) => {
self.mouse_pos = Vec2::new(pos.x as f32, pos.y as f32);
self.is_hovered = self.bounds.contains(self.mouse_pos);
if chart.interactive.is_dragging && chart.interactive.pan_enabled {
if let Some(last_pos) = self.last_drag_pos {
let delta = self.mouse_pos - last_pos;
self.apply_pan(chart, delta);
}
self.last_drag_pos = Some(self.mouse_pos);
return true;
}
if self.is_hovered {
let plot_area = self.plot_area(chart);
if let Some(hit) = self.hit_test(chart, &plot_area, self.mouse_pos) {
chart.interactive.hovered_point = Some((hit.series_index, hit.point_index));
} else {
chart.interactive.hovered_point = None;
}
} else {
chart.interactive.hovered_point = None;
}
self.is_hovered
}
Event::MouseButtonDown(button) => {
if *button == MouseButton::Left {
self.left_mouse_down = true;
if !self.is_hovered {
return false;
}
if chart.interactive.pan_enabled {
chart.interactive.is_dragging = true;
chart.interactive.drag_start = Some(self.mouse_pos);
self.last_drag_pos = Some(self.mouse_pos);
}
let plot_area = self.plot_area(chart);
if let Some(hit) = self.hit_test(chart, &plot_area, self.mouse_pos) {
let point = (hit.series_index, hit.point_index);
if !chart.interactive.selected_points.contains(&point) {
chart.interactive.selected_points.push(point);
}
}
true
} else {
false
}
}
Event::MouseButtonUp(button) => {
if *button == MouseButton::Left {
self.left_mouse_down = false;
chart.interactive.is_dragging = false;
chart.interactive.drag_start = None;
self.last_drag_pos = None;
true
} else {
false
}
}
Event::MouseScrolled(delta) => {
if !self.is_hovered || !chart.interactive.zoom_enabled {
return false;
}
let (scroll_x, scroll_y) = match delta {
MouseScrollDelta::LineDelta(x, y) => (*x, *y),
MouseScrollDelta::PixelDelta(pos) => {
(pos.x as f32 / 100.0, pos.y as f32 / 100.0)
}
};
let zoom_factor_x = 1.0 + scroll_x * self.zoom_sensitivity;
let zoom_factor_y = 1.0 + scroll_y * self.zoom_sensitivity;
let x_significant = scroll_x.abs() > 0.001;
let y_significant = scroll_y.abs() > 0.001;
if x_significant && y_significant {
chart.interactive.zoom_xy(zoom_factor_x, zoom_factor_y);
} else if y_significant {
chart.interactive.zoom_by(zoom_factor_y);
} else if x_significant {
chart.interactive.zoom_x(zoom_factor_x);
}
true
}
Event::KeyInput(key_event) => {
if !self.is_hovered {
return false;
}
if key_event.state == ElementState::Pressed {
use astrelis_winit::event::{Key, NamedKey};
match &key_event.logical_key {
Key::Named(NamedKey::Home) => {
chart.interactive.reset();
true
}
Key::Character(c) if c == "r" || c == "R" => {
chart.interactive.reset();
true
}
Key::Character(c) if c == "+" || c == "=" => {
if chart.interactive.zoom_enabled {
let center = self.bounds.center();
chart.interactive.zoom_at(center, 1.2);
}
true
}
Key::Character(c) if c == "-" || c == "_" => {
if chart.interactive.zoom_enabled {
let center = self.bounds.center();
chart.interactive.zoom_at(center, 0.8);
}
true
}
_ => false,
}
} else {
false
}
}
Event::PinchGesture(PinchGesture { delta, phase }) => {
if !self.is_hovered || !chart.interactive.zoom_enabled {
return false;
}
let zoom_factor = 1.0 + (*delta as f32);
chart.interactive.zoom_by(zoom_factor);
match phase {
TouchPhase::Started => {
chart.interactive.is_dragging = true;
}
TouchPhase::Ended | TouchPhase::Cancelled => {
chart.interactive.is_dragging = false;
}
TouchPhase::Moved => {}
}
true
}
Event::PanGesture(PanGesture { delta, phase }) => {
if !self.is_hovered || !chart.interactive.pan_enabled {
return false;
}
let pixel_delta = Vec2::new(delta.x as f32, delta.y as f32);
self.apply_pan(chart, pixel_delta);
match phase {
TouchPhase::Started => {
chart.interactive.is_dragging = true;
}
TouchPhase::Ended | TouchPhase::Cancelled => {
chart.interactive.is_dragging = false;
}
TouchPhase::Moved => {}
}
true
}
Event::Touch(TouchEvent {
id,
position,
phase,
..
}) => {
if *id == 0 {
self.mouse_pos = Vec2::new(position.x as f32, position.y as f32);
self.is_hovered = self.bounds.contains(self.mouse_pos);
match phase {
TouchPhase::Started => {
if self.is_hovered && chart.interactive.pan_enabled {
chart.interactive.is_dragging = true;
self.last_drag_pos = Some(self.mouse_pos);
}
}
TouchPhase::Moved => {
if chart.interactive.is_dragging {
if let Some(last_pos) = self.last_drag_pos {
let delta = self.mouse_pos - last_pos;
self.apply_pan(chart, delta);
}
self.last_drag_pos = Some(self.mouse_pos);
}
}
TouchPhase::Ended | TouchPhase::Cancelled => {
chart.interactive.is_dragging = false;
self.last_drag_pos = None;
}
}
self.is_hovered
} else {
false
}
}
_ => false,
}
}
fn apply_pan(&self, chart: &mut Chart, pixel_delta: Vec2) {
let plot_area = self.plot_area(chart);
let (x_min, x_max) = chart.x_range();
let (y_min, y_max) = chart.y_range();
let data_dx = -(pixel_delta.x / plot_area.width) as f64 * (x_max - x_min);
let data_dy = (pixel_delta.y / plot_area.height) as f64 * (y_max - y_min);
chart.interactive.pan_offset.x += data_dx as f32 * self.pan_sensitivity;
chart.interactive.pan_offset.y += data_dy as f32 * self.pan_sensitivity;
}
fn hit_test(&self, chart: &Chart, plot_area: &Rect, pixel: Vec2) -> Option<HitTestResult> {
if !plot_area.contains(pixel) {
return None;
}
let mut best: Option<HitTestResult> = None;
for (series_idx, series) in chart.series.iter().enumerate() {
let (x_min, x_max) = chart.axis_range(series.x_axis);
let (y_min, y_max) = chart.axis_range(series.y_axis);
let data_x =
x_min + ((pixel.x - plot_area.x) / plot_area.width) as f64 * (x_max - x_min);
let data_radius = (self.hit_test_distance / plot_area.width) as f64 * (x_max - x_min);
let search_min = data_x - data_radius;
let search_max = data_x + data_radius;
let start_idx = series
.data
.binary_search_by(|p| {
p.x.partial_cmp(&search_min)
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or_else(|i| i);
for (point_idx, point) in series.data[start_idx..].iter().enumerate() {
if point.x > search_max {
break; }
let actual_idx = start_idx + point_idx;
let px =
plot_area.x + ((point.x - x_min) / (x_max - x_min)) as f32 * plot_area.width;
let py = plot_area.y + plot_area.height
- ((point.y - y_min) / (y_max - y_min)) as f32 * plot_area.height;
let point_pixel = Vec2::new(px, py);
let dist = pixel.distance(point_pixel);
if dist <= self.hit_test_distance {
if best.as_ref().map_or(true, |b| dist < b.distance) {
best = Some(HitTestResult {
series_index: series_idx,
point_index: actual_idx,
distance: dist,
data_point: *point,
pixel_position: point_pixel,
});
}
}
}
}
best
}
pub fn screen_to_data(&self, chart: &Chart, screen_pos: Vec2) -> DataPoint {
let plot_area = self.plot_area(chart);
let (x_min, x_max) = chart.x_range();
let (y_min, y_max) = chart.y_range();
let x = x_min + ((screen_pos.x - plot_area.x) / plot_area.width) as f64 * (x_max - x_min);
let y = y_max - ((screen_pos.y - plot_area.y) / plot_area.height) as f64 * (y_max - y_min);
DataPoint::new(x, y)
}
pub fn data_to_screen(&self, chart: &Chart, data: DataPoint) -> Vec2 {
let plot_area = self.plot_area(chart);
let (x_min, x_max) = chart.x_range();
let (y_min, y_max) = chart.y_range();
let x = plot_area.x + ((data.x - x_min) / (x_max - x_min)) as f32 * plot_area.width;
let y = plot_area.y + plot_area.height
- ((data.y - y_min) / (y_max - y_min)) as f32 * plot_area.height;
Vec2::new(x, y)
}
pub fn tooltip_text(&self, chart: &Chart) -> Option<String> {
if let Some((series_idx, point_idx)) = chart.interactive.hovered_point {
if let Some(series) = chart.series.get(series_idx) {
if let Some(point) = series.data.get(point_idx) {
return Some(format!(
"{}\nx: {:.4}\ny: {:.4}",
series.name, point.x, point.y
));
}
}
}
None
}
pub fn clear_selection(&self, chart: &mut Chart) {
chart.interactive.selected_points.clear();
}
}
pub trait InteractiveChartExt {
fn draw_interactive(
&mut self,
chart: &Chart,
bounds: Rect,
controller: &InteractiveChartController,
) -> ChartDrawResult;
}
#[derive(Debug, Clone)]
pub struct ChartDrawResult {
pub is_hovered: bool,
pub hovered_point: Option<HitTestResult>,
pub plot_area: Rect,
}
impl InteractiveChartExt for ChartRenderer<'_> {
fn draw_interactive(
&mut self,
chart: &Chart,
bounds: Rect,
controller: &InteractiveChartController,
) -> ChartDrawResult {
self.draw(chart, bounds);
let plot_area = bounds.inset(chart.padding);
let hovered_point = if controller.is_hovered() {
self.hit_test(chart, &plot_area, controller.mouse_position(), 15.0)
} else {
None
};
ChartDrawResult {
is_hovered: controller.is_hovered(),
hovered_point,
plot_area,
}
}
}
#[cfg(feature = "chart-text")]
pub trait ChartTextExt {
fn draw_all_text(
&mut self,
chart: &Chart,
bounds: &Rect,
plot_area: &Rect,
geometry: &mut crate::GeometryRenderer,
);
fn calculate_adjusted_bounds(&self, chart: &Chart, bounds: &Rect) -> Rect;
fn calculate_plot_area(&self, chart: &Chart, bounds: &Rect) -> Rect;
}
#[cfg(feature = "chart-text")]
impl ChartTextExt for super::text::ChartTextRenderer {
fn draw_all_text(
&mut self,
chart: &Chart,
bounds: &Rect,
plot_area: &Rect,
geometry: &mut crate::GeometryRenderer,
) {
self.draw_title(chart, bounds);
self.draw_tick_labels(chart, plot_area);
self.draw_axis_labels(chart, plot_area);
self.draw_legend(chart, plot_area, geometry);
}
fn calculate_adjusted_bounds(&self, chart: &Chart, bounds: &Rect) -> Rect {
let margins = self.calculate_margins(chart);
Rect::new(
bounds.x + margins.left,
bounds.y + margins.top,
(bounds.width - margins.left - margins.right).max(1.0),
(bounds.height - margins.top - margins.bottom).max(1.0),
)
}
fn calculate_plot_area(&self, chart: &Chart, bounds: &Rect) -> Rect {
let adjusted = self.calculate_adjusted_bounds(chart, bounds);
adjusted.inset(chart.padding)
}
}