Skip to main content

egui_charts/chart/
pan_zoom.rs

1//! Pan, zoom, and scroll interaction logic for the chart widget.
2//!
3//! This module implements all viewport manipulation interactions:
4//!
5//! - **Mouse wheel** -- vertical scroll zooms the time axis (or the price axis
6//!   when hovering over it); horizontal scroll pans; Shift+wheel pans.
7//! - **Pinch-to-zoom** -- trackpad / mobile two-finger pinch gesture.
8//! - **Drag-to-pan** -- left-click drag scrolls the chart; dragging on the
9//!   price axis scales prices; dragging on the time axis zooms time.
10//! - **Kinetic scrolling** -- momentum-based animation after a flick gesture.
11//! - **Box zoom** -- left-click drag (in zoom mode) zooms into the selected
12//!   rectangular region.
13//! - **Double-click reset** -- double-click on the time axis jumps to latest;
14//!   double-click on the price axis re-enables auto-scale.
15//! - **Timescale config** -- applies pending visible-bar and width settings.
16
17use egui::{Pos2, Rect, Response, Ui};
18use web_time::Instant;
19
20use super::helpers::{apply_price_zoom, y_to_price};
21use super::state::BoxZoomMode;
22use crate::widget::Chart;
23
24impl Chart {
25    /// Handle mouse wheel events for time-axis zoom, price-axis zoom, and horizontal pan.
26    ///
27    /// Returns `Some(delta_y)` when the wheel was over the price axis, signaling
28    /// that price zoom should be applied downstream (deferred to avoid borrow conflicts).
29    pub fn handle_mouse_wheel(
30        &mut self,
31        ui: &Ui,
32        response: &Response,
33        chart_width: f32,
34        chart_rect_min_x: f32,
35        price_axis_rect: Rect,
36    ) -> Option<f32> {
37        let mut pending_price_zoom = None;
38
39        if !response.hovered()
40            || (!self.chart_options.handle_scale.mouse_wheel
41                && !self.chart_options.handle_scroll.mouse_wheel)
42        {
43            return pending_price_zoom;
44        }
45
46        let scroll_input = ui.input(|i| {
47            let mut dx = if i.smooth_scroll_delta.x.abs() > 0.0 {
48                i.smooth_scroll_delta.x
49            } else {
50                i.raw_scroll_delta.x
51            };
52            let mut dy = if i.smooth_scroll_delta.y.abs() > 0.0 {
53                i.smooth_scroll_delta.y
54            } else {
55                i.raw_scroll_delta.y
56            };
57
58            // Treat Shift+Wheel as horizontal scroll for standard mice
59            if i.modifiers.shift && dx.abs() < 0.1 && dy.abs() > 0.1 {
60                dx = dy;
61                dy = 0.0;
62            }
63            (
64                dy,
65                dx,
66                i.pointer.hover_pos(),
67                i.modifiers.command || i.modifiers.ctrl,
68            )
69        });
70        let (delta_y, delta_x, hover_pos, _is_modifier_held) = scroll_input;
71
72        let mut did_zoom = false;
73        if delta_y.abs() > 0.1 {
74            let shift_held = ui.input(|i| i.modifiers.shift);
75
76            if shift_held && self.chart_options.handle_scroll.mouse_wheel {
77                let pan_amount = -delta_y * 2.0;
78                self.state.time_scale_mut().scroll_pixels(pan_amount);
79            } else if self.chart_options.handle_scale.mouse_wheel
80                && let Some(hp) = hover_pos.or_else(|| response.hover_pos())
81            {
82                if price_axis_rect.contains(hp) {
83                    pending_price_zoom = Some(delta_y);
84                } else {
85                    let zoom_scale = (-delta_y / 100.0).clamp(-0.5, 0.5);
86                    // Zoom centered on mouse position by default
87                    // With Ctrl/Cmd held: anchor at right edge (for "zoom to latest" behavior)
88                    let zoom_point_x = hp.x - chart_rect_min_x; // Always use mouse position
89
90                    log::debug!(
91                        "[ZOOM INPUT] mouse_x={:.1}, chart_min_x={:.1}, anchor_x={:.1}, chart_width={:.1}",
92                        hp.x,
93                        chart_rect_min_x,
94                        zoom_point_x,
95                        chart_width
96                    );
97
98                    self.state.time_scale_mut().zoom(
99                        zoom_scale,
100                        zoom_point_x,
101                        chart_rect_min_x,
102                        chart_width,
103                    );
104                    did_zoom = true;
105
106                    // CRITICAL: Reset drag state after zoom to prevent jump
107                    // If user was dragging while zooming, the old start_offset is now invalid
108                    if self.scroll_start_offset.is_some() {
109                        self.scroll_start_offset = Some(self.state.time_scale().right_offset());
110                        self.scroll_start_pos = response.interact_pointer_pos();
111                    }
112                }
113            }
114        }
115
116        if !did_zoom
117            && delta_y.abs() < 0.1
118            && delta_x.abs() > 0.1
119            && self.chart_options.handle_scroll.mouse_wheel
120        {
121            self.state.time_scale_mut().scroll_pixels(delta_x);
122        }
123
124        pending_price_zoom
125    }
126
127    /// Handles pinch-to-zoom for touch/trackpad gestures
128    ///
129    /// This uses egui's multi-touch zoom_delta which is provided by trackpad
130    /// pinch gestures and mobile two-finger pinch.
131    pub fn handle_pinch_zoom(
132        &mut self,
133        ui: &Ui,
134        response: &Response,
135        chart_width: f32,
136        chart_rect_min_x: f32,
137    ) {
138        if !response.hovered() || !self.chart_options.handle_scale.pinch {
139            return;
140        }
141
142        // Get multi-touch zoom delta (1.0 = no zoom, >1.0 = zoom in, <1.0 = zoom out)
143        let zoom_info = ui.input(|i| {
144            i.multi_touch()
145                .map(|mt| (mt.zoom_delta, mt.translation_delta, i.pointer.hover_pos()))
146        });
147
148        if let Some((zoom_delta, translation_delta, hover_pos)) = zoom_info {
149            // Handle pinch zoom
150            if (zoom_delta - 1.0).abs() > 0.001 {
151                // Convert zoom_delta to our zoom scale format
152                // zoom_delta > 1.0 means fingers spreading (zoom in)
153                // zoom_delta < 1.0 means fingers pinching (zoom out)
154                let zoom_scale = (zoom_delta - 1.0) * 2.0;
155
156                // Use hover position or center of chart as zoom anchor
157                let zoom_point_x = hover_pos
158                    .map(|p| p.x - chart_rect_min_x)
159                    .unwrap_or(chart_width / 2.0);
160
161                log::debug!(
162                    "[PINCH ZOOM] zoom_delta={zoom_delta:.4}, zoom_scale={zoom_scale:.4}, anchor_x={zoom_point_x:.1}"
163                );
164
165                self.state.time_scale_mut().zoom(
166                    zoom_scale,
167                    zoom_point_x,
168                    chart_rect_min_x,
169                    chart_width,
170                );
171
172                // Reset drag state to prevent jump after pinch
173                if self.scroll_start_offset.is_some() {
174                    self.scroll_start_offset = Some(self.state.time_scale().right_offset());
175                    self.scroll_start_pos = response.interact_pointer_pos();
176                }
177            }
178
179            // Handle two-finger pan (translation during pinch)
180            if translation_delta.x.abs() > 0.5 {
181                self.state
182                    .time_scale_mut()
183                    .scroll_pixels(translation_delta.x);
184            }
185        }
186    }
187
188    /// Handle drag-to-pan, price-axis scaling, and time-axis drag-zoom.
189    ///
190    /// When the drag starts on the main chart area, horizontal movement pans the
191    /// time axis. Starting on the price axis enables price scale dragging.
192    /// Starting on the time axis enables drag-to-zoom the time scale. Also
193    /// tracks velocity for kinetic scrolling continuation after release.
194    pub fn handle_drag_pan(
195        &mut self,
196        ui: &Ui,
197        response: &Response,
198        price_axis_rect: Rect,
199        time_axis_rect: Rect,
200        chart_rect_min_x: f32,
201        has_active_drawing_tool: bool,
202    ) {
203        // CRITICAL: Skip panning if box zoom is active (right-click drag)
204        let box_zoom_active = self.box_zoom.active;
205
206        if !response.dragged()
207            || !self.chart_options.handle_scroll.pressed_mouse_move
208            || has_active_drawing_tool
209            || box_zoom_active
210        {
211            // Drag ended - handle kinetic scrolling
212            if !has_active_drawing_tool
213                && self.chart_options.kinetic_scroll.enabled
214                && self.scroll_start_pos.is_some()
215            {
216                let is_trackpad_gesture =
217                    ui.input(|i| i.multi_touch().is_some() || i.any_touches());
218
219                if is_trackpad_gesture {
220                    let move_distance = self
221                        .kinetic_scroll
222                        .last_pos
223                        .and_then(|last| {
224                            self.scroll_start_pos.map(|start| (last.x - start.x).abs())
225                        })
226                        .unwrap_or(0.0);
227
228                    let min_v_px_s = self.chart_options.kinetic_scroll.min_scroll_speed * 60.0;
229                    let max_v_px_s = self.chart_options.kinetic_scroll.max_scroll_speed * 60.0;
230
231                    if move_distance >= self.chart_options.kinetic_scroll.scroll_min_move
232                        && self.kinetic_scroll.velocity.abs() >= min_v_px_s
233                    {
234                        self.kinetic_scroll.is_active = true;
235                        self.kinetic_scroll.velocity =
236                            self.kinetic_scroll.velocity.clamp(-max_v_px_s, max_v_px_s);
237                        self.kinetic_scroll.anim_last_time = Some(Instant::now());
238                    }
239                }
240            }
241
242            self.scroll_start_pos = None;
243            self.scroll_start_offset = None;
244            self.kinetic_scroll.last_pos = None;
245            self.kinetic_scroll.last_time = None;
246            return;
247        }
248
249        // Stop kinetic animation when user starts dragging
250        self.kinetic_scroll.is_active = false;
251
252        if self.scroll_start_pos.is_none() {
253            self.scroll_start_pos = response.interact_pointer_pos();
254            self.scroll_start_offset = Some(self.state.time_scale().right_offset());
255        }
256
257        if let (Some(start_pos), Some(start_offset), Some(curr_pos)) = (
258            self.scroll_start_pos,
259            self.scroll_start_offset,
260            response.interact_pointer_pos(),
261        ) {
262            if price_axis_rect.contains(start_pos)
263                && self
264                    .chart_options
265                    .handle_scale
266                    .axis_pressed_mouse_move
267                    .price
268            {
269                self.price_scale_drag_start = Some(start_pos);
270            } else if time_axis_rect.contains(start_pos)
271                && self.chart_options.handle_scale.axis_pressed_mouse_move.time
272            {
273                let dx = curr_pos.x - start_pos.x;
274                if dx.abs() > 0.1 {
275                    let zoom_point_x = curr_pos.x - chart_rect_min_x;
276                    let zoom_scale = (dx / 100.0).signum() * (dx / 100.0).abs().min(1.0);
277                    let chart_width = time_axis_rect.width();
278                    self.state.time_scale_mut().zoom(
279                        zoom_scale,
280                        zoom_point_x,
281                        chart_rect_min_x,
282                        chart_width,
283                    );
284                    self.scroll_start_pos = Some(curr_pos);
285                }
286            } else {
287                let drag_delta_x = curr_pos.x - start_pos.x;
288                let bar_spacing = self.state.time_scale().bar_spacing();
289                let drag_in_bars = drag_delta_x / bar_spacing;
290                let new_offset = start_offset - drag_in_bars;
291
292                self.state.time_scale_mut().set_right_offset(new_offset);
293            }
294
295            // Track velocity for kinetic scrolling
296            if self.chart_options.kinetic_scroll.enabled {
297                let now = Instant::now();
298                if let (Some(last_pos), Some(last_time)) =
299                    (self.kinetic_scroll.last_pos, self.kinetic_scroll.last_time)
300                {
301                    let dt = now.duration_since(last_time).as_secs_f32();
302                    if dt > 0.0 {
303                        let dx = curr_pos.x - last_pos.x;
304                        self.kinetic_scroll.velocity = dx / dt;
305                    }
306                }
307                self.kinetic_scroll.last_pos = Some(curr_pos);
308                self.kinetic_scroll.last_time = Some(now);
309            }
310        }
311    }
312
313    /// Advance the kinetic scrolling animation by one frame.
314    ///
315    /// Applies the current velocity to the right-offset, then damps the velocity
316    /// using the configured damping coefficient. Stops when velocity drops below
317    /// the minimum threshold. Requests a repaint to keep the animation running.
318    pub fn apply_kinetic_scroll(&mut self, ui: &Ui) {
319        if !self.chart_options.kinetic_scroll.enabled || !self.kinetic_scroll.is_active {
320            return;
321        }
322
323        let now = Instant::now();
324        let dt = if let Some(t0) = self.kinetic_scroll.anim_last_time {
325            now.duration_since(t0).as_secs_f32().max(0.0)
326        } else {
327            self.kinetic_scroll.anim_last_time = Some(now);
328            0.0
329        };
330        self.kinetic_scroll.anim_last_time = Some(now);
331
332        if dt > 0.0 {
333            let velocity_in_bars_per_s =
334                self.kinetic_scroll.velocity / self.state.time_scale().bar_spacing();
335            let delta_offset = velocity_in_bars_per_s * dt;
336            let curr_offset = self.state.time_scale().right_offset();
337            let new_offset = curr_offset - delta_offset;
338
339            self.state.time_scale_mut().set_right_offset(new_offset);
340
341            let frames = (dt * 60.0).max(0.0);
342            let damping = self.chart_options.kinetic_scroll.dumping_coeff.powf(frames);
343            self.kinetic_scroll.velocity *= damping;
344
345            let min_v_px_s = self.chart_options.kinetic_scroll.min_scroll_speed * 60.0;
346            if self.kinetic_scroll.velocity.abs() < min_v_px_s {
347                self.kinetic_scroll.is_active = false;
348                self.kinetic_scroll.velocity = 0.0;
349                self.kinetic_scroll.anim_last_time = None;
350            }
351        }
352
353        ui.ctx().request_repaint();
354    }
355
356    /// Reset axes on double-click: time axis jumps to latest, price axis re-enables auto-scale.
357    pub fn handle_double_click(
358        &mut self,
359        response: &Response,
360        price_axis_rect: Rect,
361        time_axis_rect: Rect,
362    ) {
363        if !response.double_clicked() {
364            return;
365        }
366
367        if let Some(pos) = response.interact_pointer_pos() {
368            if self.chart_options.handle_scale.axis_double_click_reset.time
369                && time_axis_rect.contains(pos)
370            {
371                self.state.time_scale_mut().jump_to_latest();
372                self.state
373                    .time_scale_mut()
374                    .set_bar_spacing(self.chart_options.time_scale.bar_spacing);
375            }
376            if self
377                .chart_options
378                .handle_scale
379                .axis_double_click_reset
380                .price
381                && price_axis_rect.contains(pos)
382            {
383                self.state.set_price_auto_scale(true);
384            }
385        }
386    }
387
388    /// Handle box-zoom interaction (left-click drag when zoom mode is active).
389    ///
390    /// On press, starts tracking the selection rectangle. On release, either
391    /// zooms into the rectangle (Zoom mode) or measures price/bar delta (Measure mode).
392    /// Returns `true` if a zoom was successfully applied this frame.
393    pub fn handle_box_zoom(
394        &mut self,
395        ui: &Ui,
396        response: &Response,
397        chart_rect: Rect,
398        chart_width: f32,
399        zoom_mode_active: bool,
400    ) -> bool {
401        // Use left-click (primary) when zoom mode is active, otherwise don't trigger
402        let btn_pressed = zoom_mode_active && ui.input(|i| i.pointer.primary_pressed());
403        let btn_down = zoom_mode_active && ui.input(|i| i.pointer.primary_down());
404        let btn_released = zoom_mode_active && ui.input(|i| i.pointer.primary_released());
405
406        if btn_pressed
407            && let Some(pos) = response.interact_pointer_pos()
408            && chart_rect.contains(pos)
409        {
410            self.box_zoom.active = true;
411            self.box_zoom.start_pos = Some(pos);
412            self.box_zoom.curr_pos = Some(pos);
413        }
414
415        if btn_down
416            && self.box_zoom.active
417            && let Some(pos) = response.interact_pointer_pos()
418        {
419            self.box_zoom.curr_pos = Some(pos);
420        }
421
422        let mut zoom_applied = false;
423        if btn_released && self.box_zoom.active {
424            if let (Some(start), Some(end)) = (self.box_zoom.start_pos, self.box_zoom.curr_pos)
425                && (chart_rect.contains(start) || chart_rect.contains(end))
426            {
427                match self.box_zoom.mode {
428                    BoxZoomMode::Zoom => {
429                        zoom_applied = self.execute_box_zoom(start, end, chart_rect, chart_width);
430                    }
431                    BoxZoomMode::Measure => {
432                        // TODO(P1): Display measurement overlay showing price delta, percentage
433                        // change, and bar count between start/end of the box-select region.
434                        // Render as a floating tooltip anchored to the selection rectangle,
435                        // similar to a Measure tool (Alt+click drag).
436                    }
437                }
438            }
439            self.box_zoom.reset();
440        }
441
442        zoom_applied
443    }
444
445    /// Execute box-zoom: compute new bar spacing and right-offset to fill the viewport
446    /// with the selected region, and apply price zoom to match the vertical selection.
447    ///
448    /// Returns `false` if the selection rectangle is too small (< 20px in either dimension).
449    pub fn execute_box_zoom(
450        &mut self,
451        start: Pos2,
452        end: Pos2,
453        chart_rect: Rect,
454        chart_width: f32,
455    ) -> bool {
456        let min_x = start.x.min(end.x);
457        let max_x = start.x.max(end.x);
458        let min_y = start.y.min(end.y);
459        let max_y = start.y.max(end.y);
460
461        if (max_x - min_x).abs() <= 20.0 || (max_y - min_y).abs() <= 20.0 {
462            return false; // Zoom box too small
463        }
464
465        // Save current zoom state to history before applying new zoom
466        self.state.push_zoom_state();
467
468        let left_x_relative = min_x - chart_rect.min.x;
469        let right_x_relative = max_x - chart_rect.min.x;
470
471        let left_idx = self
472            .state
473            .time_scale()
474            .coord_to_idx(min_x, chart_rect.min.x, chart_width);
475        let right_idx = self
476            .state
477            .time_scale()
478            .coord_to_idx(max_x, chart_rect.min.x, chart_width);
479
480        let num_bars = right_idx.max(left_idx) - right_idx.min(left_idx) + 1.0;
481        if num_bars > 0.0 {
482            let new_spacing = (right_x_relative - left_x_relative) / num_bars;
483
484            let min_spacing = self.chart_options.time_scale.min_bar_spacing;
485            let max_spacing = self.chart_options.time_scale.max_bar_spacing;
486            let clamped_spacing = if max_spacing > 0.0 {
487                new_spacing.clamp(min_spacing, max_spacing)
488            } else {
489                new_spacing.max(min_spacing)
490            };
491
492            self.state.time_scale_mut().set_bar_spacing(clamped_spacing);
493
494            let center_idx = (left_idx + right_idx) / 2.0;
495            let base_idx = self.state.time_scale().base_idx() as f32;
496            let visible_bars = chart_width / clamped_spacing;
497            let target_right_offset = center_idx + (visible_bars / 2.0) - base_idx;
498            self.state
499                .time_scale_mut()
500                .set_right_offset(target_right_offset);
501        }
502
503        // Handle vertical (price) zoom
504        let price_min_y = max_y;
505        let price_max_y = min_y;
506
507        let price_range_height = chart_rect.height();
508        let price_min_ratio = (chart_rect.max.y - price_min_y) / price_range_height;
509        let price_max_ratio = (chart_rect.max.y - price_max_y) / price_range_height;
510
511        let (curr_min, curr_max) = self.state.price_range();
512        let curr_range = curr_max - curr_min;
513
514        let sel_min_price = curr_min + (price_min_ratio as f64 * curr_range);
515        let sel_max_price = curr_min + (price_max_ratio as f64 * curr_range);
516
517        self.state.set_price_range(sel_min_price, sel_max_price);
518
519        true // Zoom successfully applied
520    }
521
522    /// Apply deferred price-axis zoom from mouse wheel or price-axis drag.
523    ///
524    /// Called after all input processing to avoid mutable borrow conflicts.
525    /// Updates the chart's price range and returns the new `(min, max)`.
526    pub fn apply_price_zoom(
527        &mut self,
528        pending_price_zoom: Option<f32>,
529        response: &Response,
530        chart_rect: Rect,
531        adjusted_min: f64,
532        adjusted_max: f64,
533    ) -> (f64, f64) {
534        let mut new_min = adjusted_min;
535        let mut new_max = adjusted_max;
536
537        if let Some(delta_y) = pending_price_zoom
538            && let Some(hp) = response.hover_pos()
539        {
540            let anchor_price = y_to_price(hp.y, adjusted_min, adjusted_max, chart_rect);
541            let (min, max) = apply_price_zoom(
542                (adjusted_min, adjusted_max),
543                anchor_price,
544                delta_y,
545                chart_rect.height(),
546            );
547            self.state.set_price_range(min, max);
548            new_min = min;
549            new_max = max;
550        }
551
552        if let Some(start_pos) = self.price_scale_drag_start.take()
553            && let Some(curr_pos) = response.interact_pointer_pos()
554        {
555            let dy = curr_pos.y - start_pos.y;
556            let anchor_price = y_to_price(start_pos.y, new_min, new_max, chart_rect);
557            let (min, max) =
558                apply_price_zoom((new_min, new_max), anchor_price, dy, chart_rect.height());
559            self.state.set_price_range(min, max);
560            new_min = min;
561            new_max = max;
562        }
563
564        (new_min, new_max)
565    }
566
567    /// Apply pending timescale configuration: width, initial visible bars,
568    /// lock-on-resize behavior, and pending start-index jumps.
569    pub fn apply_timescale_config(&mut self, chart_width: f32) {
570        self.state.time_scale_mut().set_width(chart_width);
571
572        if self.apply_visible_bars_once {
573            if let Some(desired) = self.desired_visible_bars
574                && desired > 0
575            {
576                let mut spacing = self.calculate_bar_spacing(chart_width, desired);
577                let min = self.chart_options.time_scale.min_bar_spacing;
578                let max = self.chart_options.time_scale.max_bar_spacing;
579                spacing = if max > 0.0 {
580                    spacing.clamp(min, max)
581                } else {
582                    spacing.max(min)
583                };
584                self.state.time_scale_mut().set_bar_spacing(spacing);
585            }
586            self.apply_visible_bars_once = false;
587        }
588
589        // lockVisibleTimeRangeOnResize
590        if self
591            .chart_options
592            .time_scale
593            .lock_visible_time_range_on_resize
594        {
595            if let Some(prev_width) = self.prev_width
596                && (chart_width - prev_width).abs() > 1.0
597            {
598                let prev_visible_bars = self.calculate_visible_bars(prev_width);
599                if prev_visible_bars > 0 {
600                    let mut spacing = chart_width / prev_visible_bars as f32;
601                    let min = self.chart_options.time_scale.min_bar_spacing;
602                    let max = self.chart_options.time_scale.max_bar_spacing;
603                    spacing = if max > 0.0 {
604                        spacing.clamp(min, max)
605                    } else {
606                        spacing.max(min)
607                    };
608                    self.state.time_scale_mut().set_bar_spacing(spacing);
609                }
610            }
611            self.prev_width = Some(chart_width);
612        }
613
614        // Apply pending start index
615        if let Some(target_start) = self.pending_start_idx.take() {
616            let bar_spacing = self.state.time_scale().bar_spacing();
617            if bar_spacing > 0.0 {
618                let visible_len = chart_width / bar_spacing;
619                let base_idx = self.state.time_scale().base_idx() as f32;
620                let desired_right_border = (target_start as f32) + visible_len - 1.0;
621                let desired_offset = desired_right_border - base_idx;
622                self.state.time_scale_mut().set_right_offset(desired_offset);
623            }
624        }
625    }
626}