qmlmathplot 0.1.0__py3-none-any.whl → 0.2.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/camera.py +28 -0
- qmlmathplot/curve.py +5 -3
- qmlmathplot/export.py +164 -0
- qmlmathplot/mpl.py +232 -0
- qmlmathplot/plot.py +182 -28
- qmlmathplot/qml/PlotView.qml +52 -11
- qmlmathplot/themes/default.mplstyle +26 -0
- qmlmathplot/themes.py +11 -6
- qmlmathplot/widget.py +15 -10
- {qmlmathplot-0.1.0.dist-info → qmlmathplot-0.2.0.dist-info}/METADATA +127 -9
- {qmlmathplot-0.1.0.dist-info → qmlmathplot-0.2.0.dist-info}/RECORD +13 -10
- {qmlmathplot-0.1.0.dist-info → qmlmathplot-0.2.0.dist-info}/WHEEL +1 -1
- {qmlmathplot-0.1.0.dist-info → qmlmathplot-0.2.0.dist-info}/entry_points.txt +0 -0
qmlmathplot/camera.py
CHANGED
|
@@ -219,6 +219,13 @@ class Camera(QObject):
|
|
|
219
219
|
xlim: QVector2D = Property("QVariant", _get_xlim, _set_xlim, notify=viewChanged)
|
|
220
220
|
ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
|
|
221
221
|
|
|
222
|
+
def _get_viewport(self) -> QVector2D:
|
|
223
|
+
width, height = self._effective_size()
|
|
224
|
+
return QVector2D(width, height)
|
|
225
|
+
|
|
226
|
+
#: Last reported size in logical pixels (the reference size before the first report).
|
|
227
|
+
viewport: QVector2D = Property("QVariant", _get_viewport, notify=viewChanged)
|
|
228
|
+
|
|
222
229
|
# --------------------------------------------------------------- ticks
|
|
223
230
|
def tick_values(self) -> tuple[list[tuple[float, str]], list[tuple[float, str]]]:
|
|
224
231
|
"""Ticks for both axes of the visible range, as ``(value, label)`` pairs."""
|
|
@@ -280,6 +287,27 @@ class Camera(QObject):
|
|
|
280
287
|
self.viewChanged.emit()
|
|
281
288
|
self.ticksChanged.emit()
|
|
282
289
|
|
|
290
|
+
@Slot(float, float, float, float)
|
|
291
|
+
def set_view(self, centre_x: float, centre_y: float, scale_x: float, scale_y: float) -> None:
|
|
292
|
+
"""Set the centre and *both* scales at once (world units per logical pixel).
|
|
293
|
+
|
|
294
|
+
The camera normally derives the y scale from ``aspect``; this is the primitive for a
|
|
295
|
+
caller that has two scales in hand — the exporter borrowing the camera for a render,
|
|
296
|
+
and restoring it afterwards. ``"auto"`` stays "auto" (the scales are simply set), a
|
|
297
|
+
numeric aspect is re-derived from the pair so the two scales are exactly as asked.
|
|
298
|
+
"""
|
|
299
|
+
scale_x = min(max(float(scale_x), self.MIN_SCALE), self.MAX_SCALE)
|
|
300
|
+
scale_y = min(max(float(scale_y), self.MIN_SCALE), self.MAX_SCALE)
|
|
301
|
+
self._centre = QVector2D(float(centre_x), float(centre_y))
|
|
302
|
+
self._zoom = scale_x
|
|
303
|
+
if isinstance(self._aspect, str):
|
|
304
|
+
self._y_scale = scale_y
|
|
305
|
+
else:
|
|
306
|
+
self._aspect = scale_x / scale_y
|
|
307
|
+
self.aspectChanged.emit()
|
|
308
|
+
self._home = False
|
|
309
|
+
self._view_changed()
|
|
310
|
+
|
|
283
311
|
# --------------------------------------------------------------- slots
|
|
284
312
|
@Slot(float, float)
|
|
285
313
|
def setViewport(self, width: float, height: float) -> None:
|
qmlmathplot/curve.py
CHANGED
|
@@ -25,9 +25,11 @@ from .qsb import bake
|
|
|
25
25
|
|
|
26
26
|
__all__ = ["Curve", "CurveListModel"]
|
|
27
27
|
|
|
28
|
-
#: First colour of the default cycle (
|
|
29
|
-
DEFAULT_COLOR = "#
|
|
30
|
-
|
|
28
|
+
#: First colour of the default cycle (matplotlib's "tab10" blue, as in its default style).
|
|
29
|
+
DEFAULT_COLOR = "#1f77b4"
|
|
30
|
+
#: matplotlib's default ten-colour cycle.
|
|
31
|
+
DEFAULT_COLOR_CYCLE = ("#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
|
|
32
|
+
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf")
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def _color(value: object) -> QColor:
|
qmlmathplot/export.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Export layer: render a region of this canvas at a pixel size (docs/api-design.md §9).
|
|
2
|
+
|
|
3
|
+
An export is not a re-draw at another ``dpi`` — the plot *is* a Qt scene, so the same scene is
|
|
4
|
+
rendered into a hidden ``QQuickWidget`` and read back synchronously. Everything (curves, grid,
|
|
5
|
+
axes, theme) follows the live styling, so an export cannot drift from what the user sees; only
|
|
6
|
+
the camera is *borrowed* for the duration of the render and restored before the event loop runs
|
|
7
|
+
again, so the live view never repaints the export state.
|
|
8
|
+
|
|
9
|
+
QtWidgets is imported lazily (a pure-QML app that never exports never loads it).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from PySide6.QtCore import QSize, Qt, QUrl
|
|
18
|
+
from PySide6.QtGui import QColor, QImage
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover - typing only; the real imports stay lazy
|
|
21
|
+
from PySide6.QtQuickWidgets import QQuickWidget
|
|
22
|
+
from PySide6.QtWidgets import QApplication
|
|
23
|
+
|
|
24
|
+
from .plot import Plot
|
|
25
|
+
|
|
26
|
+
__all__ = ["ADJUSTABLES", "render"]
|
|
27
|
+
|
|
28
|
+
#: How a requested range and a requested pixel size are reconciled (see the module docstring).
|
|
29
|
+
ADJUSTABLES = ("box", "datalim", "stretch")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pair(value: Sequence[float], name: str) -> tuple[float, float]:
|
|
33
|
+
try:
|
|
34
|
+
lo, hi = (float(value[0]), float(value[1]))
|
|
35
|
+
except (TypeError, IndexError, KeyError):
|
|
36
|
+
raise ValueError(f"{name} must be a (min, max) pair, got {value!r}") from None
|
|
37
|
+
if not hi > lo:
|
|
38
|
+
raise ValueError(f"{name} must be increasing, got {value!r}")
|
|
39
|
+
return lo, hi
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _widgets() -> tuple[type[QApplication], type[QQuickWidget]]:
|
|
43
|
+
"""The QtWidgets classes the export needs, or a clear error when they are not usable."""
|
|
44
|
+
try:
|
|
45
|
+
from PySide6.QtQuickWidgets import QQuickWidget
|
|
46
|
+
from PySide6.QtWidgets import QApplication
|
|
47
|
+
except ImportError as exc: # pragma: no cover - both ship with PySide6
|
|
48
|
+
raise RuntimeError("exporting needs QtWidgets/QtQuickWidgets (PySide6)") from exc
|
|
49
|
+
if not isinstance(QApplication.instance(), QApplication):
|
|
50
|
+
# Creating a QWidget under a bare QGuiApplication aborts the process inside Qt (and
|
|
51
|
+
# `QApplication.instance()` *is* the QGuiApplication there), so this has to be refused
|
|
52
|
+
# before the offscreen widget is touched.
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"exporting needs a QApplication: the offscreen render uses a QQuickWidget; "
|
|
55
|
+
"create one (MathPlotWidget does it for you) or use a widgets host"
|
|
56
|
+
)
|
|
57
|
+
return QApplication, QQuickWidget
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render(
|
|
61
|
+
plot: Plot,
|
|
62
|
+
*,
|
|
63
|
+
xlim: Sequence[float] | None = None,
|
|
64
|
+
ylim: Sequence[float] | None = None,
|
|
65
|
+
width: float | None = None,
|
|
66
|
+
height: float | None = None,
|
|
67
|
+
dpi: float = 1.0,
|
|
68
|
+
adjustable: str | None = None,
|
|
69
|
+
transparent: bool = False,
|
|
70
|
+
) -> QImage:
|
|
71
|
+
"""Render ``plot`` offscreen and return the grabbed image, in device pixels.
|
|
72
|
+
|
|
73
|
+
``xlim`` / ``ylim`` default to the camera (the live centre and scale, extended to the export
|
|
74
|
+
size), ``width`` / ``height`` default to the live view's device size (``dpi`` multiplies
|
|
75
|
+
both), and ``adjustable`` reconciles the two parameter sets.
|
|
76
|
+
"""
|
|
77
|
+
dpi = float(dpi)
|
|
78
|
+
if not dpi > 0:
|
|
79
|
+
raise ValueError(f"dpi must be positive, got {dpi!r}")
|
|
80
|
+
mode = "box" if adjustable is None else str(adjustable) # matplotlib's own default
|
|
81
|
+
if mode not in ADJUSTABLES:
|
|
82
|
+
raise ValueError(f"adjustable must be one of {ADJUSTABLES}, got {adjustable!r}")
|
|
83
|
+
for name, value in (("width", width), ("height", height)):
|
|
84
|
+
if value is not None and not float(value) > 0:
|
|
85
|
+
raise ValueError(f"{name} must be positive, got {value!r}")
|
|
86
|
+
|
|
87
|
+
_app, quick_widget = _widgets()
|
|
88
|
+
from .view import qml_component_path, register_qml_types
|
|
89
|
+
|
|
90
|
+
register_qml_types()
|
|
91
|
+
camera = plot.camera
|
|
92
|
+
live = (camera.centre.x(), camera.centre.y(), camera.scale_x, camera.scale_y)
|
|
93
|
+
live_size = (camera.viewport.x(), camera.viewport.y())
|
|
94
|
+
background = plot.background
|
|
95
|
+
|
|
96
|
+
widget: QQuickWidget = quick_widget()
|
|
97
|
+
widget.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
|
98
|
+
widget.setResizeMode(quick_widget.ResizeMode.SizeRootObjectToView)
|
|
99
|
+
widget.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
100
|
+
image = QImage()
|
|
101
|
+
device = (0, 0)
|
|
102
|
+
try:
|
|
103
|
+
if transparent:
|
|
104
|
+
plot.background = QColor(0, 0, 0, 0)
|
|
105
|
+
widget.setClearColor(QColor(0, 0, 0, 0))
|
|
106
|
+
widget.setSource(QUrl.fromLocalFile(qml_component_path()))
|
|
107
|
+
if widget.status() != quick_widget.Status.Ready:
|
|
108
|
+
errors = "; ".join(error.toString() for error in widget.errors())
|
|
109
|
+
raise RuntimeError(f"the QML component failed to load: {errors}")
|
|
110
|
+
root = widget.rootObject() # the widget itself has no such property
|
|
111
|
+
if root is None:
|
|
112
|
+
raise RuntimeError("the QML component has no root object")
|
|
113
|
+
root.setProperty("plot", plot)
|
|
114
|
+
|
|
115
|
+
# width/height are device pixels, so the offscreen view gets the logical size
|
|
116
|
+
pixel_ratio = widget.devicePixelRatioF()
|
|
117
|
+
device = (
|
|
118
|
+
round((float(width) if width is not None else live_size[0] * pixel_ratio) * dpi),
|
|
119
|
+
round((float(height) if height is not None else live_size[1] * pixel_ratio) * dpi),
|
|
120
|
+
)
|
|
121
|
+
logical = (device[0] / pixel_ratio, device[1] / pixel_ratio)
|
|
122
|
+
widget.resize(round(logical[0]), round(logical[1]))
|
|
123
|
+
widget.show() # offscreen: WA_DontShowOnScreen, never exposed
|
|
124
|
+
|
|
125
|
+
# The range: the camera, extended to the export size, unless the caller asked for one
|
|
126
|
+
range_x = _pair(xlim, "xlim") if xlim is not None else (
|
|
127
|
+
live[0] - 0.5 * logical[0] * live[2], live[0] + 0.5 * logical[0] * live[2])
|
|
128
|
+
range_y = _pair(ylim, "ylim") if ylim is not None else (
|
|
129
|
+
live[1] - 0.5 * logical[1] * live[3], live[1] + 0.5 * logical[1] * live[3])
|
|
130
|
+
span_x, span_y = range_x[1] - range_x[0], range_y[1] - range_y[0]
|
|
131
|
+
centre = (0.5 * (range_x[0] + range_x[1]), 0.5 * (range_y[0] + range_y[1]))
|
|
132
|
+
|
|
133
|
+
if mode == "stretch": # the range maps onto the size exactly
|
|
134
|
+
scale = (span_x / logical[0], span_y / logical[1])
|
|
135
|
+
elif mode == "box": # the live ratio, the range drawn as large as it fits
|
|
136
|
+
# The largest scale (the smallest world units per pixel) for which the requested
|
|
137
|
+
# range still fits: the binding axis is exact, the other keeps background margins.
|
|
138
|
+
# Drawn without distortion — a world circle stays round, only the visible span
|
|
139
|
+
# changes when the requested range's aspect differs from the size's.
|
|
140
|
+
ratio = live[2] / live[3]
|
|
141
|
+
scale_x = max(span_x / logical[0], ratio * span_y / logical[1])
|
|
142
|
+
scale = (scale_x, scale_x / ratio)
|
|
143
|
+
else: # "datalim": the live scales, the limits expand
|
|
144
|
+
scale = (max(live[2], span_x / logical[0]), max(live[3], span_y / logical[1]))
|
|
145
|
+
|
|
146
|
+
camera.setViewport(logical[0], logical[1])
|
|
147
|
+
camera.set_view(centre[0], centre[1], scale[0], scale[1])
|
|
148
|
+
image = widget.grabFramebuffer()
|
|
149
|
+
finally:
|
|
150
|
+
# Restore before the event loop runs again, so no live view repaints the export state.
|
|
151
|
+
camera.set_view(live[0], live[1], live[2], live[3])
|
|
152
|
+
camera.setViewport(live_size[0], live_size[1])
|
|
153
|
+
plot.background = background
|
|
154
|
+
widget.hide()
|
|
155
|
+
widget.deleteLater()
|
|
156
|
+
|
|
157
|
+
if image.size() != QSize(device[0], device[1]):
|
|
158
|
+
# At a fractional devicePixelRatio the offscreen widget can land within one pixel of the
|
|
159
|
+
# requested size; make the promise exact (a 1 px nearest resample is invisible).
|
|
160
|
+
image = image.scaled(
|
|
161
|
+
device[0], device[1],
|
|
162
|
+
Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.FastTransformation,
|
|
163
|
+
)
|
|
164
|
+
return image
|
qmlmathplot/mpl.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Hand-off to matplotlib — and through it to sympy's plotting.
|
|
2
|
+
|
|
3
|
+
This is deliberately a **second renderer**, not the one the Qt view uses, and it is a *sampled*
|
|
4
|
+
one: matplotlib draws polylines, so an oscillating function such as ``sin(1/x)`` aliases exactly
|
|
5
|
+
as it does in any sampling renderer — which is the reason the Qt side exists and evaluates the
|
|
6
|
+
expression per pixel instead. Fonts and antialiasing differ from the Qt view as well, and the
|
|
7
|
+
style mapping below is partial. What this buys is **vector output** (``.svg``/``.pdf``) and
|
|
8
|
+
matplotlib's ecosystem (and sympy's plotting), nothing else.
|
|
9
|
+
|
|
10
|
+
matplotlib is an *optional* dependency (the ``matplotlib`` extra): this module imports nothing
|
|
11
|
+
until one of its functions is called, and ``import qmlmathplot`` never pulls it in.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
import sympy as sp
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING: # pragma: no cover - typing only; the real imports stay lazy
|
|
22
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
23
|
+
from matplotlib.figure import Figure
|
|
24
|
+
|
|
25
|
+
from .curve import Curve
|
|
26
|
+
from .plot import Plot
|
|
27
|
+
|
|
28
|
+
__all__ = ["figure_for", "save_via_matplotlib", "sympy_plot_for"]
|
|
29
|
+
|
|
30
|
+
#: Logical pixels to points (96 px/inch, 72 pt/inch): our sizes are in px, matplotlib's in pt.
|
|
31
|
+
PX_TO_PT = 72.0 / 96.0
|
|
32
|
+
#: The DPI the hand-off assumes for sizes that only exist in pixels (the live viewport).
|
|
33
|
+
DEFAULT_DPI = 100.0
|
|
34
|
+
_EXTRA_HINT = (
|
|
35
|
+
"the matplotlib hand-off needs the optional extra: pip install 'qmlmathplot[matplotlib]'"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _require() -> tuple[type[Figure], type[FigureCanvasAgg]]:
|
|
40
|
+
"""The matplotlib classes for a headless figure, or a clear error naming the extra."""
|
|
41
|
+
try:
|
|
42
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
43
|
+
from matplotlib.figure import Figure
|
|
44
|
+
except ImportError as exc: # no matplotlib installed
|
|
45
|
+
raise ImportError(_EXTRA_HINT) from exc
|
|
46
|
+
return Figure, FigureCanvasAgg
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pair(value: Sequence[float], name: str) -> tuple[float, float]:
|
|
50
|
+
try:
|
|
51
|
+
lo, hi = float(value[0]), float(value[1])
|
|
52
|
+
except (TypeError, IndexError, KeyError):
|
|
53
|
+
raise ValueError(f"{name} must be a (min, max) pair, got {value!r}") from None
|
|
54
|
+
if not hi > lo:
|
|
55
|
+
raise ValueError(f"{name} must be increasing, got {value!r}")
|
|
56
|
+
return lo, hi
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _visible_curves(plot: Plot) -> list[Curve]:
|
|
60
|
+
"""The curves the Qt view would draw (a hidden curve is not handed over)."""
|
|
61
|
+
return [plot.curves.at(index) for index in range(len(plot.curves))
|
|
62
|
+
if plot.curves.at(index).visible]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sample(expression: str, x0: float, x1: float, samples: int) -> tuple[object, object]:
|
|
66
|
+
"""Uniform samples of one curve; NaN where the function is outside its domain.
|
|
67
|
+
|
|
68
|
+
matplotlib breaks the line at NaN, which is how the hand-off keeps ``log(x)`` from being
|
|
69
|
+
drawn for x < 0. Infinities (poles, ``1/x`` at 0) are turned into NaN as well, so a pole
|
|
70
|
+
breaks the line instead of drawing a connector across it.
|
|
71
|
+
"""
|
|
72
|
+
import numpy
|
|
73
|
+
|
|
74
|
+
symbol = sp.Symbol("x")
|
|
75
|
+
try:
|
|
76
|
+
function = sp.lambdify(symbol, sp.sympify(expression, locals={"x": symbol}),
|
|
77
|
+
modules=["numpy"])
|
|
78
|
+
with numpy.errstate(all="ignore"): # log(x<0) etc.: NaN, not a warning storm
|
|
79
|
+
xs = numpy.linspace(x0, x1, samples)
|
|
80
|
+
ys = numpy.asarray(function(xs), dtype=float)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
# Broad on purpose: sympify, lambdify and the evaluation itself all report by raising,
|
|
83
|
+
# and the caller wants one clear message naming the expression.
|
|
84
|
+
raise ValueError(f"cannot sample {expression!r}: {exc}") from exc
|
|
85
|
+
if ys.ndim == 0: # a constant expression evaluates to a scalar
|
|
86
|
+
ys = numpy.full(xs.shape, float(ys))
|
|
87
|
+
ys[~numpy.isfinite(ys)] = numpy.nan
|
|
88
|
+
return xs, ys
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def figure_for(
|
|
92
|
+
plot: Plot,
|
|
93
|
+
*,
|
|
94
|
+
xlim: Sequence[float] | None = None,
|
|
95
|
+
ylim: Sequence[float] | None = None,
|
|
96
|
+
samples: int = 2000,
|
|
97
|
+
adjustable: str = "box",
|
|
98
|
+
size: tuple[float, float] | None = None,
|
|
99
|
+
dpi: float = DEFAULT_DPI,
|
|
100
|
+
transparent: bool = False,
|
|
101
|
+
) -> Figure:
|
|
102
|
+
"""A matplotlib ``Figure`` with one Axes, reflecting this plot as far as it maps.
|
|
103
|
+
|
|
104
|
+
``xlim`` / ``ylim`` default to the camera's visible range; ``size`` is the figure size in
|
|
105
|
+
device pixels (default: the live viewport) and ``dpi`` turns it into inches. ``adjustable``
|
|
106
|
+
follows the export's modes: ``"box"`` and ``"datalim"`` map to matplotlib's own, and
|
|
107
|
+
``"stretch"`` (independent scales) becomes matplotlib's ``"auto"`` aspect.
|
|
108
|
+
"""
|
|
109
|
+
if adjustable not in ("box", "datalim", "stretch"):
|
|
110
|
+
raise ValueError(f'adjustable must be "box", "datalim" or "stretch", got {adjustable!r}')
|
|
111
|
+
Figure, FigureCanvasAgg = _require()
|
|
112
|
+
dpi = float(dpi)
|
|
113
|
+
if not dpi > 0:
|
|
114
|
+
raise ValueError(f"dpi must be positive, got {dpi!r}")
|
|
115
|
+
|
|
116
|
+
x_range = _pair(xlim, "xlim") if xlim is not None else (plot.xlim.x(), plot.xlim.y())
|
|
117
|
+
y_range = _pair(ylim, "ylim") if ylim is not None else (plot.ylim.x(), plot.ylim.y())
|
|
118
|
+
if size is None: # the live viewport, at the hand-off's dpi
|
|
119
|
+
viewport = plot.camera.viewport
|
|
120
|
+
size = (viewport.x(), viewport.y())
|
|
121
|
+
inches = (size[0] / dpi, size[1] / dpi)
|
|
122
|
+
|
|
123
|
+
figure = Figure(figsize=inches, dpi=dpi)
|
|
124
|
+
FigureCanvasAgg(figure) # headless canvas: no pyplot, no GUI backend
|
|
125
|
+
axes = figure.add_subplot()
|
|
126
|
+
axes.set_facecolor(plot.background.name())
|
|
127
|
+
figure.patch.set_facecolor("none" if transparent else plot.background.name())
|
|
128
|
+
axes.set_xlim(*x_range)
|
|
129
|
+
axes.set_ylim(*y_range)
|
|
130
|
+
|
|
131
|
+
if adjustable == "stretch": # independent scales: matplotlib's "auto"
|
|
132
|
+
axes.set_aspect("auto")
|
|
133
|
+
else:
|
|
134
|
+
aspect = plot.camera.aspect # "auto" | number, same convention as ours
|
|
135
|
+
axes.set_aspect("auto" if isinstance(aspect, str) else float(aspect),
|
|
136
|
+
adjustable=adjustable)
|
|
137
|
+
|
|
138
|
+
if plot.axes_position == "zero": # our axes through world (0,0), no box
|
|
139
|
+
axes.spines["left"].set_position("zero")
|
|
140
|
+
axes.spines["bottom"].set_position("zero")
|
|
141
|
+
axes.spines["top"].set_visible(False)
|
|
142
|
+
axes.spines["right"].set_visible(False)
|
|
143
|
+
for spine in axes.spines.values():
|
|
144
|
+
spine.set_color(plot.axis_color.name())
|
|
145
|
+
spine.set_linewidth(plot.axis_width * PX_TO_PT)
|
|
146
|
+
|
|
147
|
+
axes.set_axisbelow(True) # the Qt view paints its grid below the curves
|
|
148
|
+
if plot.grid:
|
|
149
|
+
axes.grid(True, color=plot.grid_color.name(),
|
|
150
|
+
linewidth=plot.grid_width * PX_TO_PT, alpha=plot.grid_alpha,
|
|
151
|
+
linestyle=plot.grid_style)
|
|
152
|
+
else:
|
|
153
|
+
# Passing line properties together with `False` makes matplotlib *enable* the grid.
|
|
154
|
+
axes.grid(False)
|
|
155
|
+
axes.tick_params(colors=plot.tick_color.name(), labelcolor=plot.text_color.name(),
|
|
156
|
+
labelsize=plot.tick_font_size * PX_TO_PT)
|
|
157
|
+
axes.xaxis.label.set_fontsize(plot.font_size * PX_TO_PT)
|
|
158
|
+
axes.yaxis.label.set_fontsize(plot.font_size * PX_TO_PT)
|
|
159
|
+
if plot.title:
|
|
160
|
+
axes.set_title(plot.title, color=plot.title_color.name(),
|
|
161
|
+
fontsize=plot.title_font_size * PX_TO_PT,
|
|
162
|
+
fontweight="bold" if plot.title_bold else "normal")
|
|
163
|
+
|
|
164
|
+
for curve in _visible_curves(plot):
|
|
165
|
+
xs, ys = _sample(curve.expression, x_range[0], x_range[1], samples)
|
|
166
|
+
axes.plot(xs, ys, color=curve.color.name(), linewidth=curve.lineWidth * PX_TO_PT,
|
|
167
|
+
label=curve.label or curve.expression)
|
|
168
|
+
return figure
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def save_via_matplotlib(
|
|
172
|
+
plot: Plot,
|
|
173
|
+
path: str,
|
|
174
|
+
*,
|
|
175
|
+
xlim: Sequence[float] | None = None,
|
|
176
|
+
ylim: Sequence[float] | None = None,
|
|
177
|
+
samples: int = 2000,
|
|
178
|
+
adjustable: str = "box",
|
|
179
|
+
width: float | None = None,
|
|
180
|
+
height: float | None = None,
|
|
181
|
+
dpi: float = DEFAULT_DPI,
|
|
182
|
+
transparent: bool = False,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Write the hand-off figure with matplotlib's own ``savefig`` (svg/pdf/png/…)."""
|
|
185
|
+
size = None
|
|
186
|
+
if width is not None or height is not None:
|
|
187
|
+
viewport = plot.camera.viewport
|
|
188
|
+
size = (float(width) if width is not None else viewport.x(),
|
|
189
|
+
float(height) if height is not None else viewport.y())
|
|
190
|
+
figure = figure_for(plot, xlim=xlim, ylim=ylim, samples=samples, adjustable=adjustable,
|
|
191
|
+
size=size, dpi=dpi, transparent=transparent)
|
|
192
|
+
figure.savefig(str(path), transparent=transparent)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def sympy_plot_for(
|
|
196
|
+
plot: Plot,
|
|
197
|
+
*,
|
|
198
|
+
xlim: Sequence[float] | None = None,
|
|
199
|
+
ylim: Sequence[float] | None = None,
|
|
200
|
+
samples: int = 2000,
|
|
201
|
+
adaptive: bool = False,
|
|
202
|
+
) -> object:
|
|
203
|
+
"""Hand this plot to ``sympy.plotting``, uniform by default (``adaptive`` asks for sympy's.
|
|
204
|
+
|
|
205
|
+
Returns whatever ``sympy.plotting.plot(..., show=False)`` returns (a matplotlib-backend
|
|
206
|
+
``Plot`` object in sympy 1.14), so ``handle.save(path)``/``handle.show()`` work, the window
|
|
207
|
+
is the same one the matplotlib hand-off would show, and every curve keeps its colour.
|
|
208
|
+
"""
|
|
209
|
+
_require() # sympy's plotting backend is matplotlib too
|
|
210
|
+
symbol = sp.Symbol("x")
|
|
211
|
+
curves = _visible_curves(plot)
|
|
212
|
+
if not curves:
|
|
213
|
+
raise ValueError("nothing to hand to sympy: the plot has no visible curve")
|
|
214
|
+
x_range = _pair(xlim, "xlim") if xlim is not None else (plot.xlim.x(), plot.xlim.y())
|
|
215
|
+
y_range = _pair(ylim, "ylim") if ylim is not None else (plot.ylim.x(), plot.ylim.y())
|
|
216
|
+
expressions = []
|
|
217
|
+
for curve in curves:
|
|
218
|
+
try:
|
|
219
|
+
expressions.append(sp.sympify(curve.expression, locals={"x": symbol}))
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
raise ValueError(f"cannot parse {curve.expression!r}: {exc}") from exc
|
|
222
|
+
handle = sp.plotting.plot(
|
|
223
|
+
*expressions, (symbol, x_range[0], x_range[1]), show=False,
|
|
224
|
+
adaptive=adaptive, # see Plot.to_sympy for why False is the default
|
|
225
|
+
nb_of_points=samples,
|
|
226
|
+
label=[curve.label or curve.expression for curve in curves],
|
|
227
|
+
)
|
|
228
|
+
handle.xlim = x_range # the same window as the matplotlib hand-off
|
|
229
|
+
handle.ylim = y_range
|
|
230
|
+
for index, curve in enumerate(curves): # per-series style (not a global line_color)
|
|
231
|
+
handle[index].line_color = curve.color.name()
|
|
232
|
+
return handle
|
qmlmathplot/plot.py
CHANGED
|
@@ -9,10 +9,11 @@ names — see ``theme``.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from PySide6.QtCore import Property, QObject, Signal, Slot
|
|
12
|
-
from PySide6.QtGui import QColor, QVector2D
|
|
12
|
+
from PySide6.QtGui import QColor, QImage, QVector2D
|
|
13
13
|
|
|
14
14
|
from .camera import Camera
|
|
15
15
|
from .curve import DEFAULT_COLOR_CYCLE, Curve, CurveListModel
|
|
16
|
+
from .themes import names, resolve
|
|
16
17
|
|
|
17
18
|
__all__ = ["Plot"]
|
|
18
19
|
|
|
@@ -60,10 +61,14 @@ class Plot(QObject):
|
|
|
60
61
|
cameraChanged = Signal()
|
|
61
62
|
curvesChanged = Signal()
|
|
62
63
|
viewChanged = Signal()
|
|
63
|
-
aspectChanged = Signal()
|
|
64
|
+
aspectChanged = Signal() # forwarded from the camera (the forwarded `aspect`)
|
|
64
65
|
themeChanged = Signal()
|
|
65
66
|
|
|
66
67
|
backgroundChanged = Signal()
|
|
68
|
+
axisColorChanged = Signal()
|
|
69
|
+
axisWidthChanged = Signal()
|
|
70
|
+
axesPositionChanged = Signal()
|
|
71
|
+
available_themesChanged = Signal()
|
|
67
72
|
gridChanged = Signal()
|
|
68
73
|
gridColorChanged = Signal()
|
|
69
74
|
gridWidthChanged = Signal()
|
|
@@ -85,11 +90,15 @@ class Plot(QObject):
|
|
|
85
90
|
def __init__(self, parent: QObject | None = None) -> None:
|
|
86
91
|
super().__init__(parent)
|
|
87
92
|
self._camera = Camera(self)
|
|
93
|
+
self._theme = ""
|
|
88
94
|
self._curves = CurveListModel(DEFAULT_COLOR_CYCLE, self.line_width, self)
|
|
89
95
|
self._camera.viewChanged.connect(self.viewChanged)
|
|
90
96
|
self._camera.aspectChanged.connect(self.aspectChanged)
|
|
91
97
|
self.cameraChanged.emit()
|
|
92
98
|
self.curvesChanged.emit()
|
|
99
|
+
# matplotlib's look out of the box: the theme file is the single source of truth, so
|
|
100
|
+
# the fallbacks above and the theme agree instead of drifting apart.
|
|
101
|
+
self._set_theme("default")
|
|
93
102
|
|
|
94
103
|
# ------------------------------------------------------------- the model
|
|
95
104
|
def _get_camera(self) -> Camera:
|
|
@@ -128,7 +137,9 @@ class Plot(QObject):
|
|
|
128
137
|
ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
|
|
129
138
|
# `aspect` is forwarded too because a QML *binding* cannot reach through an object chain
|
|
130
139
|
# (`plot.camera.aspect: 1.0` is rejected by QML), while a direct property binds fine.
|
|
131
|
-
aspect: str | float = Property(
|
|
140
|
+
aspect: str | float = Property(
|
|
141
|
+
"QVariant", _get_aspect, _set_aspect, notify=aspectChanged
|
|
142
|
+
)
|
|
132
143
|
|
|
133
144
|
@Slot(str, "QVariant", "QVariant", str, result=QObject)
|
|
134
145
|
@Slot(str, result=QObject)
|
|
@@ -146,23 +157,135 @@ class Plot(QObject):
|
|
|
146
157
|
def remove_curve(self, curve: Curve) -> None:
|
|
147
158
|
self._curves.remove_curve(curve)
|
|
148
159
|
|
|
160
|
+
# --------------------------------------------------------------- export
|
|
161
|
+
@Slot(result="QVariant")
|
|
162
|
+
def to_image(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
xlim: object = None,
|
|
166
|
+
ylim: object = None,
|
|
167
|
+
width: float | None = None,
|
|
168
|
+
height: float | None = None,
|
|
169
|
+
dpi: float = 1.0,
|
|
170
|
+
adjustable: str | None = None,
|
|
171
|
+
transparent: bool = False,
|
|
172
|
+
) -> QImage:
|
|
173
|
+
"""Render this canvas region offscreen and return it (device pixels), see
|
|
174
|
+
``docs/api-design.md`` §9. QML calls it with no arguments: the live view, its own size.
|
|
175
|
+
"""
|
|
176
|
+
from .export import render # lazy: a pure-QML app never needs QtWidgets
|
|
177
|
+
|
|
178
|
+
return render(
|
|
179
|
+
self,
|
|
180
|
+
xlim=xlim,
|
|
181
|
+
ylim=ylim,
|
|
182
|
+
width=width,
|
|
183
|
+
height=height,
|
|
184
|
+
dpi=dpi,
|
|
185
|
+
adjustable=adjustable,
|
|
186
|
+
transparent=transparent,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def to_matplotlib(
|
|
190
|
+
self,
|
|
191
|
+
*,
|
|
192
|
+
xlim: object = None,
|
|
193
|
+
ylim: object = None,
|
|
194
|
+
samples: int = 2000,
|
|
195
|
+
adjustable: str = "box",
|
|
196
|
+
) -> object:
|
|
197
|
+
"""Hand this plot to matplotlib and return a ``matplotlib.figure.Figure`` (one Axes).
|
|
198
|
+
|
|
199
|
+
This is a **second renderer**, and a *sampled* one: matplotlib draws polylines, so an
|
|
200
|
+
oscillating function such as ``sin(1/x)`` aliases (exactly what the Qt view avoids by
|
|
201
|
+
evaluating the expression per pixel), the fonts and antialiasing differ from the Qt
|
|
202
|
+
view, and the style mapping is partial — background, grid, per-curve colour/width,
|
|
203
|
+
tick/text colours, font sizes, title, axis colour/width, the limits, the aspect with
|
|
204
|
+
matplotlib's ``adjustable``, and ``axes_position="zero"`` (spines through the origin).
|
|
205
|
+
What it buys is vector output and matplotlib's ecosystem, nothing else. Needs the
|
|
206
|
+
optional ``matplotlib`` extra (``pip install 'qmlmathplot[matplotlib]'``).
|
|
207
|
+
"""
|
|
208
|
+
from .mpl import figure_for
|
|
209
|
+
|
|
210
|
+
return figure_for(
|
|
211
|
+
self, xlim=xlim, ylim=ylim, samples=samples, adjustable=adjustable
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def to_sympy(
|
|
215
|
+
self,
|
|
216
|
+
*,
|
|
217
|
+
xlim: object = None,
|
|
218
|
+
ylim: object = None,
|
|
219
|
+
samples: int = 2000,
|
|
220
|
+
adaptive: bool = False,
|
|
221
|
+
) -> object:
|
|
222
|
+
"""Hand this plot to ``sympy.plotting`` and return the Plot object it builds.
|
|
223
|
+
|
|
224
|
+
Through sympy this reaches any of sympy's plotting backends. The sampling is ours
|
|
225
|
+
(uniform, ``samples`` points) unless ``adaptive=True`` asks for sympy's own sampler; the
|
|
226
|
+
aliasing caveat of :meth:`to_matplotlib` applies either way, because an oscillating
|
|
227
|
+
function cannot be resolved by sampling at all. Measured on ``sin(1/x)`` over [-1, 1]:
|
|
228
|
+
sympy 1.14's adaptive sampler produced **289** points — *fewer* than the fixed 2000 —
|
|
229
|
+
emitted no warning, and drew the same block of vertical connectors, which is why the
|
|
230
|
+
fixed count is the default. Hidden curves are skipped, each curve keeps its colour, and a
|
|
231
|
+
passed ``ylim`` is honoured (sympy has no y-range of its own). Needs the optional
|
|
232
|
+
``matplotlib`` extra.
|
|
233
|
+
"""
|
|
234
|
+
from .mpl import sympy_plot_for
|
|
235
|
+
|
|
236
|
+
return sympy_plot_for(
|
|
237
|
+
self, xlim=xlim, ylim=ylim, samples=samples, adaptive=adaptive
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
@Slot(str)
|
|
241
|
+
def savefig(self, path: str, *, backend: str = "qt", **kwargs: object) -> None:
|
|
242
|
+
"""Write the plot to ``path`` (the format comes from the suffix); QML passes a path only.
|
|
243
|
+
|
|
244
|
+
``backend="qt"`` (the default) renders through the offscreen Qt view — device pixels,
|
|
245
|
+
with :meth:`to_image`'s keywords. ``backend="matplotlib"`` renders a *sampled* figure
|
|
246
|
+
through :meth:`to_matplotlib` and calls matplotlib's own ``savefig``, so vector formats
|
|
247
|
+
(``.svg``, ``.pdf``) work and the look is matplotlib's, not the Qt view's.
|
|
248
|
+
"""
|
|
249
|
+
if backend == "qt":
|
|
250
|
+
if not self.to_image(**kwargs).save(str(path)):
|
|
251
|
+
raise OSError(f"could not write the export to {path!r}")
|
|
252
|
+
return
|
|
253
|
+
if backend == "matplotlib":
|
|
254
|
+
from .mpl import save_via_matplotlib
|
|
255
|
+
|
|
256
|
+
save_via_matplotlib(self, str(path), **kwargs)
|
|
257
|
+
return
|
|
258
|
+
raise ValueError(f'backend must be "qt" or "matplotlib", got {backend!r}')
|
|
259
|
+
|
|
149
260
|
# -------------------------------------------------------------- styling
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
261
|
+
# The fallbacks repeat matplotlib's default style (themes/default.mplstyle, applied in
|
|
262
|
+
# __init__) so a plot looks the same whether or not a theme was applied.
|
|
263
|
+
background: QColor = _style(
|
|
264
|
+
QColor, "background", backgroundChanged, QColor("white")
|
|
265
|
+
)
|
|
266
|
+
grid: bool = _style(bool, "grid", gridChanged, False)
|
|
267
|
+
grid_color: QColor = _style(
|
|
268
|
+
QColor, "grid_color", gridColorChanged, QColor("#b0b0b0")
|
|
269
|
+
)
|
|
270
|
+
grid_width: float = _style(float, "grid_width", gridWidthChanged, 1.07)
|
|
271
|
+
grid_alpha: float = _style(float, "grid_alpha", gridAlphaChanged, 1.0)
|
|
155
272
|
#: "-" | "--" | ":" | "-." (QML turns it into a dash pattern).
|
|
156
273
|
grid_style: str = _style(str, "grid_style", gridStyleChanged, "-")
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
274
|
+
axis_color: QColor = _style(QColor, "axis_color", axisColorChanged, QColor("black"))
|
|
275
|
+
axis_width: float = _style(float, "axis_width", axisWidthChanged, 1.07)
|
|
276
|
+
text_color: QColor = _style(QColor, "text_color", textColorChanged, QColor("black"))
|
|
277
|
+
tick_color: QColor = _style(QColor, "tick_color", tickColorChanged, QColor("black"))
|
|
278
|
+
tick_length: float = _style(float, "tick_length", tickLengthChanged, 4.67)
|
|
279
|
+
font_size: float = _style(float, "font_size", fontSizeChanged, 13.33)
|
|
280
|
+
tick_font_size: float = _style(float, "tick_font_size", tickFontSizeChanged, 13.33)
|
|
162
281
|
title: str = _style(str, "title", titleChanged, "")
|
|
163
|
-
title_color: QColor = _style(
|
|
164
|
-
|
|
165
|
-
|
|
282
|
+
title_color: QColor = _style(
|
|
283
|
+
QColor, "title_color", titleColorChanged, QColor("black")
|
|
284
|
+
)
|
|
285
|
+
title_font_size: float = _style(
|
|
286
|
+
float, "title_font_size", titleFontSizeChanged, 16.0
|
|
287
|
+
)
|
|
288
|
+
title_bold: bool = _style(bool, "title_bold", titleBoldChanged, False)
|
|
166
289
|
ticks_visible: bool = _style(bool, "ticks_visible", ticksVisibleChanged, True)
|
|
167
290
|
|
|
168
291
|
def _get_color_cycle(self) -> list[str]:
|
|
@@ -176,37 +299,68 @@ class Plot(QObject):
|
|
|
176
299
|
self._curves.set_color_cycle(tuple(cycle))
|
|
177
300
|
self.colorCycleChanged.emit()
|
|
178
301
|
|
|
179
|
-
color_cycle: list[str] = Property(
|
|
180
|
-
|
|
302
|
+
color_cycle: list[str] = Property(
|
|
303
|
+
"QVariant", _get_color_cycle, _set_color_cycle, notify=colorCycleChanged
|
|
304
|
+
)
|
|
181
305
|
|
|
182
306
|
def _get_line_width(self) -> float:
|
|
183
|
-
return float(getattr(self, "_line_width",
|
|
307
|
+
return float(getattr(self, "_line_width", 2.0))
|
|
184
308
|
|
|
185
309
|
def _set_line_width(self, value: float) -> None:
|
|
186
310
|
value = float(value)
|
|
187
311
|
if value == self._get_line_width():
|
|
188
312
|
return
|
|
189
313
|
self._line_width = value
|
|
190
|
-
self._curves.set_default_line_width(value)
|
|
314
|
+
self._curves.set_default_line_width(value) # default for the curves added later
|
|
191
315
|
self.lineWidthChanged.emit()
|
|
192
316
|
|
|
193
317
|
#: Default line width of new curves (logical pixels).
|
|
194
|
-
line_width: float = Property(
|
|
195
|
-
|
|
318
|
+
line_width: float = Property(
|
|
319
|
+
float, _get_line_width, _set_line_width, notify=lineWidthChanged
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _get_axes_position(self) -> str:
|
|
323
|
+
return getattr(self, "_axes_position", "zero")
|
|
324
|
+
|
|
325
|
+
def _set_axes_position(self, value: str) -> None:
|
|
326
|
+
value = str(value)
|
|
327
|
+
if value not in ("zero", "edge"):
|
|
328
|
+
raise ValueError(f'axesPosition must be "zero" or "edge", got {value!r}')
|
|
329
|
+
if value == self._get_axes_position():
|
|
330
|
+
return
|
|
331
|
+
self._axes_position = value
|
|
332
|
+
self.axesPositionChanged.emit()
|
|
333
|
+
|
|
334
|
+
#: Where the ticks ride: the axes through world (0,0), or the item's edges.
|
|
335
|
+
axes_position: str = Property(
|
|
336
|
+
str, _get_axes_position, _set_axes_position, notify=axesPositionChanged
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def _get_available_themes(self) -> list[str]:
|
|
340
|
+
return list(names())
|
|
341
|
+
|
|
342
|
+
#: Theme names a host can offer (``themes.names()``); read-only.
|
|
343
|
+
available_themes: list[str] = Property(
|
|
344
|
+
"QVariant", _get_available_themes, notify=available_themesChanged
|
|
345
|
+
)
|
|
196
346
|
|
|
197
347
|
def _get_theme(self) -> str:
|
|
198
348
|
return getattr(self, "_theme", "")
|
|
199
349
|
|
|
200
350
|
def _set_theme(self, name: str) -> None:
|
|
201
|
-
"""Apply a named theme: every entry is a style property of this object.
|
|
202
|
-
name = str(name)
|
|
203
|
-
if name == self._get_theme():
|
|
204
|
-
return
|
|
205
|
-
from .themes import resolve # lazy: a missing themes module must not break import
|
|
351
|
+
"""Apply a named theme: every entry is a style property of this object.
|
|
206
352
|
|
|
207
|
-
|
|
353
|
+
Assigning a theme always applies it — that is what makes ``plot.theme = "default"`` a
|
|
354
|
+
reset back to matplotlib's look. The style setters emit only on a real change, so a
|
|
355
|
+
re-application does not churn. An unknown name raises ``KeyError`` before applying
|
|
356
|
+
anything, leaving the current look alone.
|
|
357
|
+
"""
|
|
358
|
+
name = str(name)
|
|
359
|
+
resolved = resolve(name)
|
|
208
360
|
for key, value in resolved.items():
|
|
209
361
|
setattr(self, key, value)
|
|
362
|
+
if name == self._get_theme():
|
|
363
|
+
return
|
|
210
364
|
self._theme = name
|
|
211
365
|
self.themeChanged.emit()
|
|
212
366
|
|
qmlmathplot/qml/PlotView.qml
CHANGED
|
@@ -141,32 +141,68 @@ Item {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// Ticks, labels and title: screen-space furniture, so plain items.
|
|
144
|
+
// Ticks, labels, axes and title: screen-space furniture, so plain items.
|
|
145
145
|
Item {
|
|
146
|
+
id: furniture
|
|
147
|
+
|
|
146
148
|
anchors.fill: parent
|
|
147
149
|
visible: root.plot.ticks_visible
|
|
148
150
|
|
|
151
|
+
// The axes are the lines through world (0,0). When 0 is off screen the axis sticks to
|
|
152
|
+
// the nearest edge instead, so its labels are never lost (the grid stays where it is).
|
|
153
|
+
// "edge" pins both axes to the item's edges and draws no axis line: the old behaviour.
|
|
154
|
+
readonly property bool atZero: root.plot.axes_position === "zero"
|
|
155
|
+
readonly property real xAxisY: atZero
|
|
156
|
+
? Math.max(0, Math.min(height, root.mapToScreen(0, 0).y))
|
|
157
|
+
: height
|
|
158
|
+
readonly property real yAxisX: atZero
|
|
159
|
+
? Math.max(0, Math.min(width, root.mapToScreen(0, 0).x))
|
|
160
|
+
: 0
|
|
161
|
+
|
|
162
|
+
Rectangle {
|
|
163
|
+
visible: furniture.atZero
|
|
164
|
+
x: 0
|
|
165
|
+
width: parent.width
|
|
166
|
+
height: root.plot.axis_width
|
|
167
|
+
y: furniture.xAxisY - height / 2
|
|
168
|
+
color: root.plot.axis_color
|
|
169
|
+
}
|
|
170
|
+
Rectangle {
|
|
171
|
+
visible: furniture.atZero
|
|
172
|
+
x: furniture.yAxisX - width / 2
|
|
173
|
+
y: 0
|
|
174
|
+
width: root.plot.axis_width
|
|
175
|
+
height: parent.height
|
|
176
|
+
color: root.plot.axis_color
|
|
177
|
+
}
|
|
178
|
+
|
|
149
179
|
Repeater {
|
|
150
180
|
model: root.plot.camera.ticks_x
|
|
151
181
|
|
|
182
|
+
// The mark and the label ride the x axis; both are clamped so they stay on screen
|
|
183
|
+
// when the axis is pinned to an edge (a label may never leave the item).
|
|
152
184
|
delegate: Item {
|
|
153
185
|
required property var modelData
|
|
154
186
|
|
|
155
187
|
x: Math.round(root.mapToScreen(modelData[0], 0).x)
|
|
156
188
|
width: 1
|
|
157
|
-
height:
|
|
158
|
-
anchors.bottom: parent.bottom
|
|
189
|
+
height: furniture.height
|
|
159
190
|
|
|
160
191
|
Rectangle {
|
|
161
|
-
|
|
192
|
+
y: Math.max(0, Math.min(furniture.xAxisY, furniture.height - height))
|
|
193
|
+
width: 1
|
|
194
|
+
height: root.plot.tick_length
|
|
162
195
|
color: root.plot.tick_color
|
|
163
196
|
}
|
|
164
197
|
Text {
|
|
165
198
|
text: modelData[1]
|
|
166
199
|
color: root.plot.text_color
|
|
167
200
|
font.pixelSize: root.plot.tick_font_size
|
|
168
|
-
|
|
169
|
-
|
|
201
|
+
// centred on the tick, but never half off the item
|
|
202
|
+
x: Math.max(-parent.x, Math.min(-Math.round(width / 2),
|
|
203
|
+
furniture.width - width - parent.x))
|
|
204
|
+
y: Math.max(0, Math.min(furniture.xAxisY + root.plot.tick_length + 3,
|
|
205
|
+
furniture.height - height))
|
|
170
206
|
}
|
|
171
207
|
}
|
|
172
208
|
}
|
|
@@ -177,21 +213,26 @@ Item {
|
|
|
177
213
|
delegate: Item {
|
|
178
214
|
required property var modelData
|
|
179
215
|
|
|
216
|
+
x: 0
|
|
180
217
|
y: Math.round(root.mapToScreen(0, modelData[0]).y)
|
|
181
|
-
width:
|
|
218
|
+
width: furniture.width
|
|
182
219
|
height: 1
|
|
183
|
-
anchors.left: parent.left
|
|
184
220
|
|
|
185
221
|
Rectangle {
|
|
186
|
-
|
|
222
|
+
x: Math.max(0, Math.min(furniture.yAxisX - width, furniture.width - width))
|
|
223
|
+
width: root.plot.tick_length
|
|
224
|
+
height: 1
|
|
187
225
|
color: root.plot.tick_color
|
|
188
226
|
}
|
|
189
227
|
Text {
|
|
190
228
|
text: modelData[1]
|
|
191
229
|
color: root.plot.text_color
|
|
192
230
|
font.pixelSize: root.plot.tick_font_size
|
|
193
|
-
x:
|
|
194
|
-
|
|
231
|
+
x: Math.max(3, Math.min(furniture.yAxisX - width - 3,
|
|
232
|
+
furniture.width - width - 3))
|
|
233
|
+
// centred on the tick, but never half off the item
|
|
234
|
+
y: Math.max(-parent.y, Math.min(-Math.round(height / 2),
|
|
235
|
+
furniture.height - height - parent.y))
|
|
195
236
|
}
|
|
196
237
|
}
|
|
197
238
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Matplotlib's default style (the values in matplotlib.rcParamsDefault), so a plot with no
|
|
2
|
+
# theme looks like a matplotlib plot out of the box. Written out rather than imported because
|
|
3
|
+
# Matplotlib keeps its defaults in rcParams, not in a stylelib file.
|
|
4
|
+
axes.facecolor: white
|
|
5
|
+
figure.facecolor: white
|
|
6
|
+
axes.edgecolor: black
|
|
7
|
+
axes.linewidth: 0.8
|
|
8
|
+
axes.grid: False
|
|
9
|
+
grid.color: b0b0b0
|
|
10
|
+
grid.linestyle: -
|
|
11
|
+
grid.linewidth: 0.8
|
|
12
|
+
grid.alpha: 1.0
|
|
13
|
+
axes.prop_cycle: cycler('color', ['1f77b4', 'ff7f0e', '2ca02c', 'd62728', '9467bd', '8c564b', 'e377c2', '7f7f7f', 'bcbd22', '17becf'])
|
|
14
|
+
lines.linewidth: 1.5
|
|
15
|
+
text.color: black
|
|
16
|
+
axes.labelcolor: black
|
|
17
|
+
xtick.color: black
|
|
18
|
+
ytick.color: black
|
|
19
|
+
xtick.major.size: 3.5
|
|
20
|
+
ytick.major.size: 3.5
|
|
21
|
+
font.size: 10
|
|
22
|
+
xtick.labelsize: medium
|
|
23
|
+
ytick.labelsize: medium
|
|
24
|
+
axes.titlecolor: black
|
|
25
|
+
axes.titlesize: large
|
|
26
|
+
axes.titleweight: normal
|
qmlmathplot/themes.py
CHANGED
|
@@ -60,6 +60,8 @@ _SOURCES: dict[str, tuple[str, ...]] = {
|
|
|
60
60
|
"grid_alpha": ("grid.alpha",),
|
|
61
61
|
"grid_style": ("grid.linestyle",),
|
|
62
62
|
"color_cycle": ("axes.prop_cycle",),
|
|
63
|
+
"axis_color": ("axes.edgecolor",),
|
|
64
|
+
"axis_width": ("axes.linewidth",),
|
|
63
65
|
"line_width": ("lines.linewidth",),
|
|
64
66
|
"text_color": ("text.color", "axes.labelcolor"),
|
|
65
67
|
"tick_color": ("xtick.color", "ytick.color"),
|
|
@@ -75,26 +77,29 @@ _SOURCES: dict[str, tuple[str, ...]] = {
|
|
|
75
77
|
_BOOL_KEYS = frozenset({"axes.grid", "axes.titleweight"})
|
|
76
78
|
#: Keys whose value is a size in points (converted to logical pixels).
|
|
77
79
|
_PT_KEYS = frozenset({
|
|
78
|
-
"grid.linewidth", "lines.linewidth", "
|
|
80
|
+
"grid.linewidth", "lines.linewidth", "axes.linewidth",
|
|
81
|
+
"xtick.major.size", "ytick.major.size",
|
|
79
82
|
"font.size", "xtick.labelsize", "ytick.labelsize", "axes.titlesize",
|
|
80
83
|
})
|
|
81
84
|
#: Keys whose value is a colour.
|
|
82
85
|
_COLOR_KEYS = frozenset({
|
|
83
86
|
"xtick.color", "ytick.color", "text.color", "axes.labelcolor", "axes.titlecolor",
|
|
84
|
-
"grid.color", "axes.facecolor", "figure.facecolor",
|
|
87
|
+
"grid.color", "axes.facecolor", "figure.facecolor", "axes.edgecolor",
|
|
85
88
|
})
|
|
86
89
|
|
|
87
90
|
|
|
88
91
|
def names() -> list[str]:
|
|
89
|
-
"""Names of the bundled themes (file stems),
|
|
92
|
+
"""Names of the bundled themes (file stems), ``"default"`` first.
|
|
93
|
+
|
|
94
|
+
``"default"`` is Matplotlib's default style (``themes/default.mplstyle``), which is also
|
|
95
|
+
what a Plot uses before any theme is applied, so ``plot.theme = "default"`` restores it.
|
|
96
|
+
"""
|
|
90
97
|
stems = sorted(p.stem for p in THEME_DIR.glob("*.mplstyle")) if THEME_DIR.is_dir() else []
|
|
91
|
-
return ["default", *stems]
|
|
98
|
+
return ["default", *(s for s in stems if s != "default")]
|
|
92
99
|
|
|
93
100
|
|
|
94
101
|
def raw(name: str) -> dict[str, str]:
|
|
95
102
|
"""The theme's raw ``rcParams`` (key -> text value), before conversion."""
|
|
96
|
-
if name == "default":
|
|
97
|
-
return {}
|
|
98
103
|
path = Path(name)
|
|
99
104
|
if not path.is_file():
|
|
100
105
|
path = THEME_DIR / f"{name}.mplstyle"
|
qmlmathplot/widget.py
CHANGED
|
@@ -37,9 +37,9 @@ class MathPlotWidget(QWidget):
|
|
|
37
37
|
"""Function plotting widget (QWidget version).
|
|
38
38
|
|
|
39
39
|
:param expression: expression in sympy syntax, ``"sin(x)"`` by default
|
|
40
|
-
:param line_width: line width (logical pixels)
|
|
41
|
-
:param curve_color: curve color (``#rrggbb``)
|
|
42
|
-
:param background_color: background color (``#rrggbb``)
|
|
40
|
+
:param line_width: line width (logical pixels); None keeps the plot's own default
|
|
41
|
+
:param curve_color: curve color (``#rrggbb``); None keeps the plot's own default
|
|
42
|
+
:param background_color: background color (``#rrggbb``); None keeps the plot's own default
|
|
43
43
|
:param aspect: ``"auto"`` (default) or the ratio of the y scale to the x scale
|
|
44
44
|
|
|
45
45
|
The signals ``expressionChanged`` / ``errorChanged`` stay in sync with the first curve; a
|
|
@@ -54,14 +54,15 @@ class MathPlotWidget(QWidget):
|
|
|
54
54
|
expression: str = "sin(x)",
|
|
55
55
|
parent: QWidget | None = None,
|
|
56
56
|
*,
|
|
57
|
-
line_width: float =
|
|
58
|
-
curve_color: str =
|
|
59
|
-
background_color: str =
|
|
57
|
+
line_width: float | None = None,
|
|
58
|
+
curve_color: str | None = None,
|
|
59
|
+
background_color: str | None = None,
|
|
60
60
|
aspect: str | float = "auto",
|
|
61
61
|
) -> None:
|
|
62
62
|
# Check before super().__init__(): constructing a QWidget without a
|
|
63
|
-
# QApplication aborts inside Qt, so the friendly error would never be reached
|
|
64
|
-
|
|
63
|
+
# QApplication aborts inside Qt, so the friendly error would never be reached (note
|
|
64
|
+
# that `QApplication.instance()` is the QGuiApplication in a pure-QML host).
|
|
65
|
+
if not isinstance(QApplication.instance(), QApplication):
|
|
65
66
|
raise RuntimeError("please create a QApplication first — QQuickWidget needs QtWidgets")
|
|
66
67
|
super().__init__(parent)
|
|
67
68
|
register_qml_types()
|
|
@@ -79,8 +80,12 @@ class MathPlotWidget(QWidget):
|
|
|
79
80
|
|
|
80
81
|
self._root = self._quick.rootObject() # status is Ready, so this is always valid
|
|
81
82
|
self._plot = Plot(self)
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
# None for a style argument means "keep the plot's own default" (which the plot takes
|
|
84
|
+
# from its theme), so the widget does not re-state matplotlib's numbers.
|
|
85
|
+
if background_color is not None:
|
|
86
|
+
self._plot.background = background_color
|
|
87
|
+
if line_width is not None:
|
|
88
|
+
self._plot.line_width = float(line_width)
|
|
84
89
|
self._curve = self._plot.add_curve(expression, color=curve_color, line_width=line_width)
|
|
85
90
|
self._curve.expressionChanged.connect(self.expressionChanged)
|
|
86
91
|
self._curve.errorChanged.connect(self.errorChanged)
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: qmlmathplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)
|
|
5
5
|
Author: I-AM-A-NOOB
|
|
6
6
|
Author-email: I-AM-A-NOOB <suzc08@outlook.com>
|
|
7
7
|
Requires-Dist: sympy>=1.14
|
|
8
8
|
Requires-Dist: pyside6>=6.7
|
|
9
|
-
Requires-
|
|
9
|
+
Requires-Dist: matplotlib>=3.8 ; extra == 'matplotlib'
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Provides-Extra: matplotlib
|
|
10
12
|
Description-Content-Type: text/markdown
|
|
11
13
|
|
|
12
14
|
# QMLMathPlot
|
|
@@ -38,6 +40,10 @@ window.show()
|
|
|
38
40
|
app.exec()
|
|
39
41
|
```
|
|
40
42
|
|
|
43
|
+
An un-themed plot looks like matplotlib (white background, tab10 colours, grey grid, black
|
|
44
|
+
axes through the origin with the tick labels riding them) — the default style sheet is applied
|
|
45
|
+
at construction, so `plot.theme = "default"` is the reset.
|
|
46
|
+
|
|
41
47
|
`MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
|
|
42
48
|
`line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
|
|
43
49
|
`reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
|
|
@@ -116,7 +122,7 @@ The model:
|
|
|
116
122
|
|
|
117
123
|
| Object | Members |
|
|
118
124
|
|---|---|
|
|
119
|
-
| `Plot` | `camera`, `curves`, `xlim`/`ylim` (forwarded), `add_curve()` / `remove_curve()`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
|
|
125
|
+
| `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
|
|
120
126
|
| `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, slots `setViewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `pan_pixels(dx, dy)` / `reset()` |
|
|
121
127
|
| `Curve` | `expression`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
|
|
122
128
|
| `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
|
|
@@ -135,8 +141,8 @@ build order — is specified in [`docs/api-design.md`](docs/api-design.md). In s
|
|
|
135
141
|
Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
|
|
136
142
|
while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
|
|
137
143
|
bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no legend (host
|
|
138
|
-
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
139
|
-
implemented
|
|
144
|
+
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
145
|
+
(§9, implemented — see *Export* below).
|
|
140
146
|
|
|
141
147
|
## The camera (centre, zoom, aspect)
|
|
142
148
|
|
|
@@ -164,12 +170,16 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
|
|
|
164
170
|
|
|
165
171
|
## Themes
|
|
166
172
|
|
|
167
|
-
`Plot.theme` applies a style sheet to the style properties above
|
|
173
|
+
`Plot.theme` applies a style sheet to the style properties above; the default is
|
|
174
|
+
matplotlib's own default style, so an untouched plot already looks like matplotlib and
|
|
175
|
+
`plot.theme = "default"` is the reset:
|
|
168
176
|
|
|
169
177
|
```python
|
|
170
178
|
plot.theme = "ggplot" # any of the bundled sheets
|
|
179
|
+
plot.theme = "default" # back to matplotlib's look
|
|
171
180
|
plot.theme = "my-style" # themes/my-style.mplstyle next to the package
|
|
172
181
|
plot.theme = "path/to/any.mplstyle" # any file in the same format
|
|
182
|
+
plot.available_themes # the names, for a host's picker (both demos use one)
|
|
173
183
|
```
|
|
174
184
|
|
|
175
185
|
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
@@ -179,6 +189,86 @@ have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes
|
|
|
179
189
|
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
180
190
|
theme alone.
|
|
181
191
|
|
|
192
|
+
## Axes, grid and ticks
|
|
193
|
+
|
|
194
|
+
- The **grid** is drawn in QML (a `Canvas`), one line per tick position, in `grid_color` /
|
|
195
|
+
`grid_width` / `grid_alpha` / `grid_style` (`-`, `--`, `:`, `-.`).
|
|
196
|
+
- The **axes** are the lines through world (0,0) — the x-axis at `y = 0`, the y-axis at
|
|
197
|
+
`x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
|
|
198
|
+
axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
|
|
199
|
+
nearest edge, so its labels are never lost (and a label never leaves the item); the grid
|
|
200
|
+
stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
|
|
201
|
+
- `camera.viewport` reports the last size the view sent, so a host can size an export from it.
|
|
202
|
+
- The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
|
|
203
|
+
`nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
|
|
204
|
+
and formats them, so a frame never recomputes ticks.
|
|
205
|
+
- `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
|
|
206
|
+
|
|
207
|
+
## Export
|
|
208
|
+
|
|
209
|
+
`savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
plot.savefig("out.png", xlim=(-1, 1), ylim=(-1, 1), width=1200, height=400)
|
|
213
|
+
plot.savefig("hi.png", dpi=2.0) # the live view, twice the pixels
|
|
214
|
+
image = plot.to_image(width=800, height=600) # -> QImage
|
|
215
|
+
plot.to_image() # the live view, its own size
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- `xlim` / `ylim` default to the **camera**: the live centre and scale, extended to the export
|
|
219
|
+
size. The *region* is a parameter, so an export can cover more canvas than the widget shows.
|
|
220
|
+
- `width` / `height` are **device pixels** and default to the live view's device size; `dpi`
|
|
221
|
+
multiplies both (`dpi=2.0` doubles the pixels).
|
|
222
|
+
- `adjustable` reconciles a requested range with a requested size when their aspects disagree —
|
|
223
|
+
Matplotlib's parameter, and its own default:
|
|
224
|
+
|
|
225
|
+
| `adjustable` | What happens |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `"box"` (**default**) | one scale, taken from the live scale ratio, chosen so the requested range fits: the range is centred and the leftover pixels stay background — *nothing is distorted* |
|
|
228
|
+
| `"datalim"` | the camera's own scales, with the limits expanding (never cropped) to fill the size |
|
|
229
|
+
| `"stretch"` | the requested range maps onto the requested size exactly — independent scales, so a mismatched aspect **distorts** (the "report figure" mode) |
|
|
230
|
+
|
|
231
|
+
With no explicit range all three agree, because the range derived from the camera already has
|
|
232
|
+
the export size's aspect.
|
|
233
|
+
- `transparent=True` clears to alpha 0.
|
|
234
|
+
- Everything else follows the live styling (curves, grid, axes, theme), so an export cannot
|
|
235
|
+
drift from what the user sees. Rendering goes through an offscreen `QQuickWidget`, so the
|
|
236
|
+
export needs a `QApplication` (a clear error is raised instead of a crash if there is none).
|
|
237
|
+
|
|
238
|
+
### Vector output, matplotlib and sympy
|
|
239
|
+
|
|
240
|
+
`to_matplotlib()` hands the plot to matplotlib (one `Axes`) and `savefig(..., backend="matplotlib")`
|
|
241
|
+
renders through it, so `.svg`/`.pdf` work; `to_sympy()` builds the same figure through
|
|
242
|
+
`sympy.plotting`, which is what you want when the figure should live in sympy's world:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
figure = plot.to_matplotlib(xlim=(-1, 1), ylim=(-1, 1)) # -> matplotlib.figure.Figure
|
|
246
|
+
plot.savefig("out.svg", backend="matplotlib") # vector, matplotlib's own savefig
|
|
247
|
+
handle = plot.to_sympy(xlim=(-1, 1))
|
|
248
|
+
handle.save("out.png")
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
matplotlib is an **optional** extra (`pip install "qmlmathplot[matplotlib]"`); nothing in the
|
|
252
|
+
package imports it until one of these is called, and the error names the extra. `sympy` itself is
|
|
253
|
+
a hard dependency, so `to_sympy()` needs matplotlib only because its plotting backend does.
|
|
254
|
+
Neither hand-off needs a Qt application: a `Plot` works in a plain script, which is what makes it
|
|
255
|
+
usable next to a notebook or a matplotlib-only tool.
|
|
256
|
+
|
|
257
|
+
**This is a second renderer, and a sampled one.** matplotlib draws polylines, so each curve is
|
|
258
|
+
evaluated at `samples` points (default 2000) and an oscillating function such as `sin(1/x)`
|
|
259
|
+
aliases — exactly what the Qt view avoids by evaluating the expression per pixel. SymPy does not
|
|
260
|
+
solve it either, and its adaptive mode does not help: measured on `sin(1/x)` over [-1, 1],
|
|
261
|
+
`sympy.plotting.plot(adaptive=True)` uses **289** points (fewer than a fixed 2000), emits no
|
|
262
|
+
warning in sympy 1.14, and draws the same block of vertical connectors. The difference is how
|
|
263
|
+
the two fail: a sampled renderer draws an artefact, the Qt view draws the envelope the data
|
|
264
|
+
actually supports. (`to_sympy()` returns the object sympy made, so its own `adaptive` mode is
|
|
265
|
+
available there.) Fonts and antialiasing are matplotlib's, not the Qt view's, so the two
|
|
266
|
+
renderers cannot look identical, and the style mapping is partial: background, grid (on/off and
|
|
267
|
+
colour/width/alpha/dash), per-curve colour and width, tick and text colours, font sizes, title,
|
|
268
|
+
axis colour/width, the limits, the aspect with matplotlib's own `adjustable`, and
|
|
269
|
+
`axes_position="zero"` (spines through the origin, no box). What it buys is vector output and
|
|
270
|
+
matplotlib's ecosystem — nothing else.
|
|
271
|
+
|
|
182
272
|
## RHI backends
|
|
183
273
|
|
|
184
274
|
`--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
|
|
@@ -197,6 +287,8 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
|
|
|
197
287
|
| Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
|
|
198
288
|
| View | `src/qmlmathplot/qml/PlotView.qml` (+ `view.py`) | QML: background, grid, ticks/title, one `ShaderEffect` per curve, pan/zoom input; `view.py` registers the types and yields the component path |
|
|
199
289
|
| Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
|
|
290
|
+
| Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
|
|
291
|
+
| Hand-off | `src/qmlmathplot/mpl.py` | `Plot.to_matplotlib()` / `to_sympy()` / `savefig(backend="matplotlib")`: a sampled second renderer for vector output (matplotlib is an optional extra, imported lazily) |
|
|
200
292
|
| Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
|
|
201
293
|
| Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
|
|
202
294
|
| Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
|
|
@@ -237,15 +329,18 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
237
329
|
- `test_curve.py`: a failed expression keeps the last working shaders, the style
|
|
238
330
|
setters are idempotent, and the list model reports the right roles.
|
|
239
331
|
- `test_plot.py`: the plot's properties convert, notify exactly once and are wired
|
|
240
|
-
(a PySide property with an unresolved notify name silently loses it), the
|
|
241
|
-
are forwarded to the camera, and a theme applies
|
|
332
|
+
(a PySide property with an unresolved notify name silently loses it), the defaults are
|
|
333
|
+
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
334
|
+
(and re-applies as a reset) while an unknown name is rejected.
|
|
242
335
|
- `test_themes.py`: the vendored style sheets resolve to this library's properties.
|
|
243
336
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
244
337
|
the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
|
|
245
338
|
- `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
|
|
246
339
|
shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
|
|
247
340
|
columns, poles not connected, nothing drawn outside the domain, and the QML
|
|
248
|
-
furniture (grid lines exactly at the tick positions,
|
|
341
|
+
furniture (grid lines exactly at the tick positions, the axes crossing at the origin
|
|
342
|
+
with their labels on them, the axis pinning to the edge once the origin is panned
|
|
343
|
+
away, the title).
|
|
249
344
|
- `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
|
|
250
345
|
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
251
346
|
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
@@ -255,6 +350,17 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
255
350
|
needs one, and a process may only have one).
|
|
256
351
|
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
257
352
|
must not steal the pan).
|
|
353
|
+
- `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
|
|
354
|
+
`adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
|
|
355
|
+
outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
|
|
356
|
+
`.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
|
|
357
|
+
`qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
|
|
358
|
+
extra.
|
|
359
|
+
- `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
|
|
360
|
+
(`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
|
|
361
|
+
`adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
|
|
362
|
+
in `"box"`, with background margins; `"stretch"` fills the frame and distorts), the live
|
|
363
|
+
camera and background survive an export, and `transparent` clears to alpha 0.
|
|
258
364
|
|
|
259
365
|
## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
|
|
260
366
|
|
|
@@ -355,6 +461,12 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
355
461
|
same x 9 times, and macros are preprocessor expansion with no call semantics.
|
|
356
462
|
- PySide ties a property's notify signal to the `Signal` **object**; a name string
|
|
357
463
|
silently leaves the property without one (and QML bindings then never refresh).
|
|
464
|
+
- Snap **hairlines** (grid) to a half pixel, but never a **styled** line: rounding the
|
|
465
|
+
axis position put the 1.07 px y-axis half off the centre column, so the line came out
|
|
466
|
+
pale where it should be black.
|
|
467
|
+
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
468
|
+
widget has no such property, so it silently does nothing): inject the model with
|
|
469
|
+
`widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
|
|
358
470
|
|
|
359
471
|
## Releasing
|
|
360
472
|
|
|
@@ -376,6 +488,12 @@ be rehearsed:
|
|
|
376
488
|
pip install -i https://test.pypi.org/simple/ qmlmathplot
|
|
377
489
|
```
|
|
378
490
|
|
|
491
|
+
If the publish step fails with `invalid-publisher: valid token, but no corresponding
|
|
492
|
+
publisher`, the trusted-publisher entry is missing or does not match. The action prints the
|
|
493
|
+
claims it presented (`repository`, `workflow_ref`, `environment`) — the entry must match them
|
|
494
|
+
exactly, **including the environment name**, and it must exist on the index the run targets: a
|
|
495
|
+
manual run goes to **test.pypi.org**, a tag push to **pypi.org**, so both need an entry.
|
|
496
|
+
|
|
379
497
|
The workflow runs on Windows: that is the platform this library is verified on (the shader baker
|
|
380
498
|
`qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
|
|
381
499
|
and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
qmlmathplot/__init__.py,sha256=C_fYVkO8WQPVW1K_wvjdH8OniRbUpdCkIPB-ULvP6Kk,2832
|
|
2
2
|
qmlmathplot/app.py,sha256=9ojtrU9_wIcgUq7Ib449KRkuLSsr7FLzSBNguI-T8l4,3124
|
|
3
|
-
qmlmathplot/camera.py,sha256=
|
|
4
|
-
qmlmathplot/curve.py,sha256=
|
|
3
|
+
qmlmathplot/camera.py,sha256=YSLtGODs_efGwmtKbjT9osss-jmyV7MaLEYOu_zcxg0,16894
|
|
4
|
+
qmlmathplot/curve.py,sha256=Q-3S8WhsHWanWnZXkHH_nak8q7CXDHSioqyUyblKQJ0,10723
|
|
5
|
+
qmlmathplot/export.py,sha256=QfoE4TKnKt-xdaUAkB8IsyupyfwFKu8hn0ikKp8mzrI,8065
|
|
5
6
|
qmlmathplot/model.py,sha256=WAY2VyoMKR5vGkPxrr2GOfUnl0xXW01ntfQ_qW8i2Wg,24961
|
|
6
|
-
qmlmathplot/
|
|
7
|
-
qmlmathplot/
|
|
7
|
+
qmlmathplot/mpl.py,sha256=Liaqq1NWBjuSe95uxBsx2YbUULU4F5kvcvWDQErnE-Y,10684
|
|
8
|
+
qmlmathplot/plot.py,sha256=XDp_Jg2FQLQLDmUhUy5tuzAFCerihwitaBICp4i_xuA,15306
|
|
9
|
+
qmlmathplot/qml/PlotView.qml,sha256=4XfSGcmlsD6LNua_qhB5DtVuL-PTFO3gG4LhJzzQC7M,11519
|
|
8
10
|
qmlmathplot/qsb.py,sha256=kTaC3hKAZYSNU5M2ySRFcWUdwTZT8DAHUdsUgKRCanc,3964
|
|
9
11
|
qmlmathplot/themes/LICENSE.matplotlib,sha256=ojr3trhyymyw7GeYxkhO2JYoAxUVscbgMFz9b1qrcVM,4928
|
|
10
12
|
qmlmathplot/themes/README.md,sha256=4IHDS6yasksnvrWvhEa5b5aUIa73US2C72-ndS3blow,767
|
|
@@ -12,6 +14,7 @@ qmlmathplot/themes/Solarize_Light2.mplstyle,sha256=En_R213nyEuOVDRw_RyN51uYLgJFP
|
|
|
12
14
|
qmlmathplot/themes/bmh.mplstyle,sha256=IxH21Mw1K17WATUfza3HdG7uBdT1-Sksv0H0P9DsiVY,714
|
|
13
15
|
qmlmathplot/themes/classic.mplstyle,sha256=ewUO1vd72JCowfCwc5i3CJrt1X5wmjSI1UzcSNNhXzc,25082
|
|
14
16
|
qmlmathplot/themes/dark_background.mplstyle,sha256=8nQHo3xgzWx6SPf8rsQ2gT89NXyQHaLhkgsDbAantGk,633
|
|
17
|
+
qmlmathplot/themes/default.mplstyle,sha256=2fVjCiF2yDrw87nz0i4k39fY3izejl5AZ-OKF9SlXwU,869
|
|
15
18
|
qmlmathplot/themes/fast.mplstyle,sha256=HDqa0GATC9GjNeRA8rYiZM-qh7hTxsraeyYziGlbgzg,299
|
|
16
19
|
qmlmathplot/themes/fivethirtyeight.mplstyle,sha256=lkCjlDtqUnDiMxEr6BDa-qMMIfVDxVS8ycfJcv8bmtw,817
|
|
17
20
|
qmlmathplot/themes/ggplot.mplstyle,sha256=S7Oc_ty3DpUM0A2kctZBemMjj7IS0ccJl06QJFQh2Xw,995
|
|
@@ -34,10 +37,10 @@ qmlmathplot/themes/seaborn-v0_8-white.mplstyle,sha256=VY6sw8wkqbl0leWtWa5gz8xfDM
|
|
|
34
37
|
qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle,sha256=IOm2H1utXO_zR7FWqMLBiRxHyxABL3kq1fh0-6BDJ0E,694
|
|
35
38
|
qmlmathplot/themes/seaborn-v0_8.mplstyle,sha256=N9lUFHvOn06wT4MODXpVVGQMSueONejeAfCX5UfWrIM,1187
|
|
36
39
|
qmlmathplot/themes/tableau-colorblind10.mplstyle,sha256=PzUMoOtw0V6l0bPk8ApRAKvcxdJmzRU2bVOkNqz8DnU,192
|
|
37
|
-
qmlmathplot/themes.py,sha256=
|
|
40
|
+
qmlmathplot/themes.py,sha256=QocZLOgPYXCJW9fYc6i5q6nHEE_S4mQVZXySZ4ISCPw,9225
|
|
38
41
|
qmlmathplot/view.py,sha256=G6W0WfI4yT0eX4rHswqwu3y48MQTXjcA0BnWpIS1vD0,1746
|
|
39
|
-
qmlmathplot/widget.py,sha256=
|
|
40
|
-
qmlmathplot-0.
|
|
41
|
-
qmlmathplot-0.
|
|
42
|
-
qmlmathplot-0.
|
|
43
|
-
qmlmathplot-0.
|
|
42
|
+
qmlmathplot/widget.py,sha256=HXSQko0YwixG0bx--BP6k_Fd_fn-58P-ywT2-HZw0E0,7899
|
|
43
|
+
qmlmathplot-0.2.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
|
|
44
|
+
qmlmathplot-0.2.0.dist-info/entry_points.txt,sha256=4z36D_RpPSYLiJDRM8iXnrSe1T4c2alfQJPcJTss3qY,54
|
|
45
|
+
qmlmathplot-0.2.0.dist-info/METADATA,sha256=qlC49LZEVjSKctg5FELNUSiMnUkisgwwAd5VtKONzqU,30542
|
|
46
|
+
qmlmathplot-0.2.0.dist-info/RECORD,,
|
|
File without changes
|