Added direct graph navigation without activating Matplotlib toolbar modes:
Mouse wheel zooms in and out around the cursor. Left-click dragging pans the graph. Matplotlib toolbar modes still work and take precedence when activated. The existing Home button can reset the view.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from matplotlib.backend_bases import MouseButton
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
||||
from matplotlib.figure import Figure
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
@@ -550,9 +551,11 @@ class GraphWorkspacePage(QWidget):
|
||||
self.results = results
|
||||
self.plot_layout = QVBoxLayout(self)
|
||||
self.figure = Figure(layout="constrained")
|
||||
self.canvas = FigureCanvasQTAgg(self.figure)
|
||||
self.canvas = InteractiveFigureCanvas(self.figure)
|
||||
self.axes = self.figure.add_subplot(111)
|
||||
self.navigation_toolbar = NavigationToolbar2QT(self.canvas, self)
|
||||
self.canvas.axes = self.axes
|
||||
self.navigation_toolbar = GraphNavigationToolbar(self.canvas, self)
|
||||
self.canvas.navigation_toolbar = self.navigation_toolbar
|
||||
self.plot_layout.addWidget(self.navigation_toolbar)
|
||||
self.plot_layout.addWidget(self.canvas)
|
||||
self.refresh_chart()
|
||||
@@ -578,9 +581,110 @@ class GraphWorkspacePage(QWidget):
|
||||
self.axes.grid(True, alpha=0.25)
|
||||
if self.axes.lines:
|
||||
self.axes.legend()
|
||||
self.axes.relim()
|
||||
self.axes.autoscale_view()
|
||||
self.canvas.set_home_view()
|
||||
self.canvas.draw_idle()
|
||||
|
||||
|
||||
class GraphNavigationToolbar(NavigationToolbar2QT):
|
||||
"""Navigation toolbar whose Home action includes direct canvas navigation."""
|
||||
|
||||
def home(self, *args) -> None:
|
||||
del args
|
||||
self.canvas.reset_home_view()
|
||||
|
||||
|
||||
class InteractiveFigureCanvas(FigureCanvasQTAgg):
|
||||
"""Matplotlib canvas with always-available wheel zoom and drag pan."""
|
||||
|
||||
def __init__(self, figure: Figure) -> None:
|
||||
super().__init__(figure)
|
||||
self.axes = None
|
||||
self.navigation_toolbar = None
|
||||
self._pan_start = None
|
||||
self._home_view = None
|
||||
self.mpl_connect("scroll_event", self._zoom_at_cursor)
|
||||
self.mpl_connect("button_press_event", self._start_pan)
|
||||
self.mpl_connect("motion_notify_event", self._pan)
|
||||
self.mpl_connect("button_release_event", self._finish_pan)
|
||||
|
||||
def _toolbar_is_active(self) -> bool:
|
||||
return bool(
|
||||
self.navigation_toolbar is not None
|
||||
and self.navigation_toolbar.mode
|
||||
)
|
||||
|
||||
def set_home_view(self) -> None:
|
||||
if self.axes is not None:
|
||||
self._home_view = (self.axes.get_xlim(), self.axes.get_ylim())
|
||||
|
||||
def reset_home_view(self) -> None:
|
||||
if self.axes is None or self._home_view is None:
|
||||
return
|
||||
x_limits, y_limits = self._home_view
|
||||
self.axes.set_xlim(x_limits)
|
||||
self.axes.set_ylim(y_limits)
|
||||
self.draw_idle()
|
||||
|
||||
def _zoom_at_cursor(self, event) -> None:
|
||||
if (
|
||||
self.axes is None
|
||||
or event.inaxes is not self.axes
|
||||
or event.xdata is None
|
||||
or event.ydata is None
|
||||
or self._toolbar_is_active()
|
||||
):
|
||||
return
|
||||
scale = 0.8 if event.button == "up" else 1.25
|
||||
left, right = self.axes.get_xlim()
|
||||
bottom, top = self.axes.get_ylim()
|
||||
self.axes.set_xlim(
|
||||
event.xdata - (event.xdata - left) * scale,
|
||||
event.xdata + (right - event.xdata) * scale,
|
||||
)
|
||||
self.axes.set_ylim(
|
||||
event.ydata - (event.ydata - bottom) * scale,
|
||||
event.ydata + (top - event.ydata) * scale,
|
||||
)
|
||||
self.draw_idle()
|
||||
|
||||
def _start_pan(self, event) -> None:
|
||||
if (
|
||||
self.axes is None
|
||||
or event.inaxes is not self.axes
|
||||
or event.button != MouseButton.LEFT
|
||||
or self._toolbar_is_active()
|
||||
):
|
||||
return
|
||||
self._pan_start = (
|
||||
event.x,
|
||||
event.y,
|
||||
self.axes.get_xlim(),
|
||||
self.axes.get_ylim(),
|
||||
)
|
||||
|
||||
def _pan(self, event) -> None:
|
||||
if (
|
||||
self.axes is None
|
||||
or self._pan_start is None
|
||||
or event.x is None
|
||||
or event.y is None
|
||||
):
|
||||
return
|
||||
start_x, start_y, x_limits, y_limits = self._pan_start
|
||||
width = max(self.axes.bbox.width, 1.0)
|
||||
height = max(self.axes.bbox.height, 1.0)
|
||||
delta_x = (event.x - start_x) * (x_limits[1] - x_limits[0]) / width
|
||||
delta_y = (event.y - start_y) * (y_limits[1] - y_limits[0]) / height
|
||||
self.axes.set_xlim(x_limits[0] - delta_x, x_limits[1] - delta_x)
|
||||
self.axes.set_ylim(y_limits[0] - delta_y, y_limits[1] - delta_y)
|
||||
self.draw_idle()
|
||||
|
||||
def _finish_pan(self, _event) -> None:
|
||||
self._pan_start = None
|
||||
|
||||
|
||||
def _safe_file_stem(model_name: str) -> str:
|
||||
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", model_name).strip("._")
|
||||
return stem or "simulation"
|
||||
|
||||
Reference in New Issue
Block a user