1use super::dataset::{chart_mode, ChartMode, DatasetEntry};
2use super::types::*;
3use crate::widgets::Renderer;
4use graphix_rt::GXExt;
5use iced_core::{mouse, Point, Rectangle};
6use iced_widget::canvas as iced_canvas;
7use std::cell::Cell;
8
9const SNAP_THRESHOLD: f32 = 20.0;
11const ZOOM_FACTOR: f64 = 1.1;
13const DOUBLE_CLICK_MS: u128 = 400;
15
16#[derive(Clone, Copy, Debug)]
18pub struct PlotInfo {
19 pub rect: Rectangle,
20 pub x_range: (f64, f64),
21 pub y_range: (f64, f64),
22}
23
24#[derive(Clone, Debug)]
26pub struct SnapPoint {
27 pub pixel: Point,
28 pub label: String,
29 pub value: String,
30}
31
32pub struct ChartState {
34 pub cache: iced_canvas::Cache<Renderer>,
35 pub cursor: Option<Point>,
37 pub x_view: Option<(f64, f64)>,
39 pub y_view: Option<(f64, f64)>,
40 pub drag_origin: Option<Point>,
42 drag_x_view: Option<(f64, f64)>,
43 drag_y_view: Option<(f64, f64)>,
44 drag_yaw: Option<f64>,
46 drag_pitch: Option<f64>,
47 pub yaw_offset: f64,
49 pub pitch_offset: f64,
50 pub scale_factor: f64,
51 pub plot_info: Cell<Option<PlotInfo>>,
53 pub snap_point: Option<SnapPoint>,
55 last_click: Option<std::time::Instant>,
57}
58
59impl Default for ChartState {
60 fn default() -> Self {
61 Self {
62 cache: iced_canvas::Cache::new(),
63 cursor: None,
64 x_view: None,
65 y_view: None,
66 drag_origin: None,
67 drag_x_view: None,
68 drag_y_view: None,
69 drag_yaw: None,
70 drag_pitch: None,
71 yaw_offset: 0.0,
72 pitch_offset: 0.0,
73 scale_factor: 1.0,
74 plot_info: Cell::new(None),
75 snap_point: None,
76 last_click: None,
77 }
78 }
79}
80
81impl ChartState {
82 pub(crate) fn handle_event<X: GXExt>(
84 &mut self,
85 chart: &super::ChartW<X>,
86 event: &iced_core::event::Event,
87 bounds: Rectangle,
88 cursor: mouse::Cursor,
89 ) -> Option<iced_widget::Action<crate::widgets::Message>> {
90 use iced_core::event::Event;
91 use iced_core::mouse::Event as ME;
92 use iced_widget::Action;
93
94 let mode = chart_mode(&chart.datasets);
95
96 match event {
97 Event::Mouse(ME::CursorMoved { position }) => {
98 let local = Point::new(position.x - bounds.x, position.y - bounds.y);
99 self.cursor = Some(local);
100
101 if let Some(origin) = self.drag_origin {
102 let dx = local.x - origin.x;
103 let dy = local.y - origin.y;
104 self.handle_drag(mode, dx, dy);
105 self.cache.clear();
106 return Some(Action::request_redraw().and_capture());
107 }
108
109 if let Some(info) = self.plot_info.get() {
111 if mode != ChartMode::ThreeD {
112 self.snap_point =
113 find_nearest_point(&chart.datasets, &info, local, mode);
114 }
115 }
116 Some(Action::request_redraw())
117 }
118
119 Event::Mouse(ME::WheelScrolled { delta }) => {
120 let pos = match cursor.position_in(bounds) {
121 Some(p) => p,
122 None => return None,
123 };
124 let lines = match delta {
125 mouse::ScrollDelta::Lines { y, .. } => *y,
126 mouse::ScrollDelta::Pixels { y, .. } => *y / 28.0,
127 };
128 if lines.abs() < 0.001 {
129 return None;
130 }
131 let info = self.plot_info.get()?;
132 self.handle_scroll(mode, &info, pos, lines);
133 self.cache.clear();
134 Some(Action::capture())
135 }
136
137 Event::Mouse(ME::ButtonPressed(mouse::Button::Left)) => {
138 let pos = match cursor.position_in(bounds) {
139 Some(p) => p,
140 None => return None,
141 };
142 let now = std::time::Instant::now();
144 if let Some(last) = self.last_click {
145 if now.duration_since(last).as_millis() < DOUBLE_CLICK_MS {
146 self.x_view = None;
148 self.y_view = None;
149 self.yaw_offset = 0.0;
150 self.pitch_offset = 0.0;
151 self.scale_factor = 1.0;
152 self.cache.clear();
153 self.last_click = None;
154 return Some(Action::capture());
155 }
156 }
157 self.last_click = Some(now);
158 self.drag_origin = Some(pos);
159 self.drag_x_view =
160 self.x_view.or_else(|| self.plot_info.get().map(|i| i.x_range));
161 self.drag_y_view =
162 self.y_view.or_else(|| self.plot_info.get().map(|i| i.y_range));
163 self.drag_yaw = Some(self.yaw_offset);
164 self.drag_pitch = Some(self.pitch_offset);
165 Some(Action::capture())
166 }
167
168 Event::Mouse(ME::ButtonReleased(mouse::Button::Left)) => {
169 if self.drag_origin.is_some() {
170 self.drag_origin = None;
171 self.drag_x_view = None;
172 self.drag_y_view = None;
173 self.drag_yaw = None;
174 self.drag_pitch = None;
175 return Some(Action::capture());
176 }
177 None
178 }
179
180 Event::Mouse(ME::CursorLeft) => {
181 self.cursor = None;
182 self.snap_point = None;
183 Some(Action::request_redraw())
184 }
185
186 _ => None,
187 }
188 }
189
190 fn handle_drag(&mut self, mode: ChartMode, dx: f32, dy: f32) {
191 match mode {
192 ChartMode::ThreeD => {
193 if let (Some(base_yaw), Some(base_pitch)) =
195 (self.drag_yaw, self.drag_pitch)
196 {
197 self.yaw_offset = base_yaw - (dx as f64) * 0.01;
198 self.pitch_offset = base_pitch + (dy as f64) * 0.01;
199 }
200 }
201 ChartMode::Bar => {
202 if let Some(info) = self.plot_info.get() {
204 let y_range = self.drag_y_view.unwrap_or(info.y_range);
205 let y_span = y_range.1 - y_range.0;
206 let dy_data = (dy as f64 / info.rect.height as f64) * y_span;
207 self.y_view = Some((y_range.0 + dy_data, y_range.1 + dy_data));
208 }
209 }
210 ChartMode::Pie | ChartMode::Empty => {}
211 _ => {
212 if let Some(info) = self.plot_info.get() {
214 let x_range = self.drag_x_view.unwrap_or(info.x_range);
215 let y_range = self.drag_y_view.unwrap_or(info.y_range);
216 let x_span = x_range.1 - x_range.0;
217 let y_span = y_range.1 - y_range.0;
218 let dx_data = -(dx as f64 / info.rect.width as f64) * x_span;
219 let dy_data = (dy as f64 / info.rect.height as f64) * y_span;
220 self.x_view = Some((x_range.0 + dx_data, x_range.1 + dx_data));
221 self.y_view = Some((y_range.0 + dy_data, y_range.1 + dy_data));
222 }
223 }
224 }
225 }
226
227 fn handle_scroll(
228 &mut self,
229 mode: ChartMode,
230 info: &PlotInfo,
231 cursor: Point,
232 lines: f32,
233 ) {
234 let factor = if lines > 0.0 { 1.0 / ZOOM_FACTOR } else { ZOOM_FACTOR };
235
236 match mode {
237 ChartMode::ThreeD => {
238 self.scale_factor *=
240 if lines > 0.0 { ZOOM_FACTOR } else { 1.0 / ZOOM_FACTOR };
241 self.scale_factor = self.scale_factor.clamp(0.1, 10.0);
242 }
243 ChartMode::Bar => {
244 let y_range = self.y_view.unwrap_or(info.y_range);
246 let t_y = (cursor.y - info.rect.y) / info.rect.height;
247 let data_y = y_range.1 - t_y as f64 * (y_range.1 - y_range.0);
248 let new_span = (y_range.1 - y_range.0) * factor;
249 let t_y_f = t_y as f64;
250 self.y_view =
251 Some((data_y - (1.0 - t_y_f) * new_span, data_y + t_y_f * new_span));
252 }
253 ChartMode::Pie | ChartMode::Empty => {}
254 _ => {
255 let x_range = self.x_view.unwrap_or(info.x_range);
257 let y_range = self.y_view.unwrap_or(info.y_range);
258
259 let t_x =
260 ((cursor.x - info.rect.x) / info.rect.width).clamp(0.0, 1.0) as f64;
261 let t_y =
262 ((cursor.y - info.rect.y) / info.rect.height).clamp(0.0, 1.0) as f64;
263
264 let data_x = x_range.0 + t_x * (x_range.1 - x_range.0);
265 let data_y = y_range.1 - t_y * (y_range.1 - y_range.0);
266
267 let x_span = (x_range.1 - x_range.0) * factor;
268 let y_span = (y_range.1 - y_range.0) * factor;
269
270 self.x_view =
271 Some((data_x - t_x * x_span, data_x + (1.0 - t_x) * x_span));
272 self.y_view =
273 Some((data_y - (1.0 - t_y) * y_span, data_y + t_y * y_span));
274 }
275 }
276 }
277
278 pub fn mouse_interaction(
280 &self,
281 mode: ChartMode,
282 bounds: Rectangle,
283 cursor: mouse::Cursor,
284 ) -> mouse::Interaction {
285 let _pos = match cursor.position_in(bounds) {
286 Some(p) => p,
287 None => return mouse::Interaction::default(),
288 };
289 if self.drag_origin.is_some() {
290 return mouse::Interaction::Grabbing;
291 }
292 match mode {
293 ChartMode::ThreeD => mouse::Interaction::Grab,
294 ChartMode::Pie | ChartMode::Empty => mouse::Interaction::default(),
295 _ => mouse::Interaction::Crosshair,
296 }
297 }
298}
299
300fn pixel_to_data(pixel: Point, info: &PlotInfo) -> Option<(f64, f64)> {
302 let t_x = (pixel.x - info.rect.x) / info.rect.width;
303 let t_y = (pixel.y - info.rect.y) / info.rect.height;
304 if t_x < 0.0 || t_x > 1.0 || t_y < 0.0 || t_y > 1.0 {
305 return None;
306 }
307 let x = info.x_range.0 + t_x as f64 * (info.x_range.1 - info.x_range.0);
308 let y = info.y_range.1 - t_y as f64 * (info.y_range.1 - info.y_range.0);
309 Some((x, y))
310}
311
312fn data_to_pixel(x: f64, y: f64, info: &PlotInfo) -> Point {
314 let t_x = (x - info.x_range.0) / (info.x_range.1 - info.x_range.0);
315 let t_y = (info.y_range.1 - y) / (info.y_range.1 - info.y_range.0);
316 Point::new(
317 info.rect.x + t_x as f32 * info.rect.width,
318 info.rect.y + t_y as f32 * info.rect.height,
319 )
320}
321
322fn try_snap(
324 best: &mut Option<(f32, SnapPoint)>,
325 cursor: Point,
326 px: Point,
327 label: &str,
328 value: String,
329) {
330 let dist = ((px.x - cursor.x).powi(2) + (px.y - cursor.y).powi(2)).sqrt();
331 if dist < SNAP_THRESHOLD && best.as_ref().map_or(true, |(d, _)| dist < *d) {
332 *best = Some((dist, SnapPoint { pixel: px, label: label.to_string(), value }));
333 }
334}
335
336fn find_nearest_point<X: GXExt>(
338 datasets: &[DatasetEntry<X>],
339 info: &PlotInfo,
340 cursor: Point,
341 mode: ChartMode,
342) -> Option<SnapPoint> {
343 if cursor.x < info.rect.x
345 || cursor.x > info.rect.x + info.rect.width
346 || cursor.y < info.rect.y
347 || cursor.y > info.rect.y + info.rect.height
348 {
349 return None;
350 }
351
352 let mut best: Option<(f32, SnapPoint)> = None;
353
354 for (i, ds) in datasets.iter().enumerate() {
355 let default_label = format!("Series {}", i + 1);
356 let series_label = ds.label().unwrap_or(&default_label);
357 match ds {
358 DatasetEntry::XY { data, .. } | DatasetEntry::DashedLine { data, .. } => {
359 if let Some(d) = data.t.as_ref() {
360 match d {
361 XYData::Numeric(pts) if mode == ChartMode::Numeric => {
362 for &(x, y) in pts.iter() {
363 let px = data_to_pixel(x, y, info);
364 try_snap(
365 &mut best,
366 cursor,
367 px,
368 series_label,
369 format!("({x:.4}, {y:.4})"),
370 );
371 }
372 }
373 XYData::DateTime(pts) if mode == ChartMode::TimeSeries => {
374 for &(dt, y) in pts.iter() {
375 let x = dt.timestamp_millis() as f64;
376 let px = data_to_pixel(x, y, info);
377 try_snap(
378 &mut best,
379 cursor,
380 px,
381 series_label,
382 format!("({dt}, {y:.4})"),
383 );
384 }
385 }
386 _ => {}
387 }
388 }
389 }
390 DatasetEntry::Candlestick { data, .. } => {
391 if let Some(d) = data.t.as_ref() {
392 match d {
393 OHLCData::Numeric(pts) => {
394 for pt in pts.iter() {
395 let px = data_to_pixel(pt.x, pt.close, info);
396 try_snap(
397 &mut best,
398 cursor,
399 px,
400 series_label,
401 format!(
402 "O:{:.2} H:{:.2} L:{:.2} C:{:.2}",
403 pt.open, pt.high, pt.low, pt.close
404 ),
405 );
406 }
407 }
408 OHLCData::DateTime(pts) => {
409 for pt in pts.iter() {
410 let x = pt.x.timestamp_millis() as f64;
411 let px = data_to_pixel(x, pt.close, info);
412 try_snap(
413 &mut best,
414 cursor,
415 px,
416 series_label,
417 format!(
418 "{}: O:{:.2} H:{:.2} L:{:.2} C:{:.2}",
419 pt.x, pt.open, pt.high, pt.low, pt.close
420 ),
421 );
422 }
423 }
424 }
425 }
426 }
427 DatasetEntry::ErrorBar { data, .. } => {
428 if let Some(d) = data.t.as_ref() {
429 match d {
430 EBData::Numeric(pts) => {
431 for pt in pts.iter() {
432 let px = data_to_pixel(pt.x, pt.avg, info);
433 try_snap(
434 &mut best,
435 cursor,
436 px,
437 series_label,
438 format!(
439 "avg:{:.2} [{:.2}, {:.2}]",
440 pt.avg, pt.min, pt.max
441 ),
442 );
443 }
444 }
445 EBData::DateTime(pts) => {
446 for pt in pts.iter() {
447 let x = pt.x.timestamp_millis() as f64;
448 let px = data_to_pixel(x, pt.avg, info);
449 try_snap(
450 &mut best,
451 cursor,
452 px,
453 series_label,
454 format!(
455 "{}: avg:{:.2} [{:.2}, {:.2}]",
456 pt.x, pt.avg, pt.min, pt.max
457 ),
458 );
459 }
460 }
461 }
462 }
463 }
464 DatasetEntry::Bar { data, style } => {
465 if let Some(bd) = data.t.as_ref() {
466 let (data_x, _) = match pixel_to_data(cursor, info) {
472 Some(p) => p,
473 None => continue,
474 };
475 if data_x < 0.0 {
476 continue;
477 }
478 let idx = data_x.floor() as usize;
479 if idx >= bd.0.len() {
480 continue;
481 }
482 let (cat, val) = &bd.0[idx];
483 let label = style.label.as_deref().unwrap_or(cat.as_str());
484 let pixel = data_to_pixel(idx as f64 + 0.5, *val, info);
485 let dist = (cursor.x - pixel.x).abs();
486 if best.as_ref().map_or(true, |(d, _)| dist < *d) {
487 best = Some((
488 dist,
489 SnapPoint {
490 pixel,
491 label: label.to_string(),
492 value: format!("{cat}: {val:.2}"),
493 },
494 ));
495 }
496 }
497 }
498 DatasetEntry::Pie { data, style } => {
499 if let Some(bd) = data.t.as_ref() {
500 let total: f64 = bd.0.iter().map(|(_, v)| *v).sum();
501 if total <= 0.0 {
502 continue;
503 }
504 let cx = info.rect.x + info.rect.width / 2.0;
505 let cy = info.rect.y + info.rect.height / 2.0;
506 let radius = (info.rect.width.min(info.rect.height) * 0.35).max(10.0);
507 let dx = cursor.x - cx;
508 let dy = cursor.y - cy;
509 let start = style.start_angle.unwrap_or(0.0);
514 let angle =
515 ((dy.atan2(dx) as f64).to_degrees() - start).rem_euclid(360.0);
516 let mut cumulative = 0.0;
517 for (cat, val) in bd.0.iter() {
518 let slice_angle = (*val / total) * 360.0;
519 if angle >= cumulative && angle < cumulative + slice_angle {
520 let pct = (*val / total) * 100.0;
521 let mid_deg = cumulative + slice_angle / 2.0 + start;
526 let mid_rad = (mid_deg as f64).to_radians();
527 let anchor_r = (radius * 0.5) as f64;
528 let pixel = Point::new(
529 cx + (mid_rad.cos() * anchor_r) as f32,
530 cy + (mid_rad.sin() * anchor_r) as f32,
531 );
532 best = Some((
533 0.0,
534 SnapPoint {
535 pixel,
536 label: cat.clone(),
537 value: format!("{val:.2} ({pct:.1}%)"),
538 },
539 ));
540 break;
541 }
542 cumulative += slice_angle;
543 }
544 }
545 }
546 DatasetEntry::Scatter3D { .. }
548 | DatasetEntry::Line3D { .. }
549 | DatasetEntry::Surface { .. } => {}
550 }
551 }
552
553 best.map(|(_, sp)| sp)
554}
555
556pub fn draw_tooltip(
558 frame: &mut iced_widget::canvas::Frame<Renderer>,
559 snap: &SnapPoint,
560 bounds_size: iced_core::Size,
561) {
562 use iced_core::{Color, Size};
563 use iced_widget::canvas::{Path, Stroke};
564
565 let highlight = Path::circle(snap.pixel, 5.0);
567 frame.fill(&highlight, Color::from_rgba8(255, 100, 100, 0.78));
568 frame.stroke(&highlight, Stroke::default().with_color(Color::WHITE).with_width(1.5));
569
570 let text = format!("{}: {}", snap.label, snap.value);
572 let font_size = 12.0_f32;
573 let text_w = text.len() as f32 * font_size * 0.6 + 16.0;
574 let text_h = font_size + 12.0;
575 let pad = 8.0_f32;
576
577 let mut tx = snap.pixel.x + 12.0;
579 let mut ty = snap.pixel.y - text_h - 8.0;
580
581 if tx + text_w > bounds_size.width {
583 tx = snap.pixel.x - text_w - 12.0;
584 }
585 if ty < 0.0 {
586 ty = snap.pixel.y + 12.0;
587 }
588 if tx < 0.0 {
589 tx = pad;
590 }
591
592 let bg_rect = Path::rectangle(Point::new(tx, ty), Size::new(text_w, text_h));
594 frame.fill(&bg_rect, Color::from_rgba8(40, 40, 50, 0.9));
595 frame.stroke(
596 &bg_rect,
597 Stroke::default()
598 .with_color(Color::from_rgba8(120, 120, 140, 0.78))
599 .with_width(1.0),
600 );
601
602 frame.fill_text(iced_widget::canvas::Text {
604 content: text,
605 position: Point::new(tx + pad, ty + pad / 2.0),
606 color: Color::from_rgba8(240, 240, 240, 1.0),
607 size: font_size.into(),
608 ..iced_widget::canvas::Text::default()
609 });
610}