qmlmathplot 0.3.0__py3-none-any.whl → 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qmlmathplot/__init__.py +21 -1
- qmlmathplot/animation.py +87 -0
- qmlmathplot/camera.py +187 -33
- qmlmathplot/canvas.py +107 -0
- qmlmathplot/curve.py +181 -58
- qmlmathplot/export.py +2 -2
- qmlmathplot/figure.py +442 -0
- qmlmathplot/input.py +146 -0
- qmlmathplot/interval.py +561 -0
- qmlmathplot/model.py +976 -260
- qmlmathplot/plot.py +12 -4
- qmlmathplot/pyplot.py +330 -0
- qmlmathplot/qml/PlotCurves.qml +29 -0
- qmlmathplot/qml/PlotFurniture.qml +228 -0
- qmlmathplot/qml/PlotInput.qml +134 -0
- qmlmathplot/qml/PlotView.qml +204 -217
- qmlmathplot/qsb.py +189 -16
- qmlmathplot/view.py +1 -1
- qmlmathplot/widget.py +53 -23
- {qmlmathplot-0.3.0.dist-info → qmlmathplot-0.4.1.dist-info}/METADATA +329 -114
- {qmlmathplot-0.3.0.dist-info → qmlmathplot-0.4.1.dist-info}/RECORD +23 -14
- {qmlmathplot-0.3.0.dist-info → qmlmathplot-0.4.1.dist-info}/WHEEL +1 -1
- {qmlmathplot-0.3.0.dist-info → qmlmathplot-0.4.1.dist-info}/entry_points.txt +0 -0
qmlmathplot/__init__.py
CHANGED
|
@@ -52,34 +52,54 @@ from typing import TYPE_CHECKING
|
|
|
52
52
|
|
|
53
53
|
from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
|
|
54
54
|
from .curve import Curve, CurveListModel, ImplicitCurve
|
|
55
|
+
from .figure import Axes, Figure
|
|
56
|
+
from .input import ViewInput
|
|
55
57
|
from .plot import Plot
|
|
58
|
+
from .qsb import cache_dir, cache_info, clear_cache, prune_cache
|
|
56
59
|
from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
|
|
57
60
|
|
|
58
61
|
if TYPE_CHECKING: # only for type checkers; at runtime the module-level __getattr__ is used
|
|
62
|
+
from .canvas import FigureCanvasQt
|
|
59
63
|
from .widget import MathPlotWidget
|
|
60
64
|
|
|
61
65
|
__all__ = [
|
|
66
|
+
"Axes",
|
|
67
|
+
"cache_dir",
|
|
68
|
+
"cache_info",
|
|
62
69
|
"Camera",
|
|
70
|
+
"clear_cache",
|
|
63
71
|
"Curve",
|
|
64
72
|
"CurveListModel",
|
|
73
|
+
"Figure",
|
|
74
|
+
"FigureCanvasQt",
|
|
65
75
|
"HOME_SIZE",
|
|
66
76
|
"HOME_VIEW",
|
|
67
77
|
"ImplicitCurve",
|
|
68
78
|
"MathPlotWidget",
|
|
69
79
|
"nice_ticks",
|
|
70
80
|
"Plot",
|
|
81
|
+
"prune_cache",
|
|
71
82
|
"qml_component_path",
|
|
72
83
|
"QML_MAJOR",
|
|
73
84
|
"QML_MINOR",
|
|
74
85
|
"QML_URI",
|
|
75
86
|
"register_qml_types",
|
|
87
|
+
"ViewInput",
|
|
76
88
|
]
|
|
77
89
|
|
|
78
90
|
|
|
79
91
|
def __getattr__(name: str) -> object:
|
|
80
|
-
"""
|
|
92
|
+
"""QtWidgets-backed names are lazy, so pure-QML use never loads QtWidgets."""
|
|
81
93
|
if name == "MathPlotWidget":
|
|
82
94
|
from .widget import MathPlotWidget
|
|
83
95
|
|
|
84
96
|
return MathPlotWidget
|
|
97
|
+
if name == "FigureCanvasQt":
|
|
98
|
+
from .canvas import FigureCanvasQt
|
|
99
|
+
|
|
100
|
+
return FigureCanvasQt
|
|
101
|
+
if name == "pyplot":
|
|
102
|
+
from importlib import import_module
|
|
103
|
+
|
|
104
|
+
return import_module(".pyplot", __name__)
|
|
85
105
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
qmlmathplot/animation.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The one eased-camera animation shared by wheel and keyboard moves.
|
|
2
|
+
|
|
3
|
+
Kept out of :class:`~qmlmathplot.camera.Camera` so the camera stays geometry + ticks: this
|
|
4
|
+
object owns the ``QVariantAnimation`` and the interpolation policy, and calls back with the
|
|
5
|
+
state to apply. Direct manipulation (drag, pinch, direct setters) stops it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QAbstractAnimation, QEasingCurve, QObject, QVariantAnimation
|
|
13
|
+
|
|
14
|
+
__all__ = ["CameraAnimator"]
|
|
15
|
+
|
|
16
|
+
State = tuple[float, float, float, float] # centre x, centre y, scale x, scale y
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CameraAnimator(QObject):
|
|
20
|
+
"""Interpolate between two camera states; ``apply`` runs on every frame.
|
|
21
|
+
|
|
22
|
+
Scales interpolate geometrically (constant perceived zoom rate) and the centre linearly;
|
|
23
|
+
OutCubic gives the same short settle for a wheel notch and a keypress.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, apply: Callable[[State], None], parent: QObject | None = None) -> None:
|
|
27
|
+
super().__init__(parent)
|
|
28
|
+
self._apply: Callable[[State], None] = apply
|
|
29
|
+
self._from: State | None = None
|
|
30
|
+
self._to: State | None = None
|
|
31
|
+
self._animation: QVariantAnimation = QVariantAnimation(self)
|
|
32
|
+
self._animation.setStartValue(0.0)
|
|
33
|
+
self._animation.setEndValue(1.0)
|
|
34
|
+
self._animation.setEasingCurve(QEasingCurve.Type.OutCubic)
|
|
35
|
+
self._animation.valueChanged.connect(self._on_tick)
|
|
36
|
+
self._animation.finished.connect(self._on_finished)
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------ state
|
|
39
|
+
@property
|
|
40
|
+
def running(self) -> bool:
|
|
41
|
+
return self._animation.state() == QAbstractAnimation.State.Running
|
|
42
|
+
|
|
43
|
+
def target(self, current: State) -> State:
|
|
44
|
+
"""The state an in-flight animation is heading for (accumulate rapid wheel/key
|
|
45
|
+
presses); ``current`` otherwise."""
|
|
46
|
+
if self._to is not None and self.running:
|
|
47
|
+
return self._to
|
|
48
|
+
return current
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------- lifecycle
|
|
51
|
+
def start(self, start: State, end: State, duration: int) -> None:
|
|
52
|
+
duration = int(duration)
|
|
53
|
+
if duration <= 0:
|
|
54
|
+
self.stop()
|
|
55
|
+
self._apply(end)
|
|
56
|
+
return
|
|
57
|
+
if start == end:
|
|
58
|
+
self.stop()
|
|
59
|
+
return
|
|
60
|
+
self._from = start
|
|
61
|
+
self._to = end
|
|
62
|
+
self._animation.stop()
|
|
63
|
+
self._animation.setDuration(duration)
|
|
64
|
+
self._animation.start()
|
|
65
|
+
|
|
66
|
+
def stop(self) -> None:
|
|
67
|
+
if self._animation.state() != QAbstractAnimation.State.Stopped:
|
|
68
|
+
self._animation.stop()
|
|
69
|
+
self._from = None
|
|
70
|
+
self._to = None
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------- callbacks
|
|
73
|
+
def _on_tick(self, value: float) -> None:
|
|
74
|
+
if self._from is None or self._to is None:
|
|
75
|
+
return
|
|
76
|
+
t = float(value)
|
|
77
|
+
fx, fy, fsx, fsy = self._from
|
|
78
|
+
tx, ty, tsx, tsy = self._to
|
|
79
|
+
sx = fsx * (tsx / fsx) ** t
|
|
80
|
+
sy = fsy * (tsy / fsy) ** t
|
|
81
|
+
self._apply((fx + (tx - fx) * t, fy + (ty - fy) * t, sx, sy))
|
|
82
|
+
|
|
83
|
+
def _on_finished(self) -> None:
|
|
84
|
+
if self._to is not None:
|
|
85
|
+
self._apply(self._to)
|
|
86
|
+
self._from = None
|
|
87
|
+
self._to = None
|
qmlmathplot/camera.py
CHANGED
|
@@ -14,6 +14,9 @@ import math
|
|
|
14
14
|
from PySide6.QtCore import Property, QObject, Signal, Slot
|
|
15
15
|
from PySide6.QtGui import QVector2D
|
|
16
16
|
|
|
17
|
+
from .animation import CameraAnimator, State
|
|
18
|
+
from .input import ViewInput
|
|
19
|
+
|
|
17
20
|
__all__ = ["HOME_SIZE", "HOME_VIEW", "Camera", "nice_ticks"]
|
|
18
21
|
|
|
19
22
|
#: Home view — the classic 12x4 window — as (xmin, xmax, ymin, ymax).
|
|
@@ -22,7 +25,7 @@ __all__ = ["HOME_SIZE", "HOME_VIEW", "Camera", "nice_ticks"]
|
|
|
22
25
|
#: the first reported size turns the home view into (zoom, y scale) for that widget, and
|
|
23
26
|
#: the constants below are only the fallback used before any size is known.
|
|
24
27
|
HOME_VIEW = (-6.0, 6.0, -2.0, 2.0)
|
|
25
|
-
#: Reference size for the home view (before the first ``
|
|
28
|
+
#: Reference size for the home view (before the first ``set_viewport``).
|
|
26
29
|
HOME_SIZE = (900.0, 600.0)
|
|
27
30
|
|
|
28
31
|
|
|
@@ -91,6 +94,7 @@ class Camera(QObject):
|
|
|
91
94
|
zoomStepChanged = Signal()
|
|
92
95
|
panEnabledChanged = Signal()
|
|
93
96
|
zoomEnabledChanged = Signal()
|
|
97
|
+
animationDurationChanged = Signal()
|
|
94
98
|
|
|
95
99
|
#: Scale limits (world units per pixel), to keep panning/zooming reversible.
|
|
96
100
|
MIN_SCALE = 1e-12
|
|
@@ -109,6 +113,16 @@ class Camera(QObject):
|
|
|
109
113
|
self._zoom_step = 0.9
|
|
110
114
|
self._pan_enabled = True
|
|
111
115
|
self._zoom_enabled = True
|
|
116
|
+
self._ticks_key: tuple[float, float, float, float] | None = None
|
|
117
|
+
self._ticks_x: list[tuple[float, str]] = []
|
|
118
|
+
self._ticks_y: list[tuple[float, str]] = []
|
|
119
|
+
self._ticks_x_qml: list[list[float | str]] = []
|
|
120
|
+
self._ticks_y_qml: list[list[float | str]] = []
|
|
121
|
+
self._input = ViewInput(self)
|
|
122
|
+
self._animation_duration = 160
|
|
123
|
+
# One animation drives every discrete camera move (a wheel notch, a keypress). Drags
|
|
124
|
+
# and touch gestures are direct manipulation and must never be animated through.
|
|
125
|
+
self._animator = CameraAnimator(self._apply_state, self)
|
|
112
126
|
|
|
113
127
|
# ------------------------------------------------------------- state
|
|
114
128
|
def _get_centre(self) -> QVector2D:
|
|
@@ -118,6 +132,7 @@ class Camera(QObject):
|
|
|
118
132
|
x, y = _pair(value)
|
|
119
133
|
if (x, y) == (self._centre.x(), self._centre.y()):
|
|
120
134
|
return
|
|
135
|
+
self._stop_animation()
|
|
121
136
|
self._centre = QVector2D(x, y)
|
|
122
137
|
self._home = False
|
|
123
138
|
self._view_changed()
|
|
@@ -144,6 +159,7 @@ class Camera(QObject):
|
|
|
144
159
|
raise ValueError(f"aspect must be positive, got {value!r}")
|
|
145
160
|
if value == self._aspect:
|
|
146
161
|
return
|
|
162
|
+
self._stop_animation()
|
|
147
163
|
old_y = self.scale_y
|
|
148
164
|
self._aspect = value
|
|
149
165
|
self._home = False
|
|
@@ -173,6 +189,7 @@ class Camera(QObject):
|
|
|
173
189
|
value = min(max(value, self.MIN_SCALE), self.MAX_SCALE)
|
|
174
190
|
if value == self._zoom:
|
|
175
191
|
return
|
|
192
|
+
self._stop_animation()
|
|
176
193
|
factor = value / self._zoom
|
|
177
194
|
self._zoom = value
|
|
178
195
|
if keep_ratio and isinstance(self._aspect, str):
|
|
@@ -195,6 +212,7 @@ class Camera(QObject):
|
|
|
195
212
|
width, _ = self._effective_size()
|
|
196
213
|
if not hi > lo or width <= 0:
|
|
197
214
|
return
|
|
215
|
+
self._stop_animation()
|
|
198
216
|
self._centre = QVector2D(0.5 * (lo + hi), self._centre.y())
|
|
199
217
|
self._scale_to((hi - lo) / width) # keeps the ratio: y follows the same factor
|
|
200
218
|
|
|
@@ -208,6 +226,7 @@ class Camera(QObject):
|
|
|
208
226
|
_, height = self._effective_size()
|
|
209
227
|
if not hi > lo or height <= 0:
|
|
210
228
|
return
|
|
229
|
+
self._stop_animation()
|
|
211
230
|
self._centre = QVector2D(self._centre.x(), 0.5 * (lo + hi))
|
|
212
231
|
if isinstance(self._aspect, str):
|
|
213
232
|
self._y_scale = min(max((hi - lo) / height, self.MIN_SCALE), self.MAX_SCALE)
|
|
@@ -227,16 +246,30 @@ class Camera(QObject):
|
|
|
227
246
|
viewport: QVector2D = Property("QVariant", _get_viewport, notify=viewChanged)
|
|
228
247
|
|
|
229
248
|
# --------------------------------------------------------------- ticks
|
|
249
|
+
def _ensure_ticks(self) -> None:
|
|
250
|
+
"""Recompute both axes once per distinct view; the two properties share the result."""
|
|
251
|
+
xlim, ylim = self._get_xlim(), self._get_ylim()
|
|
252
|
+
key = (xlim.x(), xlim.y(), ylim.x(), ylim.y())
|
|
253
|
+
if key == self._ticks_key:
|
|
254
|
+
return
|
|
255
|
+
self._ticks_key = key
|
|
256
|
+
self._ticks_x = nice_ticks(xlim.x(), xlim.y())
|
|
257
|
+
self._ticks_y = nice_ticks(ylim.x(), ylim.y())
|
|
258
|
+
self._ticks_x_qml = _qml_pairs(self._ticks_x)
|
|
259
|
+
self._ticks_y_qml = _qml_pairs(self._ticks_y)
|
|
260
|
+
|
|
230
261
|
def tick_values(self) -> tuple[list[tuple[float, str]], list[tuple[float, str]]]:
|
|
231
262
|
"""Ticks for both axes of the visible range, as ``(value, label)`` pairs."""
|
|
232
|
-
|
|
233
|
-
return
|
|
263
|
+
self._ensure_ticks()
|
|
264
|
+
return self._ticks_x, self._ticks_y
|
|
234
265
|
|
|
235
266
|
def _get_ticks_x(self) -> list[list[float | str]]:
|
|
236
|
-
|
|
267
|
+
self._ensure_ticks()
|
|
268
|
+
return self._ticks_x_qml
|
|
237
269
|
|
|
238
270
|
def _get_ticks_y(self) -> list[list[float | str]]:
|
|
239
|
-
|
|
271
|
+
self._ensure_ticks()
|
|
272
|
+
return self._ticks_y_qml
|
|
240
273
|
|
|
241
274
|
ticks_x: list[tuple[float, str]] = Property("QVariant", _get_ticks_x, notify=ticksChanged)
|
|
242
275
|
ticks_y: list[tuple[float, str]] = Property("QVariant", _get_ticks_y, notify=ticksChanged)
|
|
@@ -283,9 +316,147 @@ class Camera(QObject):
|
|
|
283
316
|
bool, _get_zoom_enabled, _set_zoom_enabled, notify=zoomEnabledChanged
|
|
284
317
|
)
|
|
285
318
|
|
|
319
|
+
def _get_input(self) -> ViewInput:
|
|
320
|
+
return self._input
|
|
321
|
+
|
|
322
|
+
#: Keyboard bindings, focus switch and pan step (see :class:`ViewInput`).
|
|
323
|
+
input: ViewInput = Property(ViewInput, _get_input, constant=True)
|
|
324
|
+
|
|
325
|
+
def _get_animation_duration(self) -> int:
|
|
326
|
+
return self._animation_duration
|
|
327
|
+
|
|
328
|
+
def _set_animation_duration(self, value: int) -> None:
|
|
329
|
+
value = int(value)
|
|
330
|
+
if value < 0:
|
|
331
|
+
raise ValueError(f"animationDuration must be >= 0, got {value!r}")
|
|
332
|
+
if value == self._animation_duration:
|
|
333
|
+
return
|
|
334
|
+
self._animation_duration = value
|
|
335
|
+
self.animationDurationChanged.emit()
|
|
336
|
+
|
|
337
|
+
#: Duration of a wheel/keyboard camera move in ms; 0 disables the animation.
|
|
338
|
+
animationDuration: int = Property(
|
|
339
|
+
int, _get_animation_duration, _set_animation_duration, notify=animationDurationChanged
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# --------------------------------------------------------------- animation
|
|
343
|
+
def _state(self) -> tuple[float, float, float, float]:
|
|
344
|
+
return (self._centre.x(), self._centre.y(), self._zoom, self._get_scale_y())
|
|
345
|
+
|
|
346
|
+
def _clamp_state(
|
|
347
|
+
self, centre_x: float, centre_y: float, scale_x: float, scale_y: float
|
|
348
|
+
) -> tuple[float, float, float, float]:
|
|
349
|
+
return (
|
|
350
|
+
float(centre_x),
|
|
351
|
+
float(centre_y),
|
|
352
|
+
min(max(float(scale_x), self.MIN_SCALE), self.MAX_SCALE),
|
|
353
|
+
min(max(float(scale_y), self.MIN_SCALE), self.MAX_SCALE),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def _apply_view(
|
|
357
|
+
self, centre_x: float, centre_y: float, scale_x: float, scale_y: float
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Apply a state without stopping the animation that produced it."""
|
|
360
|
+
centre_x, centre_y, scale_x, scale_y = self._clamp_state(
|
|
361
|
+
centre_x, centre_y, scale_x, scale_y
|
|
362
|
+
)
|
|
363
|
+
if (centre_x, centre_y, scale_x, scale_y) == self._state():
|
|
364
|
+
return
|
|
365
|
+
self._centre = QVector2D(centre_x, centre_y)
|
|
366
|
+
self._zoom = scale_x
|
|
367
|
+
if isinstance(self._aspect, str):
|
|
368
|
+
self._y_scale = scale_y
|
|
369
|
+
self._home = False
|
|
370
|
+
self._view_changed()
|
|
371
|
+
|
|
372
|
+
def _target_state(self) -> State:
|
|
373
|
+
"""The state an in-flight animation is heading for (accumulate rapid wheel/key
|
|
374
|
+
presses); the current state otherwise."""
|
|
375
|
+
return self._animator.target(self._state())
|
|
376
|
+
|
|
377
|
+
def _stop_animation(self) -> None:
|
|
378
|
+
self._animator.stop()
|
|
379
|
+
|
|
380
|
+
@Slot()
|
|
381
|
+
def stop_animation(self) -> None:
|
|
382
|
+
"""Cancel an in-flight animated move (called by the view on direct manipulation)."""
|
|
383
|
+
self._animator.stop()
|
|
384
|
+
|
|
385
|
+
def _apply_state(self, state: State) -> None:
|
|
386
|
+
self._apply_view(*state)
|
|
387
|
+
|
|
388
|
+
def _animate_to(self, state: State, duration: int) -> None:
|
|
389
|
+
self._animator.start(self._state(), self._clamp_state(*state), duration)
|
|
390
|
+
|
|
391
|
+
def _zoom_state(
|
|
392
|
+
self,
|
|
393
|
+
base: tuple[float, float, float, float],
|
|
394
|
+
factor: float,
|
|
395
|
+
u: float,
|
|
396
|
+
v: float,
|
|
397
|
+
width: float,
|
|
398
|
+
height: float,
|
|
399
|
+
) -> tuple[float, float, float, float]:
|
|
400
|
+
"""The state after scaling ``base`` by ``factor`` with (u, v) held fixed."""
|
|
401
|
+
bx, by, bsx, bsy = base
|
|
402
|
+
tsx = min(max(bsx * factor, self.MIN_SCALE), self.MAX_SCALE)
|
|
403
|
+
actual = tsx / bsx
|
|
404
|
+
if isinstance(self._aspect, str):
|
|
405
|
+
tsy = min(max(bsy * actual, self.MIN_SCALE), self.MAX_SCALE)
|
|
406
|
+
else:
|
|
407
|
+
tsy = tsx / float(self._aspect)
|
|
408
|
+
if width > 0 and height > 0:
|
|
409
|
+
anchor_x = bx + (u - 0.5) * width * bsx
|
|
410
|
+
anchor_y = by + (0.5 - v) * height * bsy
|
|
411
|
+
tx = anchor_x - (u - 0.5) * width * tsx
|
|
412
|
+
ty = anchor_y - (0.5 - v) * height * tsy
|
|
413
|
+
else:
|
|
414
|
+
tx, ty = bx, by
|
|
415
|
+
return self._clamp_state(tx, ty, tsx, tsy)
|
|
416
|
+
|
|
417
|
+
@Slot(float, float, float, float, float)
|
|
418
|
+
def zoom_at(self, factor: float, u: float, v: float, width: float, height: float) -> None:
|
|
419
|
+
"""Magnify the view by ``factor`` (>1 closer, <1 farther) with (u, v) held fixed.
|
|
420
|
+
|
|
421
|
+
The primitive behind a pinch: the gesture supplies the incremental magnification.
|
|
422
|
+
"""
|
|
423
|
+
factor = float(factor)
|
|
424
|
+
if not self._zoom_enabled or factor <= 0.0 or factor == 1.0:
|
|
425
|
+
return
|
|
426
|
+
self._stop_animation()
|
|
427
|
+
self._apply_view(*self._zoom_state(self._state(), 1.0 / factor, u, v, width, height))
|
|
428
|
+
|
|
429
|
+
@Slot(float, float, float, float, float, int)
|
|
430
|
+
def animate_zoom(
|
|
431
|
+
self, delta: float, u: float, v: float, width: float, height: float, duration: int
|
|
432
|
+
) -> None:
|
|
433
|
+
"""Animate one wheel/keyboard zoom step (``delta`` wheel units, 120 = one notch)."""
|
|
434
|
+
if not self._zoom_enabled or delta == 0:
|
|
435
|
+
return
|
|
436
|
+
factor = self._zoom_step ** (float(delta) / 120.0)
|
|
437
|
+
self._animate_to(
|
|
438
|
+
self._zoom_state(self._target_state(), factor, u, v, width, height), duration
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
@Slot(float, float, int)
|
|
442
|
+
def animate_pan(self, dx: float, dy: float, duration: int) -> None:
|
|
443
|
+
"""Animate a pan by a pixel displacement (screen y points down)."""
|
|
444
|
+
if not self._pan_enabled or (dx == 0.0 and dy == 0.0):
|
|
445
|
+
return
|
|
446
|
+
bx, by, bsx, bsy = self._target_state()
|
|
447
|
+
self._animate_to((bx - dx * bsx, by + dy * bsy, bsx, bsy), duration)
|
|
448
|
+
|
|
286
449
|
def _view_changed(self) -> None:
|
|
450
|
+
previous_key = self._ticks_key
|
|
451
|
+
previous_x, previous_y = self._ticks_x, self._ticks_y
|
|
452
|
+
self._ensure_ticks()
|
|
287
453
|
self.viewChanged.emit()
|
|
288
|
-
|
|
454
|
+
# During a pan the tick labels usually stay the same; re-emitting TicksChanged would
|
|
455
|
+
# rebuild QML's tick Repeaters every frame for identical rows.
|
|
456
|
+
if (previous_key is None
|
|
457
|
+
or previous_x != self._ticks_x
|
|
458
|
+
or previous_y != self._ticks_y):
|
|
459
|
+
self.ticksChanged.emit()
|
|
289
460
|
|
|
290
461
|
@Slot(float, float, float, float)
|
|
291
462
|
def set_view(self, centre_x: float, centre_y: float, scale_x: float, scale_y: float) -> None:
|
|
@@ -296,6 +467,7 @@ class Camera(QObject):
|
|
|
296
467
|
and restoring it afterwards. ``"auto"`` stays "auto" (the scales are simply set), a
|
|
297
468
|
numeric aspect is re-derived from the pair so the two scales are exactly as asked.
|
|
298
469
|
"""
|
|
470
|
+
self._stop_animation()
|
|
299
471
|
scale_x = min(max(float(scale_x), self.MIN_SCALE), self.MAX_SCALE)
|
|
300
472
|
scale_y = min(max(float(scale_y), self.MIN_SCALE), self.MAX_SCALE)
|
|
301
473
|
self._centre = QVector2D(float(centre_x), float(centre_y))
|
|
@@ -310,7 +482,7 @@ class Camera(QObject):
|
|
|
310
482
|
|
|
311
483
|
# --------------------------------------------------------------- slots
|
|
312
484
|
@Slot(float, float)
|
|
313
|
-
def
|
|
485
|
+
def set_viewport(self, width: float, height: float) -> None:
|
|
314
486
|
"""Record the widget size (logical pixels) — used by the derived ranges only.
|
|
315
487
|
|
|
316
488
|
Drawing never depends on it: the shader derives its mapping from the camera and its
|
|
@@ -322,6 +494,7 @@ class Camera(QObject):
|
|
|
322
494
|
size = (float(width), float(height))
|
|
323
495
|
if size == self._size:
|
|
324
496
|
return
|
|
497
|
+
self._stop_animation()
|
|
325
498
|
self._size = size
|
|
326
499
|
self._resolve_home()
|
|
327
500
|
self._view_changed()
|
|
@@ -352,42 +525,23 @@ class Camera(QObject):
|
|
|
352
525
|
"""
|
|
353
526
|
if not self._zoom_enabled or delta == 0:
|
|
354
527
|
return
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
factor
|
|
358
|
-
if factor == 1.0:
|
|
359
|
-
return
|
|
360
|
-
if width > 0 and height > 0:
|
|
361
|
-
anchor_x = self._centre.x() + (u - 0.5) * width * self.scale_x
|
|
362
|
-
anchor_y = self._centre.y() + (0.5 - v) * height * self.scale_y
|
|
363
|
-
else:
|
|
364
|
-
anchor_x, anchor_y = self._centre.x(), self._centre.y()
|
|
365
|
-
self._zoom = target
|
|
366
|
-
if isinstance(self._aspect, str):
|
|
367
|
-
self._y_scale = min(max(self._y_scale * factor, self.MIN_SCALE), self.MAX_SCALE)
|
|
368
|
-
if width > 0 and height > 0:
|
|
369
|
-
self._centre = QVector2D(
|
|
370
|
-
anchor_x - (u - 0.5) * width * self.scale_x,
|
|
371
|
-
anchor_y - (0.5 - v) * height * self.scale_y,
|
|
372
|
-
)
|
|
373
|
-
self._home = False
|
|
374
|
-
self._view_changed()
|
|
528
|
+
factor = self._zoom_step ** (float(delta) / 120.0)
|
|
529
|
+
self._stop_animation()
|
|
530
|
+
self._apply_view(*self._zoom_state(self._state(), factor, u, v, width, height))
|
|
375
531
|
|
|
376
532
|
@Slot(float, float)
|
|
377
533
|
def pan_pixels(self, dx: float, dy: float) -> None:
|
|
378
534
|
"""Pan by a pixel displacement (screen y points down, world y points up)."""
|
|
379
535
|
if not self._pan_enabled or (dx == 0.0 and dy == 0.0):
|
|
380
536
|
return
|
|
381
|
-
self.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
)
|
|
385
|
-
self._home = False
|
|
386
|
-
self._view_changed()
|
|
537
|
+
self._stop_animation()
|
|
538
|
+
cx, cy, sx, sy = self._state()
|
|
539
|
+
self._apply_view(cx - dx * sx, cy + dy * sy, sx, sy)
|
|
387
540
|
|
|
388
541
|
@Slot()
|
|
389
542
|
def reset(self) -> None:
|
|
390
543
|
"""Back to the home view (centre and scales as the camera was configured)."""
|
|
544
|
+
self._stop_animation()
|
|
391
545
|
self._centre = QVector2D(0.0, 0.0)
|
|
392
546
|
self._home = True
|
|
393
547
|
self._resolve_home()
|
qmlmathplot/canvas.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Matplotlib-shaped canvas: a QWidget that hosts one compatibility :class:`Figure`.
|
|
2
|
+
|
|
3
|
+
QtWidgets is imported here, so this module stays lazy in ``qmlmathplot.__init__`` exactly like
|
|
4
|
+
:mod:`qmlmathplot.widget`: a pure-QML app never loads QtWidgets.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from PySide6.QtWidgets import QWidget
|
|
12
|
+
|
|
13
|
+
from .widget import MathPlotWidget
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
16
|
+
from .figure import Figure
|
|
17
|
+
|
|
18
|
+
__all__ = ["FigureCanvas", "FigureCanvasQt"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FigureCanvasQt(MathPlotWidget):
|
|
22
|
+
"""A ``MathPlotWidget`` attached to a compatibility ``Figure``.
|
|
23
|
+
|
|
24
|
+
Matplotlib code constructs canvases and then calls ``draw()``; here the Qt scene is live,
|
|
25
|
+
so ``draw()`` / ``draw_idle()`` are harmless no-ops. ``print_figure`` and ``savefig``
|
|
26
|
+
forward to the same region-based export as :meth:`Figure.savefig`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
figure: Figure | None = None,
|
|
32
|
+
parent: QWidget | None = None,
|
|
33
|
+
*,
|
|
34
|
+
width: int | None = None,
|
|
35
|
+
height: int | None = None,
|
|
36
|
+
dpi: float | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
from .figure import Figure
|
|
39
|
+
|
|
40
|
+
host = figure if figure is not None else Figure()
|
|
41
|
+
if dpi is not None:
|
|
42
|
+
host.set_dpi(dpi)
|
|
43
|
+
super().__init__(None, parent, plot=host.plot)
|
|
44
|
+
self._figure = host
|
|
45
|
+
host.canvas = self
|
|
46
|
+
target_width, target_height = host.get_size_pixels()
|
|
47
|
+
if width is not None:
|
|
48
|
+
target_width = int(width)
|
|
49
|
+
if height is not None:
|
|
50
|
+
target_height = int(height)
|
|
51
|
+
self.resize(target_width, target_height)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def figure(self) -> "Figure":
|
|
55
|
+
return self._figure
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def figure_canvas(self) -> "FigureCanvasQt":
|
|
59
|
+
"""Matplotlib's canvas property is called ``figure_canvas`` on some backends; this
|
|
60
|
+
alias keeps both spellings working."""
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------- live scene
|
|
64
|
+
def draw(self) -> None:
|
|
65
|
+
"""No-op: the scene repaints from model signals; there is no deferred renderer."""
|
|
66
|
+
self.update()
|
|
67
|
+
|
|
68
|
+
def draw_idle(self) -> None:
|
|
69
|
+
self.update()
|
|
70
|
+
|
|
71
|
+
def flush_events(self) -> None:
|
|
72
|
+
"""No-op: Qt owns the event loop."""
|
|
73
|
+
|
|
74
|
+
# -------------------------------------------------------------- export
|
|
75
|
+
def print_figure(self, filename: str, **kwargs: object) -> None:
|
|
76
|
+
self._figure.savefig(filename, **kwargs)
|
|
77
|
+
|
|
78
|
+
def savefig(self, filename: str, **kwargs: object) -> None:
|
|
79
|
+
self._figure.savefig(filename, **kwargs)
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------- geometry
|
|
82
|
+
def get_width_height(self, physical: bool = False) -> tuple[int, int]:
|
|
83
|
+
if physical:
|
|
84
|
+
ratio = self.devicePixelRatioF()
|
|
85
|
+
return (round(self.width() * ratio), round(self.height() * ratio))
|
|
86
|
+
return (self.width(), self.height())
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def device_pixel_ratio(self) -> float:
|
|
90
|
+
return self.devicePixelRatioF()
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------ explicitly absent
|
|
93
|
+
def mpl_connect(self, *args: object, **kwargs: object) -> None:
|
|
94
|
+
raise NotImplementedError(
|
|
95
|
+
"mpl_connect is not part of QMLMathPlot: Qt signals are the event system "
|
|
96
|
+
"(camera.viewChanged, camera.aspectChanged, curve.expressionChanged, …)"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def blit(self, *args: object, **kwargs: object) -> None:
|
|
100
|
+
raise NotImplementedError(
|
|
101
|
+
"blit is not part of QMLMathPlot: the Qt scene is live, there is no raster "
|
|
102
|
+
"buffer to restore"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
#: The shorter spelling used by ``matplotlib.backends.backend_qt*``.
|
|
107
|
+
FigureCanvas = FigureCanvasQt
|