qmlmathplot 0.1.0__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 +84 -0
- qmlmathplot/app.py +79 -0
- qmlmathplot/camera.py +366 -0
- qmlmathplot/curve.py +296 -0
- qmlmathplot/model.py +541 -0
- qmlmathplot/plot.py +213 -0
- qmlmathplot/qml/PlotView.qml +247 -0
- qmlmathplot/qsb.py +96 -0
- qmlmathplot/themes/LICENSE.matplotlib +99 -0
- qmlmathplot/themes/README.md +15 -0
- qmlmathplot/themes/Solarize_Light2.mplstyle +53 -0
- qmlmathplot/themes/bmh.mplstyle +27 -0
- qmlmathplot/themes/classic.mplstyle +491 -0
- qmlmathplot/themes/dark_background.mplstyle +26 -0
- qmlmathplot/themes/fast.mplstyle +11 -0
- qmlmathplot/themes/fivethirtyeight.mplstyle +37 -0
- qmlmathplot/themes/ggplot.mplstyle +39 -0
- qmlmathplot/themes/grayscale.mplstyle +29 -0
- qmlmathplot/themes/petroff10.mplstyle +5 -0
- qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +21 -0
- qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +21 -0
- qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +21 -0
- qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +21 -0
- qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8-white.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8.mplstyle +57 -0
- qmlmathplot/themes/tableau-colorblind10.mplstyle +3 -0
- qmlmathplot/themes.py +236 -0
- qmlmathplot/view.py +55 -0
- qmlmathplot/widget.py +183 -0
- qmlmathplot-0.1.0.dist-info/METADATA +383 -0
- qmlmathplot-0.1.0.dist-info/RECORD +43 -0
- qmlmathplot-0.1.0.dist-info/WHEEL +4 -0
- qmlmathplot-0.1.0.dist-info/entry_points.txt +3 -0
qmlmathplot/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""QMLMathPlot — an embeddable function plotter for Qt (Qt Quick and QtWidgets).
|
|
2
|
+
|
|
3
|
+
The plot is an **infinite canvas with a camera**: the camera holds where you are looking
|
|
4
|
+
(centre, zoom, aspect) and the visible range follows from it, so resizing a widget shows more
|
|
5
|
+
or less canvas instead of distorting or re-zooming the curve. Curves are expressions evaluated
|
|
6
|
+
per pixel in a fragment shader, so the cost does not depend on the function's frequency.
|
|
7
|
+
|
|
8
|
+
QtWidgets layout in one line:
|
|
9
|
+
|
|
10
|
+
from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget
|
|
11
|
+
from qmlmathplot import MathPlotWidget
|
|
12
|
+
|
|
13
|
+
app = QApplication([])
|
|
14
|
+
window = QWidget()
|
|
15
|
+
box = QVBoxLayout(window)
|
|
16
|
+
plot = MathPlotWidget("sin(1/x)")
|
|
17
|
+
box.addWidget(plot)
|
|
18
|
+
window.show()
|
|
19
|
+
app.exec()
|
|
20
|
+
|
|
21
|
+
Qt Quick (QML) — the component plus the model:
|
|
22
|
+
|
|
23
|
+
import QmlMathPlot 1.0
|
|
24
|
+
PlotView { plot: myPlot; anchors.fill: parent }
|
|
25
|
+
|
|
26
|
+
from PySide6.QtGui import QGuiApplication
|
|
27
|
+
from PySide6.QtQuick import QQuickView
|
|
28
|
+
from PySide6.QtCore import QUrl
|
|
29
|
+
from qmlmathplot import Plot, qml_component_path, register_qml_types
|
|
30
|
+
|
|
31
|
+
app = QGuiApplication([])
|
|
32
|
+
register_qml_types()
|
|
33
|
+
view = QQuickView()
|
|
34
|
+
view.setSource(QUrl.fromLocalFile(qml_component_path()))
|
|
35
|
+
view.rootObject().setProperty("plot", Plot())
|
|
36
|
+
view.show()
|
|
37
|
+
app.exec()
|
|
38
|
+
|
|
39
|
+
Model, without Qt widgets:
|
|
40
|
+
|
|
41
|
+
plot = Plot()
|
|
42
|
+
plot.xlim, plot.ylim = (-6.0, 6.0), (-2.0, 2.0)
|
|
43
|
+
curve = plot.add_curve("sin(1/x)", label="sin(1/x)")
|
|
44
|
+
curve.expression = "sin(2/x)" # one signal -> re-bake -> the view swaps it
|
|
45
|
+
plot.theme = "ggplot" # Matplotlib's style sheets, see qmlmathplot.themes
|
|
46
|
+
|
|
47
|
+
Demos: ``examples/minimal.py``, ``examples/explorer_qtwidgets.py``,
|
|
48
|
+
``examples/explorer_qtquick.py``. Design notes: ``docs/api-design.md``.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from typing import TYPE_CHECKING
|
|
52
|
+
|
|
53
|
+
from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
|
|
54
|
+
from .curve import Curve, CurveListModel
|
|
55
|
+
from .plot import Plot
|
|
56
|
+
from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING: # only for type checkers; at runtime the module-level __getattr__ is used
|
|
59
|
+
from .widget import MathPlotWidget
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"Camera",
|
|
63
|
+
"Curve",
|
|
64
|
+
"CurveListModel",
|
|
65
|
+
"HOME_SIZE",
|
|
66
|
+
"HOME_VIEW",
|
|
67
|
+
"MathPlotWidget",
|
|
68
|
+
"nice_ticks",
|
|
69
|
+
"Plot",
|
|
70
|
+
"qml_component_path",
|
|
71
|
+
"QML_MAJOR",
|
|
72
|
+
"QML_MINOR",
|
|
73
|
+
"QML_URI",
|
|
74
|
+
"register_qml_types",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def __getattr__(name: str) -> object:
|
|
79
|
+
"""``MathPlotWidget`` is imported lazily, so pure-QML use never loads QtWidgets."""
|
|
80
|
+
if name == "MathPlotWidget":
|
|
81
|
+
from .widget import MathPlotWidget
|
|
82
|
+
|
|
83
|
+
return MathPlotWidget
|
|
84
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
qmlmathplot/app.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""MVP entry point: wraps the component into a minimal verifiable window (called by `main.py` /
|
|
2
|
+
`uv run qmlmathplot`).
|
|
3
|
+
|
|
4
|
+
python main.py --backend d3d11 "sin(1/x)"
|
|
5
|
+
|
|
6
|
+
The RHI backend comes from the launch argument; without it, Qt's default backend is used. When
|
|
7
|
+
specified it must be written to ``QSG_RHI_BACKEND`` *before* ``QGuiApplication`` — Qt reads it
|
|
8
|
+
during platform initialization, so calling ``QQuickWindow.setGraphicsApi()`` later has no effect
|
|
9
|
+
(the window's surface has already been created for the default backend).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from PySide6.QtCore import QUrl
|
|
19
|
+
from PySide6.QtGui import QGuiApplication
|
|
20
|
+
from PySide6.QtQuick import QQuickView
|
|
21
|
+
|
|
22
|
+
from .plot import Plot
|
|
23
|
+
from .view import qml_component_path, register_qml_types
|
|
24
|
+
|
|
25
|
+
# Valid QSG_RHI_BACKEND values (Qt 6); unset = Qt default (d3d11 on Windows)
|
|
26
|
+
BACKENDS = ("d3d11", "d3d12", "vulkan", "metal", "opengl", "null")
|
|
27
|
+
|
|
28
|
+
DEFAULT_EXPRESSION = "sin(x)"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="qmlmathplot",
|
|
34
|
+
description="Minimal verifiable window for QMLMathPlot (drag to pan, wheel to zoom).",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("expression", nargs="?", default=DEFAULT_EXPRESSION,
|
|
37
|
+
help=f"expression in sympy syntax (default {DEFAULT_EXPRESSION})")
|
|
38
|
+
parser.add_argument("--backend", choices=BACKENDS, default=None,
|
|
39
|
+
help="RHI backend for Qt Quick; Qt's default when not specified")
|
|
40
|
+
parser.add_argument("--width", type=int, default=900, help="window width (default 900)")
|
|
41
|
+
parser.add_argument("--height", type=int, default=600, help="window height (default 600)")
|
|
42
|
+
return parser
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv: list[str] | None = None) -> int:
|
|
46
|
+
args = build_parser().parse_args(sys.argv[1:] if argv is None else argv)
|
|
47
|
+
|
|
48
|
+
if args.backend is not None:
|
|
49
|
+
# An explicitly given backend wins over the environment: the command line overrides all
|
|
50
|
+
os.environ["QSG_RHI_BACKEND"] = args.backend
|
|
51
|
+
|
|
52
|
+
app = QGuiApplication(sys.argv[:1])
|
|
53
|
+
register_qml_types()
|
|
54
|
+
|
|
55
|
+
view = QQuickView()
|
|
56
|
+
view.setResizeMode(QQuickView.ResizeMode.SizeRootObjectToView)
|
|
57
|
+
view.setTitle(f"QMLMathPlot — {args.expression}")
|
|
58
|
+
view.resize(args.width, args.height)
|
|
59
|
+
view.setSource(QUrl.fromLocalFile(qml_component_path()))
|
|
60
|
+
if view.status() is not QQuickView.Status.Ready:
|
|
61
|
+
for err in view.errors():
|
|
62
|
+
print(err.toString(), file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
root = view.rootObject()
|
|
66
|
+
if root is None:
|
|
67
|
+
print("failed to create the QML root object", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
# MVVM: the app side owns the model and injects it into the component (which also
|
|
71
|
+
# brings its own when nothing is injected)
|
|
72
|
+
plot = Plot()
|
|
73
|
+
plot.add_curve(args.expression)
|
|
74
|
+
root.setProperty("plot", plot)
|
|
75
|
+
|
|
76
|
+
view.show()
|
|
77
|
+
print(f"RHI backend: {view.graphicsApi()} (QSG_RHI_BACKEND={os.environ.get('QSG_RHI_BACKEND', 'not set')})",
|
|
78
|
+
flush=True)
|
|
79
|
+
return app.exec()
|
qmlmathplot/camera.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Camera layer: where we look at the infinite canvas, plus the nice-number tick algorithm.
|
|
2
|
+
|
|
3
|
+
The camera stores three numbers that do not depend on the widget at all — ``centre``,
|
|
4
|
+
``zoom`` (world units per *logical* pixel on x) and ``aspect`` (the ratio of the y scale to
|
|
5
|
+
the x scale; ``"auto"`` leaves it to the widget's shape). The visible range (``xlim`` /
|
|
6
|
+
``ylim``) is *derived* from them and the last reported size, so resizing changes what is
|
|
7
|
+
visible without touching the state — which is what makes an infinite canvas cheap.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import Property, QObject, Signal, Slot
|
|
15
|
+
from PySide6.QtGui import QVector2D
|
|
16
|
+
|
|
17
|
+
__all__ = ["HOME_SIZE", "HOME_VIEW", "Camera", "nice_ticks"]
|
|
18
|
+
|
|
19
|
+
#: Home view — the classic 12x4 window — as (xmin, xmax, ymin, ymax).
|
|
20
|
+
#:
|
|
21
|
+
#: The home is a *view*, but the camera stores scales, so the two meet at a reference size:
|
|
22
|
+
#: the first reported size turns the home view into (zoom, y scale) for that widget, and
|
|
23
|
+
#: the constants below are only the fallback used before any size is known.
|
|
24
|
+
HOME_VIEW = (-6.0, 6.0, -2.0, 2.0)
|
|
25
|
+
#: Reference size for the home view (before the first ``setViewport``).
|
|
26
|
+
HOME_SIZE = (900.0, 600.0)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _pair(value: object) -> tuple[float, float]:
|
|
30
|
+
"""Two floats from a QVector2D, a QPointF or any 2-sequence."""
|
|
31
|
+
if isinstance(value, (QVector2D,)):
|
|
32
|
+
return (value.x(), value.y())
|
|
33
|
+
x, y = value # type: ignore[misc] # documented: any pair-like value
|
|
34
|
+
return (float(x), float(y))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def nice_ticks(lo: float, hi: float, target: int = 8) -> list[tuple[float, str]]:
|
|
38
|
+
"""Ticks covering ``[lo, hi]`` at nice-number steps (1/2/5 x 10^n), about ``target`` of
|
|
39
|
+
them, as ``(value, label)``.
|
|
40
|
+
|
|
41
|
+
Pure and Qt-free: the tick values are decided once per camera change (not per frame) and
|
|
42
|
+
QML only positions the labels it is given.
|
|
43
|
+
"""
|
|
44
|
+
if not hi > lo or target < 1:
|
|
45
|
+
return []
|
|
46
|
+
raw = (hi - lo) / target
|
|
47
|
+
magnitude = 10.0 ** math.floor(math.log10(raw))
|
|
48
|
+
for multiple in (1.0, 2.0, 5.0, 10.0):
|
|
49
|
+
if raw <= multiple * magnitude:
|
|
50
|
+
step = multiple * magnitude
|
|
51
|
+
break
|
|
52
|
+
else: # pragma: no cover - the 10.0 branch always matches
|
|
53
|
+
step = 10.0 * magnitude
|
|
54
|
+
|
|
55
|
+
decimals = max(0, -math.floor(math.log10(step) + 1e-9))
|
|
56
|
+
first = math.ceil(lo / step - 1e-9) * step
|
|
57
|
+
out: list[tuple[float, str]] = []
|
|
58
|
+
for i in range(int(math.floor((hi - first) / step + 1e-9)) + 1):
|
|
59
|
+
# Round to the step's own precision: repeated addition accumulates noise (0.6000000000000001)
|
|
60
|
+
# and the label would then disagree with the position.
|
|
61
|
+
value = round(first + i * step, decimals)
|
|
62
|
+
if abs(value) < step * 1e-6:
|
|
63
|
+
value = 0.0 # no "-0" labels
|
|
64
|
+
out.append((value, _tick_label(value, step, decimals)))
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tick_label(value: float, step: float, decimals: int) -> str:
|
|
69
|
+
if abs(value) >= 1e7 or (value != 0.0 and abs(step) < 1e-7):
|
|
70
|
+
return f"{value:g}"
|
|
71
|
+
return f"{value:.{decimals}f}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _qml_pairs(ticks: list[tuple[float, str]]) -> list[list[float | str]]:
|
|
75
|
+
"""Ticks as two-element *lists* for QML: a Python tuple crosses a QVariant as an opaque
|
|
76
|
+
value (QML cannot index it), so the pairs have to be lists to be readable there."""
|
|
77
|
+
return [[value, label] for value, label in ticks]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Camera(QObject):
|
|
81
|
+
"""The view onto the infinite canvas: centre, zoom (units per logical pixel) and aspect.
|
|
82
|
+
|
|
83
|
+
``aspect`` is the ratio of the y scale to the x scale, so ``1.0`` means square units (a
|
|
84
|
+
world circle is drawn round) and ``"auto"`` keeps the two scales independent, letting the
|
|
85
|
+
widget's shape decide how much canvas is visible.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
viewChanged = Signal()
|
|
89
|
+
aspectChanged = Signal()
|
|
90
|
+
ticksChanged = Signal()
|
|
91
|
+
zoomStepChanged = Signal()
|
|
92
|
+
panEnabledChanged = Signal()
|
|
93
|
+
zoomEnabledChanged = Signal()
|
|
94
|
+
|
|
95
|
+
#: Scale limits (world units per pixel), to keep panning/zooming reversible.
|
|
96
|
+
MIN_SCALE = 1e-12
|
|
97
|
+
MAX_SCALE = 1e12
|
|
98
|
+
|
|
99
|
+
def __init__(self, parent: QObject | None = None) -> None:
|
|
100
|
+
super().__init__(parent)
|
|
101
|
+
self._centre = QVector2D(0.0, 0.0)
|
|
102
|
+
self._zoom = (HOME_VIEW[1] - HOME_VIEW[0]) / HOME_SIZE[0]
|
|
103
|
+
self._aspect: str | float = "auto"
|
|
104
|
+
self._y_scale = (HOME_VIEW[3] - HOME_VIEW[2]) / HOME_SIZE[1]
|
|
105
|
+
self._size: tuple[float, float] | None = None
|
|
106
|
+
# True while the home *view* has not been resolved for a real size: the first report
|
|
107
|
+
# then sets the scales from it, so a window opened at any size shows the home window.
|
|
108
|
+
self._home = True
|
|
109
|
+
self._zoom_step = 0.9
|
|
110
|
+
self._pan_enabled = True
|
|
111
|
+
self._zoom_enabled = True
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------- state
|
|
114
|
+
def _get_centre(self) -> QVector2D:
|
|
115
|
+
return QVector2D(self._centre)
|
|
116
|
+
|
|
117
|
+
def _set_centre(self, value: object) -> None:
|
|
118
|
+
x, y = _pair(value)
|
|
119
|
+
if (x, y) == (self._centre.x(), self._centre.y()):
|
|
120
|
+
return
|
|
121
|
+
self._centre = QVector2D(x, y)
|
|
122
|
+
self._home = False
|
|
123
|
+
self._view_changed()
|
|
124
|
+
|
|
125
|
+
# "QVariant" rather than QVector2D so a plain ``(x, y)`` from Python is accepted too.
|
|
126
|
+
centre: QVector2D = Property("QVariant", _get_centre, _set_centre, notify=viewChanged)
|
|
127
|
+
|
|
128
|
+
def _get_zoom(self) -> float:
|
|
129
|
+
return self._zoom
|
|
130
|
+
|
|
131
|
+
def _set_zoom(self, value: float) -> None:
|
|
132
|
+
self._scale_to(float(value))
|
|
133
|
+
|
|
134
|
+
zoom: float = Property(float, _get_zoom, _set_zoom, notify=viewChanged)
|
|
135
|
+
|
|
136
|
+
def _get_aspect(self) -> str | float:
|
|
137
|
+
return self._aspect
|
|
138
|
+
|
|
139
|
+
def _set_aspect(self, value: str | float) -> None:
|
|
140
|
+
if isinstance(value, str):
|
|
141
|
+
if value != "auto":
|
|
142
|
+
raise ValueError(f'aspect must be "auto" or a positive number, got {value!r}')
|
|
143
|
+
elif not float(value) > 0:
|
|
144
|
+
raise ValueError(f"aspect must be positive, got {value!r}")
|
|
145
|
+
if value == self._aspect:
|
|
146
|
+
return
|
|
147
|
+
old_y = self.scale_y
|
|
148
|
+
self._aspect = value
|
|
149
|
+
self._home = False
|
|
150
|
+
if isinstance(value, str):
|
|
151
|
+
self._y_scale = old_y # switching to "auto" keeps the current shape
|
|
152
|
+
else:
|
|
153
|
+
# Expand, never crop: a numeric aspect may only show *more* than was visible.
|
|
154
|
+
self._scale_to(max(self._zoom, float(value) * old_y), keep_ratio=False)
|
|
155
|
+
self.aspectChanged.emit()
|
|
156
|
+
self._view_changed()
|
|
157
|
+
|
|
158
|
+
aspect: str | float = Property("QVariant", _get_aspect, _set_aspect, notify=aspectChanged)
|
|
159
|
+
|
|
160
|
+
def _get_scale_x(self) -> float:
|
|
161
|
+
return self._zoom
|
|
162
|
+
|
|
163
|
+
def _get_scale_y(self) -> float:
|
|
164
|
+
if isinstance(self._aspect, str):
|
|
165
|
+
return self._y_scale
|
|
166
|
+
return self._zoom / float(self._aspect)
|
|
167
|
+
|
|
168
|
+
scale_x: float = Property(float, _get_scale_x, notify=viewChanged)
|
|
169
|
+
scale_y: float = Property(float, _get_scale_y, notify=viewChanged)
|
|
170
|
+
|
|
171
|
+
def _scale_to(self, value: float, *, keep_ratio: bool = True) -> None:
|
|
172
|
+
"""Set the x scale; in "auto" mode both scales move together (a uniform zoom)."""
|
|
173
|
+
value = min(max(value, self.MIN_SCALE), self.MAX_SCALE)
|
|
174
|
+
if value == self._zoom:
|
|
175
|
+
return
|
|
176
|
+
factor = value / self._zoom
|
|
177
|
+
self._zoom = value
|
|
178
|
+
if keep_ratio and isinstance(self._aspect, str):
|
|
179
|
+
self._y_scale = min(max(self._y_scale * factor, self.MIN_SCALE), self.MAX_SCALE)
|
|
180
|
+
self._home = False
|
|
181
|
+
self._view_changed()
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------- derived ranges
|
|
184
|
+
def _effective_size(self) -> tuple[float, float]:
|
|
185
|
+
"""Size used for the derived ranges: the last reported one, or the reference."""
|
|
186
|
+
return self._size if self._size is not None else HOME_SIZE
|
|
187
|
+
|
|
188
|
+
def _get_xlim(self) -> QVector2D:
|
|
189
|
+
width, _ = self._effective_size()
|
|
190
|
+
half = 0.5 * width * self.scale_x
|
|
191
|
+
return QVector2D(self._centre.x() - half, self._centre.x() + half)
|
|
192
|
+
|
|
193
|
+
def _set_xlim(self, value: object) -> None:
|
|
194
|
+
lo, hi = _pair(value)
|
|
195
|
+
width, _ = self._effective_size()
|
|
196
|
+
if not hi > lo or width <= 0:
|
|
197
|
+
return
|
|
198
|
+
self._centre = QVector2D(0.5 * (lo + hi), self._centre.y())
|
|
199
|
+
self._scale_to((hi - lo) / width) # keeps the ratio: y follows the same factor
|
|
200
|
+
|
|
201
|
+
def _get_ylim(self) -> QVector2D:
|
|
202
|
+
_, height = self._effective_size()
|
|
203
|
+
half = 0.5 * height * self.scale_y
|
|
204
|
+
return QVector2D(self._centre.y() - half, self._centre.y() + half)
|
|
205
|
+
|
|
206
|
+
def _set_ylim(self, value: object) -> None:
|
|
207
|
+
lo, hi = _pair(value)
|
|
208
|
+
_, height = self._effective_size()
|
|
209
|
+
if not hi > lo or height <= 0:
|
|
210
|
+
return
|
|
211
|
+
self._centre = QVector2D(self._centre.x(), 0.5 * (lo + hi))
|
|
212
|
+
if isinstance(self._aspect, str):
|
|
213
|
+
self._y_scale = min(max((hi - lo) / height, self.MIN_SCALE), self.MAX_SCALE)
|
|
214
|
+
self._home = False
|
|
215
|
+
self._view_changed()
|
|
216
|
+
else:
|
|
217
|
+
self._scale_to((hi - lo) / height * float(self._aspect), keep_ratio=False)
|
|
218
|
+
|
|
219
|
+
xlim: QVector2D = Property("QVariant", _get_xlim, _set_xlim, notify=viewChanged)
|
|
220
|
+
ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
|
|
221
|
+
|
|
222
|
+
# --------------------------------------------------------------- ticks
|
|
223
|
+
def tick_values(self) -> tuple[list[tuple[float, str]], list[tuple[float, str]]]:
|
|
224
|
+
"""Ticks for both axes of the visible range, as ``(value, label)`` pairs."""
|
|
225
|
+
xlim, ylim = self._get_xlim(), self._get_ylim()
|
|
226
|
+
return nice_ticks(xlim.x(), xlim.y()), nice_ticks(ylim.x(), ylim.y())
|
|
227
|
+
|
|
228
|
+
def _get_ticks_x(self) -> list[list[float | str]]:
|
|
229
|
+
return _qml_pairs(self.tick_values()[0])
|
|
230
|
+
|
|
231
|
+
def _get_ticks_y(self) -> list[list[float | str]]:
|
|
232
|
+
return _qml_pairs(self.tick_values()[1])
|
|
233
|
+
|
|
234
|
+
ticks_x: list[tuple[float, str]] = Property("QVariant", _get_ticks_x, notify=ticksChanged)
|
|
235
|
+
ticks_y: list[tuple[float, str]] = Property("QVariant", _get_ticks_y, notify=ticksChanged)
|
|
236
|
+
|
|
237
|
+
# ----------------------------------------------------------- behaviour
|
|
238
|
+
def _get_zoom_step(self) -> float:
|
|
239
|
+
return self._zoom_step
|
|
240
|
+
|
|
241
|
+
def _set_zoom_step(self, value: float) -> None:
|
|
242
|
+
value = float(value)
|
|
243
|
+
if not 0.0 < value < 1.0:
|
|
244
|
+
raise ValueError(f"zoomStep must be in (0, 1), got {value!r}")
|
|
245
|
+
if value == self._zoom_step:
|
|
246
|
+
return
|
|
247
|
+
self._zoom_step = value
|
|
248
|
+
self.zoomStepChanged.emit()
|
|
249
|
+
|
|
250
|
+
#: Scale factor of one wheel notch (0.9 = 10% closer per notch).
|
|
251
|
+
zoomStep: float = Property(float, _get_zoom_step, _set_zoom_step, notify=zoomStepChanged)
|
|
252
|
+
|
|
253
|
+
def _get_pan_enabled(self) -> bool:
|
|
254
|
+
return self._pan_enabled
|
|
255
|
+
|
|
256
|
+
def _set_pan_enabled(self, value: bool) -> None:
|
|
257
|
+
value = bool(value)
|
|
258
|
+
if value == self._pan_enabled:
|
|
259
|
+
return
|
|
260
|
+
self._pan_enabled = value
|
|
261
|
+
self.panEnabledChanged.emit()
|
|
262
|
+
|
|
263
|
+
panEnabled: bool = Property(bool, _get_pan_enabled, _set_pan_enabled, notify=panEnabledChanged)
|
|
264
|
+
|
|
265
|
+
def _get_zoom_enabled(self) -> bool:
|
|
266
|
+
return self._zoom_enabled
|
|
267
|
+
|
|
268
|
+
def _set_zoom_enabled(self, value: bool) -> None:
|
|
269
|
+
value = bool(value)
|
|
270
|
+
if value == self._zoom_enabled:
|
|
271
|
+
return
|
|
272
|
+
self._zoom_enabled = value
|
|
273
|
+
self.zoomEnabledChanged.emit()
|
|
274
|
+
|
|
275
|
+
zoomEnabled: bool = Property(
|
|
276
|
+
bool, _get_zoom_enabled, _set_zoom_enabled, notify=zoomEnabledChanged
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _view_changed(self) -> None:
|
|
280
|
+
self.viewChanged.emit()
|
|
281
|
+
self.ticksChanged.emit()
|
|
282
|
+
|
|
283
|
+
# --------------------------------------------------------------- slots
|
|
284
|
+
@Slot(float, float)
|
|
285
|
+
def setViewport(self, width: float, height: float) -> None:
|
|
286
|
+
"""Record the widget size (logical pixels) — used by the derived ranges only.
|
|
287
|
+
|
|
288
|
+
Drawing never depends on it: the shader derives its mapping from the camera and its
|
|
289
|
+
own size. The first report resolves the home view (see ``HOME_VIEW``); later reports
|
|
290
|
+
keep the scales, so nothing zooms while a window or a splitter is dragged.
|
|
291
|
+
"""
|
|
292
|
+
if width <= 0 or height <= 0:
|
|
293
|
+
return
|
|
294
|
+
size = (float(width), float(height))
|
|
295
|
+
if size == self._size:
|
|
296
|
+
return
|
|
297
|
+
self._size = size
|
|
298
|
+
self._resolve_home()
|
|
299
|
+
self._view_changed()
|
|
300
|
+
|
|
301
|
+
def _resolve_home(self) -> None:
|
|
302
|
+
"""Turn the home view into scales for the known size (once, while still at home)."""
|
|
303
|
+
if not self._home:
|
|
304
|
+
return
|
|
305
|
+
width, height = self._effective_size()
|
|
306
|
+
x_scale = (HOME_VIEW[1] - HOME_VIEW[0]) / width
|
|
307
|
+
y_scale = (HOME_VIEW[3] - HOME_VIEW[2]) / height
|
|
308
|
+
if isinstance(self._aspect, str):
|
|
309
|
+
self._zoom, self._y_scale = x_scale, y_scale
|
|
310
|
+
else:
|
|
311
|
+
aspect = float(self._aspect)
|
|
312
|
+
self._zoom = max(x_scale, aspect * y_scale) # expand, never crop
|
|
313
|
+
self._y_scale = self._zoom / aspect
|
|
314
|
+
if self._size is not None:
|
|
315
|
+
self._home = False
|
|
316
|
+
|
|
317
|
+
@Slot(float, float, float, float, float)
|
|
318
|
+
def zoom_by(self, delta: float, u: float, v: float, width: float, height: float) -> None:
|
|
319
|
+
"""Zoom by ``delta`` wheel units (120 = one notch), anchored at the normalised
|
|
320
|
+
cursor position ``(u, v)`` of a ``width`` x ``height`` viewport.
|
|
321
|
+
|
|
322
|
+
The factor depends only on ``delta``, so scrolling up and down at the same position
|
|
323
|
+
are exact inverses; the world point under the cursor stays put.
|
|
324
|
+
"""
|
|
325
|
+
if not self._zoom_enabled or delta == 0:
|
|
326
|
+
return
|
|
327
|
+
target = min(max(self._zoom * self._zoom_step ** (delta / 120.0), self.MIN_SCALE),
|
|
328
|
+
self.MAX_SCALE)
|
|
329
|
+
factor = target / self._zoom
|
|
330
|
+
if factor == 1.0:
|
|
331
|
+
return
|
|
332
|
+
if width > 0 and height > 0:
|
|
333
|
+
anchor_x = self._centre.x() + (u - 0.5) * width * self.scale_x
|
|
334
|
+
anchor_y = self._centre.y() + (0.5 - v) * height * self.scale_y
|
|
335
|
+
else:
|
|
336
|
+
anchor_x, anchor_y = self._centre.x(), self._centre.y()
|
|
337
|
+
self._zoom = target
|
|
338
|
+
if isinstance(self._aspect, str):
|
|
339
|
+
self._y_scale = min(max(self._y_scale * factor, self.MIN_SCALE), self.MAX_SCALE)
|
|
340
|
+
if width > 0 and height > 0:
|
|
341
|
+
self._centre = QVector2D(
|
|
342
|
+
anchor_x - (u - 0.5) * width * self.scale_x,
|
|
343
|
+
anchor_y - (0.5 - v) * height * self.scale_y,
|
|
344
|
+
)
|
|
345
|
+
self._home = False
|
|
346
|
+
self._view_changed()
|
|
347
|
+
|
|
348
|
+
@Slot(float, float)
|
|
349
|
+
def pan_pixels(self, dx: float, dy: float) -> None:
|
|
350
|
+
"""Pan by a pixel displacement (screen y points down, world y points up)."""
|
|
351
|
+
if not self._pan_enabled or (dx == 0.0 and dy == 0.0):
|
|
352
|
+
return
|
|
353
|
+
self._centre = QVector2D(
|
|
354
|
+
self._centre.x() - dx * self.scale_x,
|
|
355
|
+
self._centre.y() + dy * self.scale_y,
|
|
356
|
+
)
|
|
357
|
+
self._home = False
|
|
358
|
+
self._view_changed()
|
|
359
|
+
|
|
360
|
+
@Slot()
|
|
361
|
+
def reset(self) -> None:
|
|
362
|
+
"""Back to the home view (centre and scales as the camera was configured)."""
|
|
363
|
+
self._centre = QVector2D(0.0, 0.0)
|
|
364
|
+
self._home = True
|
|
365
|
+
self._resolve_home()
|
|
366
|
+
self._view_changed()
|