Line data Source code
1 : # SPDX-FileCopyrightText: 2025 PairInteraction Developers
2 : # SPDX-License-Identifier: LGPL-3.0-or-later
3 1 : from __future__ import annotations
4 :
5 1 : from typing import TYPE_CHECKING
6 :
7 1 : import numpy as np
8 1 : from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
9 1 : from matplotlib.figure import Figure
10 1 : from PySide6.QtCore import Qt, QTimer
11 :
12 : if TYPE_CHECKING:
13 : from PySide6.QtGui import QWheelEvent
14 : from PySide6.QtWidgets import QWidget
15 :
16 :
17 1 : class MatplotlibCanvas(FigureCanvasQTAgg):
18 : """Canvas for matplotlib figures."""
19 :
20 1 : def __init__(self, parent: QWidget | None = None) -> None:
21 : """Initialize the canvas with a figure."""
22 : # Create the figure directly instead of via pyplot, so it is not registered in pyplot's global
23 : # figure manager, which would keep this canvas (and thus the whole widget) alive forever.
24 1 : self.fig = Figure()
25 1 : self.ax = self.fig.add_subplot()
26 1 : super().__init__(self.fig)
27 :
28 1 : self.setup_zoom()
29 :
30 1 : def setup_zoom(self) -> None:
31 : """Set up mouse wheel zoom functionality."""
32 : # Wheel event accumulation variables
33 1 : self.wheel_accumulation: float = 0
34 1 : self.last_wheel_pos: list[list[float]] = []
35 1 : self.wheel_timer = QTimer(self)
36 1 : self.wheel_timer.setSingleShot(True)
37 1 : self.wheel_timer.timeout.connect(self.apply_accumulated_zoom)
38 1 : self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
39 1 : self.setMouseTracking(True)
40 :
41 1 : def wheelEvent(self, event: QWheelEvent) -> None:
42 : """Handle mouse wheel events for zooming."""
43 0 : self.wheel_accumulation += event.angleDelta().y() / 120
44 0 : self.last_wheel_pos.append([event.position().x(), event.position().y()])
45 0 : self.wheel_timer.start(100) # Apply zoom after 100ms of inactivity
46 :
47 1 : def apply_accumulated_zoom(self) -> None:
48 : """Apply the accumulated zoom from wheel events."""
49 0 : if not self.wheel_accumulation:
50 0 : return
51 :
52 0 : x_min, x_max = self.ax.get_xlim()
53 0 : y_min, y_max = self.ax.get_ylim()
54 :
55 0 : scale_factor = 1 - 0.1 * self.wheel_accumulation
56 :
57 : # Get the mouse position in data coordinates
58 0 : wheel_pos_mean = np.mean(self.last_wheel_pos, axis=0)
59 0 : x_data, y_data = self.ax.transData.inverted().transform(wheel_pos_mean)
60 0 : y_data = -(y_data - (y_max + y_min) / 2) + (y_max + y_min) / 2 # y_data is mirrored bottom / top
61 :
62 0 : self.wheel_accumulation = 0
63 0 : self.last_wheel_pos = []
64 :
65 0 : if x_data > x_max or y_data > y_max or (x_data < x_min and y_data < y_min):
66 0 : return
67 :
68 0 : if x_min <= x_data <= x_max:
69 0 : x_min_new = x_data - (x_data - x_min) * scale_factor
70 0 : x_max_new = x_data + (x_max - x_data) * scale_factor
71 0 : self.ax.set_xbound(x_min_new, x_max_new)
72 0 : self.ax.set_autoscalex_on(False)
73 :
74 0 : if y_min <= y_data <= y_max:
75 0 : y_min_new = y_data - (y_data - y_min) * scale_factor
76 0 : y_max_new = y_data + (y_max - y_data) * scale_factor
77 0 : self.ax.set_ybound(y_min_new, y_max_new)
78 0 : self.ax.set_autoscaley_on(False)
79 :
80 : # Redraw the canvas
81 0 : self.draw_idle()
|