Skip to main content

graphix_package_gui/widgets/chart/
interact.rs

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
9/// Snap threshold in pixels — how close the cursor must be to a data point.
10const SNAP_THRESHOLD: f32 = 20.0;
11/// Zoom factor per scroll line.
12const ZOOM_FACTOR: f64 = 1.1;
13/// Double-click threshold in milliseconds.
14const DOUBLE_CLICK_MS: u128 = 400;
15
16/// Plot area info captured during draw() for use by update().
17#[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/// A snapped data point for tooltip display.
25#[derive(Clone, Debug)]
26pub struct SnapPoint {
27    pub pixel: Point,
28    pub label: String,
29    pub value: String,
30}
31
32/// Interactive chart state, held as `Program::State`.
33pub struct ChartState {
34    pub cache: iced_canvas::Cache<Renderer>,
35    // cursor position (canvas-relative)
36    pub cursor: Option<Point>,
37    // zoom/pan — overrides to base axis ranges
38    pub x_view: Option<(f64, f64)>,
39    pub y_view: Option<(f64, f64)>,
40    // drag state for pan
41    pub drag_origin: Option<Point>,
42    drag_x_view: Option<(f64, f64)>,
43    drag_y_view: Option<(f64, f64)>,
44    // 3D rotation drag
45    drag_yaw: Option<f64>,
46    drag_pitch: Option<f64>,
47    // 3D interactive rotation offsets
48    pub yaw_offset: f64,
49    pub pitch_offset: f64,
50    pub scale_factor: f64,
51    // plot area info set during draw() via Cell
52    pub plot_info: Cell<Option<PlotInfo>>,
53    // nearest point for tooltip
54    pub snap_point: Option<SnapPoint>,
55    // double-click detection
56    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    /// Handle a mouse event. Returns an optional Action.
83    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                // Find nearest data point for tooltip
110                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                // Check for double-click
143                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                        // Reset zoom/pan
147                        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                // Drag rotates yaw/pitch
194                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                // Bar charts: drag only pans Y axis
203                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                // Numeric / TimeSeries: drag pans both axes
213                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                // Scroll zooms scale
239                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                // Bar: zoom Y only, centered on cursor Y
245                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                // Zoom both axes centered on cursor
256                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    /// Return the appropriate mouse cursor for the current state.
279    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
300/// Convert pixel coordinates to data coordinates.
301fn 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
312/// Convert data coordinates to pixel coordinates.
313fn 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
322/// Try to improve the current best snap with a candidate point.
323fn 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
336/// Find the nearest data point to the cursor across all datasets.
337fn find_nearest_point<X: GXExt>(
338    datasets: &[DatasetEntry<X>],
339    info: &PlotInfo,
340    cursor: Point,
341    mode: ChartMode,
342) -> Option<SnapPoint> {
343    // Only snap within the plot area
344    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                    // Bar charts use x_range (0, N) with one bar per
467                    // integer segment. Pick the bar by cursor X alone
468                    // (the entire vertical strip counts as a hit) and
469                    // snap the tooltip anchor to the bar's X-center
470                    // at the bar's top-Y — not to the cursor.
471                    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                    // Hover activates anywhere in the wedge sector,
510                    // not only inside the pie itself — any cursor
511                    // angle that lands in a slice selects it, no
512                    // matter the radial distance.
513                    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                            // Snap the tooltip anchor to the wedge
522                            // centroid at half-radius on the slice's
523                            // mid-angle, so the dot sits inside the
524                            // slice regardless of cursor position.
525                            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            // No tooltip for 3D datasets
547            DatasetEntry::Scatter3D { .. }
548            | DatasetEntry::Line3D { .. }
549            | DatasetEntry::Surface { .. } => {}
550        }
551    }
552
553    best.map(|(_, sp)| sp)
554}
555
556/// Draw the tooltip overlay onto a frame.
557pub 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    // Highlight circle at snap point
566    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    // Tooltip text
571    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    // Position tooltip near snap point, offset so it doesn't obscure the point
578    let mut tx = snap.pixel.x + 12.0;
579    let mut ty = snap.pixel.y - text_h - 8.0;
580
581    // Keep tooltip on-screen
582    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    // Background
593    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    // Text
603    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}