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/plot.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Plot layer: what the view binds to — a camera, a list of curves, and the styling.
|
|
2
|
+
|
|
3
|
+
The plot owns no pixels: it is a plain QObject, so the same plot can be shown by
|
|
4
|
+
``PlotView``, embedded through ``MathPlotWidget`` or rendered offscreen. Styling lives here
|
|
5
|
+
(one property per knob, each with a notify signal) so a theme is just a dict of property
|
|
6
|
+
names — see ``theme``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import Property, QObject, Signal, Slot
|
|
12
|
+
from PySide6.QtGui import QColor, QVector2D
|
|
13
|
+
|
|
14
|
+
from .camera import Camera
|
|
15
|
+
from .curve import DEFAULT_COLOR_CYCLE, Curve, CurveListModel
|
|
16
|
+
|
|
17
|
+
__all__ = ["Plot"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_color(value: object) -> QColor:
|
|
21
|
+
return QColor(value) if not isinstance(value, QColor) else QColor(value)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
#: Value conversions per style kind (QML hands over whatever JS has: 1 for a bool, a string
|
|
25
|
+
#: for a colour, …).
|
|
26
|
+
_CONVERTERS = {
|
|
27
|
+
bool: bool,
|
|
28
|
+
float: float,
|
|
29
|
+
str: str,
|
|
30
|
+
QColor: _to_color,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _style(kind: object, name: str, notify: Signal, default: object) -> Property:
|
|
35
|
+
"""One style Property, with the house rules baked in: converts, idempotent, and emits its
|
|
36
|
+
notify signal only on a real change (a theme writes every knob in a row).
|
|
37
|
+
|
|
38
|
+
``notify`` must be the signal *object* (PySide6 needs it to wire the property up; a name
|
|
39
|
+
string silently leaves the property without a notify signal, and QML bindings then never
|
|
40
|
+
refresh). Emitting goes through the descriptor protocol to reach this instance's signal.
|
|
41
|
+
"""
|
|
42
|
+
convert = _CONVERTERS.get(kind)
|
|
43
|
+
|
|
44
|
+
def getter(self: Plot) -> object:
|
|
45
|
+
return getattr(self, "_" + name, default)
|
|
46
|
+
|
|
47
|
+
def setter(self: Plot, value: object) -> None:
|
|
48
|
+
value = convert(value) if convert is not None else value # type: ignore[operator]
|
|
49
|
+
if value == getattr(self, "_" + name, default):
|
|
50
|
+
return
|
|
51
|
+
setattr(self, "_" + name, value)
|
|
52
|
+
notify.__get__(self, type(self)).emit()
|
|
53
|
+
|
|
54
|
+
return Property(kind, getter, setter, notify=notify)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Plot(QObject):
|
|
58
|
+
"""The canvas state: a camera, the curves, and how they are dressed."""
|
|
59
|
+
|
|
60
|
+
cameraChanged = Signal()
|
|
61
|
+
curvesChanged = Signal()
|
|
62
|
+
viewChanged = Signal()
|
|
63
|
+
aspectChanged = Signal() # forwarded from the camera (the forwarded `aspect`)
|
|
64
|
+
themeChanged = Signal()
|
|
65
|
+
|
|
66
|
+
backgroundChanged = Signal()
|
|
67
|
+
gridChanged = Signal()
|
|
68
|
+
gridColorChanged = Signal()
|
|
69
|
+
gridWidthChanged = Signal()
|
|
70
|
+
gridAlphaChanged = Signal()
|
|
71
|
+
gridStyleChanged = Signal()
|
|
72
|
+
colorCycleChanged = Signal()
|
|
73
|
+
lineWidthChanged = Signal()
|
|
74
|
+
textColorChanged = Signal()
|
|
75
|
+
tickColorChanged = Signal()
|
|
76
|
+
tickLengthChanged = Signal()
|
|
77
|
+
fontSizeChanged = Signal()
|
|
78
|
+
tickFontSizeChanged = Signal()
|
|
79
|
+
titleChanged = Signal()
|
|
80
|
+
titleColorChanged = Signal()
|
|
81
|
+
titleFontSizeChanged = Signal()
|
|
82
|
+
titleBoldChanged = Signal()
|
|
83
|
+
ticksVisibleChanged = Signal()
|
|
84
|
+
|
|
85
|
+
def __init__(self, parent: QObject | None = None) -> None:
|
|
86
|
+
super().__init__(parent)
|
|
87
|
+
self._camera = Camera(self)
|
|
88
|
+
self._curves = CurveListModel(DEFAULT_COLOR_CYCLE, self.line_width, self)
|
|
89
|
+
self._camera.viewChanged.connect(self.viewChanged)
|
|
90
|
+
self._camera.aspectChanged.connect(self.aspectChanged)
|
|
91
|
+
self.cameraChanged.emit()
|
|
92
|
+
self.curvesChanged.emit()
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------- the model
|
|
95
|
+
def _get_camera(self) -> Camera:
|
|
96
|
+
return self._camera
|
|
97
|
+
|
|
98
|
+
camera: Camera = Property(Camera, _get_camera, notify=cameraChanged)
|
|
99
|
+
|
|
100
|
+
def _get_curves(self) -> CurveListModel:
|
|
101
|
+
return self._curves
|
|
102
|
+
|
|
103
|
+
# Declared as QObject: PySide6 cannot register a Python QAbstractListModel subclass as a
|
|
104
|
+
# Qt property type (the meta-object rejects "QAbstractListModel*"), and QML only needs the
|
|
105
|
+
# object to *be* a list model.
|
|
106
|
+
curves: CurveListModel = Property(QObject, _get_curves, notify=curvesChanged)
|
|
107
|
+
|
|
108
|
+
def _get_xlim(self) -> QVector2D:
|
|
109
|
+
return self._camera.xlim
|
|
110
|
+
|
|
111
|
+
def _set_xlim(self, value: object) -> None:
|
|
112
|
+
self._camera.xlim = value
|
|
113
|
+
|
|
114
|
+
def _get_ylim(self) -> QVector2D:
|
|
115
|
+
return self._camera.ylim
|
|
116
|
+
|
|
117
|
+
def _set_ylim(self, value: object) -> None:
|
|
118
|
+
self._camera.ylim = value
|
|
119
|
+
|
|
120
|
+
def _get_aspect(self) -> str | float:
|
|
121
|
+
return self._camera.aspect
|
|
122
|
+
|
|
123
|
+
def _set_aspect(self, value: object) -> None:
|
|
124
|
+
self._camera.aspect = value
|
|
125
|
+
|
|
126
|
+
# Forwarded to the camera; "QVariant" so a plain (lo, hi) pair is accepted as well.
|
|
127
|
+
xlim: QVector2D = Property("QVariant", _get_xlim, _set_xlim, notify=viewChanged)
|
|
128
|
+
ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
|
|
129
|
+
# `aspect` is forwarded too because a QML *binding* cannot reach through an object chain
|
|
130
|
+
# (`plot.camera.aspect: 1.0` is rejected by QML), while a direct property binds fine.
|
|
131
|
+
aspect: str | float = Property("QVariant", _get_aspect, _set_aspect, notify=aspectChanged)
|
|
132
|
+
|
|
133
|
+
@Slot(str, "QVariant", "QVariant", str, result=QObject)
|
|
134
|
+
@Slot(str, result=QObject)
|
|
135
|
+
def add_curve(
|
|
136
|
+
self,
|
|
137
|
+
expression: str,
|
|
138
|
+
color: object = None,
|
|
139
|
+
line_width: object = None,
|
|
140
|
+
label: str = "",
|
|
141
|
+
) -> Curve:
|
|
142
|
+
"""Add a curve (colour and width default to the plot's cycle and ``lineWidth``)."""
|
|
143
|
+
return self._curves.add_curve(expression, color, line_width, label)
|
|
144
|
+
|
|
145
|
+
@Slot(QObject)
|
|
146
|
+
def remove_curve(self, curve: Curve) -> None:
|
|
147
|
+
self._curves.remove_curve(curve)
|
|
148
|
+
|
|
149
|
+
# -------------------------------------------------------------- styling
|
|
150
|
+
background: QColor = _style(QColor, "background", backgroundChanged, QColor("#14141e"))
|
|
151
|
+
grid: bool = _style(bool, "grid", gridChanged, True)
|
|
152
|
+
grid_color: QColor = _style(QColor, "grid_color", gridColorChanged, QColor("#2a2a3a"))
|
|
153
|
+
grid_width: float = _style(float, "grid_width", gridWidthChanged, 1.0)
|
|
154
|
+
grid_alpha: float = _style(float, "grid_alpha", gridAlphaChanged, 0.5)
|
|
155
|
+
#: "-" | "--" | ":" | "-." (QML turns it into a dash pattern).
|
|
156
|
+
grid_style: str = _style(str, "grid_style", gridStyleChanged, "-")
|
|
157
|
+
text_color: QColor = _style(QColor, "text_color", textColorChanged, QColor("#c8c8d2"))
|
|
158
|
+
tick_color: QColor = _style(QColor, "tick_color", tickColorChanged, QColor("#6a6a80"))
|
|
159
|
+
tick_length: float = _style(float, "tick_length", tickLengthChanged, 6.0)
|
|
160
|
+
font_size: float = _style(float, "font_size", fontSizeChanged, 12.0)
|
|
161
|
+
tick_font_size: float = _style(float, "tick_font_size", tickFontSizeChanged, 11.0)
|
|
162
|
+
title: str = _style(str, "title", titleChanged, "")
|
|
163
|
+
title_color: QColor = _style(QColor, "title_color", titleColorChanged, QColor("#e8e8f0"))
|
|
164
|
+
title_font_size: float = _style(float, "title_font_size", titleFontSizeChanged, 15.0)
|
|
165
|
+
title_bold: bool = _style(bool, "title_bold", titleBoldChanged, True)
|
|
166
|
+
ticks_visible: bool = _style(bool, "ticks_visible", ticksVisibleChanged, True)
|
|
167
|
+
|
|
168
|
+
def _get_color_cycle(self) -> list[str]:
|
|
169
|
+
return list(getattr(self, "_color_cycle", DEFAULT_COLOR_CYCLE))
|
|
170
|
+
|
|
171
|
+
def _set_color_cycle(self, value: object) -> None:
|
|
172
|
+
cycle = [str(entry) for entry in value] or [DEFAULT_COLOR_CYCLE[0]] # type: ignore[union-attr]
|
|
173
|
+
if cycle == self._get_color_cycle():
|
|
174
|
+
return
|
|
175
|
+
self._color_cycle = cycle
|
|
176
|
+
self._curves.set_color_cycle(tuple(cycle))
|
|
177
|
+
self.colorCycleChanged.emit()
|
|
178
|
+
|
|
179
|
+
color_cycle: list[str] = Property("QVariant", _get_color_cycle, _set_color_cycle,
|
|
180
|
+
notify=colorCycleChanged)
|
|
181
|
+
|
|
182
|
+
def _get_line_width(self) -> float:
|
|
183
|
+
return float(getattr(self, "_line_width", 1.5))
|
|
184
|
+
|
|
185
|
+
def _set_line_width(self, value: float) -> None:
|
|
186
|
+
value = float(value)
|
|
187
|
+
if value == self._get_line_width():
|
|
188
|
+
return
|
|
189
|
+
self._line_width = value
|
|
190
|
+
self._curves.set_default_line_width(value) # default for the curves added later
|
|
191
|
+
self.lineWidthChanged.emit()
|
|
192
|
+
|
|
193
|
+
#: Default line width of new curves (logical pixels).
|
|
194
|
+
line_width: float = Property(float, _get_line_width, _set_line_width,
|
|
195
|
+
notify=lineWidthChanged)
|
|
196
|
+
|
|
197
|
+
def _get_theme(self) -> str:
|
|
198
|
+
return getattr(self, "_theme", "")
|
|
199
|
+
|
|
200
|
+
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
|
|
206
|
+
|
|
207
|
+
resolved = resolve(name) # unknown name -> KeyError, theme unchanged
|
|
208
|
+
for key, value in resolved.items():
|
|
209
|
+
setattr(self, key, value)
|
|
210
|
+
self._theme = name
|
|
211
|
+
self.themeChanged.emit()
|
|
212
|
+
|
|
213
|
+
theme: str = Property(str, _get_theme, _set_theme, notify=themeChanged)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// View layer: the plot on screen — background, grid, ticks and title in QML, one fragment
|
|
2
|
+
// shader pass per curve.
|
|
3
|
+
//
|
|
4
|
+
// Usage (the host creates the model and injects it; MVVM):
|
|
5
|
+
// PlotView { plot: myPlot }
|
|
6
|
+
// It also works standalone: the component brings its own Plot and one sin(x) curve.
|
|
7
|
+
//
|
|
8
|
+
// The view owns no state: everything it draws is read from the Plot (the camera for the
|
|
9
|
+
// mapping, the curves model for the passes, the style properties for the dressing). Sizes are
|
|
10
|
+
// reported to the camera for the derived ranges only — the shader derives its own mapping from
|
|
11
|
+
// the camera and the item size, so drawing never depends on a size report arriving in order.
|
|
12
|
+
import QtQuick
|
|
13
|
+
import QmlMathPlot 1.0
|
|
14
|
+
|
|
15
|
+
Item {
|
|
16
|
+
id: root
|
|
17
|
+
|
|
18
|
+
implicitWidth: 800
|
|
19
|
+
implicitHeight: 600
|
|
20
|
+
|
|
21
|
+
// The model (injected by the host). Without injection the component brings its own plot
|
|
22
|
+
// with one sin(x) curve, so it is usable standalone; a host that adds curves itself keeps
|
|
23
|
+
// this handler from firing (it only acts on an empty model).
|
|
24
|
+
property Plot plot: defaultPlot
|
|
25
|
+
Plot {
|
|
26
|
+
id: defaultPlot
|
|
27
|
+
|
|
28
|
+
Component.onCompleted: {
|
|
29
|
+
if (curves.rowCount() === 0)
|
|
30
|
+
add_curve("sin(x)");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Forwarders, so a host can drive the component without knowing where the knob lives:
|
|
35
|
+
// `view.camera.aspect = 1.0`, `view.curves.add_curve("tan(x)")`. These are plain
|
|
36
|
+
// properties rather than QML aliases simply because an alias cannot reach through two
|
|
37
|
+
// objects (the plot is an injected property, not an id).
|
|
38
|
+
property QtObject camera: root.plot.camera
|
|
39
|
+
property QtObject curves: root.plot.curves
|
|
40
|
+
|
|
41
|
+
// World <-> screen. The camera maps the centre to the middle of the item, so screen x grows
|
|
42
|
+
// right and screen y grows down (vUV is top-down in the shader too).
|
|
43
|
+
function mapToScreen(x, y) {
|
|
44
|
+
const camera = root.plot.camera;
|
|
45
|
+
return Qt.point((x - camera.centre.x) / camera.scale_x + width / 2,
|
|
46
|
+
(camera.centre.y - y) / camera.scale_y + height / 2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mapFromScreen(point) {
|
|
50
|
+
const camera = root.plot.camera;
|
|
51
|
+
return Qt.point(camera.centre.x + (point.x - width / 2) * camera.scale_x,
|
|
52
|
+
camera.centre.y - (point.y - height / 2) * camera.scale_y);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _gridDash(style) {
|
|
56
|
+
if (style === "--")
|
|
57
|
+
return [6, 4];
|
|
58
|
+
if (style === ":")
|
|
59
|
+
return [1, 3];
|
|
60
|
+
if (style === "-.")
|
|
61
|
+
return [6, 3, 1, 3];
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The first report resolves the home view for the real size; later ones keep the scales, so
|
|
66
|
+
// nothing zooms while a window or a splitter is dragged.
|
|
67
|
+
Component.onCompleted: root.plot.camera.setViewport(width, height)
|
|
68
|
+
onWidthChanged: root.plot.camera.setViewport(width, height)
|
|
69
|
+
onHeightChanged: root.plot.camera.setViewport(width, height)
|
|
70
|
+
|
|
71
|
+
Rectangle {
|
|
72
|
+
anchors.fill: parent
|
|
73
|
+
color: root.plot.background
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Grid: one line per tick, straight from the camera's tick values. Regenerated on camera
|
|
77
|
+
// and style changes only — never per frame.
|
|
78
|
+
Canvas {
|
|
79
|
+
id: gridCanvas
|
|
80
|
+
anchors.fill: parent
|
|
81
|
+
visible: root.plot.grid
|
|
82
|
+
|
|
83
|
+
onPaint: {
|
|
84
|
+
const ctx = getContext("2d");
|
|
85
|
+
ctx.clearRect(0, 0, width, height);
|
|
86
|
+
ctx.lineWidth = root.plot.grid_width;
|
|
87
|
+
ctx.strokeStyle = root.plot.grid_color;
|
|
88
|
+
ctx.globalAlpha = root.plot.grid_alpha;
|
|
89
|
+
ctx.setLineDash(root._gridDash(root.plot.grid_style));
|
|
90
|
+
ctx.beginPath();
|
|
91
|
+
for (const tick of root.plot.camera.ticks_x) {
|
|
92
|
+
const px = Math.round(root.mapToScreen(tick[0], 0).x) + 0.5;
|
|
93
|
+
ctx.moveTo(px, 0);
|
|
94
|
+
ctx.lineTo(px, height);
|
|
95
|
+
}
|
|
96
|
+
for (const tick of root.plot.camera.ticks_y) {
|
|
97
|
+
const py = Math.round(root.mapToScreen(0, tick[0]).y) + 0.5;
|
|
98
|
+
ctx.moveTo(0, py);
|
|
99
|
+
ctx.lineTo(width, py);
|
|
100
|
+
}
|
|
101
|
+
ctx.stroke();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
onWidthChanged: requestPaint()
|
|
105
|
+
onHeightChanged: requestPaint()
|
|
106
|
+
|
|
107
|
+
Connections {
|
|
108
|
+
target: root.plot.camera
|
|
109
|
+
function onViewChanged() { gridCanvas.requestPaint(); }
|
|
110
|
+
function onTicksChanged() { gridCanvas.requestPaint(); }
|
|
111
|
+
}
|
|
112
|
+
Connections {
|
|
113
|
+
target: root.plot
|
|
114
|
+
function onGridChanged() { gridCanvas.requestPaint(); }
|
|
115
|
+
function onGridColorChanged() { gridCanvas.requestPaint(); }
|
|
116
|
+
function onGridWidthChanged() { gridCanvas.requestPaint(); }
|
|
117
|
+
function onGridAlphaChanged() { gridCanvas.requestPaint(); }
|
|
118
|
+
function onGridStyleChanged() { gridCanvas.requestPaint(); }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Curves: one full-screen fragment pass each. The expression lives in the shader, so a
|
|
123
|
+
// change swaps the .qsb and costs one pass.
|
|
124
|
+
Repeater {
|
|
125
|
+
model: root.plot.curves
|
|
126
|
+
|
|
127
|
+
delegate: ShaderEffect {
|
|
128
|
+
anchors.fill: parent
|
|
129
|
+
visible: model.visible
|
|
130
|
+
|
|
131
|
+
property vector4d camera: Qt.vector4d(root.plot.camera.centre.x,
|
|
132
|
+
root.plot.camera.centre.y,
|
|
133
|
+
root.plot.camera.scale_x,
|
|
134
|
+
root.plot.camera.scale_y)
|
|
135
|
+
property vector2d size: Qt.vector2d(root.width, root.height)
|
|
136
|
+
property real lineWidth: model.lineWidth
|
|
137
|
+
property color color: model.color
|
|
138
|
+
|
|
139
|
+
vertexShader: model.vertexShader
|
|
140
|
+
fragmentShader: model.fragmentShader
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Ticks, labels and title: screen-space furniture, so plain items.
|
|
145
|
+
Item {
|
|
146
|
+
anchors.fill: parent
|
|
147
|
+
visible: root.plot.ticks_visible
|
|
148
|
+
|
|
149
|
+
Repeater {
|
|
150
|
+
model: root.plot.camera.ticks_x
|
|
151
|
+
|
|
152
|
+
delegate: Item {
|
|
153
|
+
required property var modelData
|
|
154
|
+
|
|
155
|
+
x: Math.round(root.mapToScreen(modelData[0], 0).x)
|
|
156
|
+
width: 1
|
|
157
|
+
height: root.plot.tick_length
|
|
158
|
+
anchors.bottom: parent.bottom
|
|
159
|
+
|
|
160
|
+
Rectangle {
|
|
161
|
+
anchors.fill: parent
|
|
162
|
+
color: root.plot.tick_color
|
|
163
|
+
}
|
|
164
|
+
Text {
|
|
165
|
+
text: modelData[1]
|
|
166
|
+
color: root.plot.text_color
|
|
167
|
+
font.pixelSize: root.plot.tick_font_size
|
|
168
|
+
x: -Math.round(width / 2)
|
|
169
|
+
y: -height - 3
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
Repeater {
|
|
175
|
+
model: root.plot.camera.ticks_y
|
|
176
|
+
|
|
177
|
+
delegate: Item {
|
|
178
|
+
required property var modelData
|
|
179
|
+
|
|
180
|
+
y: Math.round(root.mapToScreen(0, modelData[0]).y)
|
|
181
|
+
width: root.plot.tick_length
|
|
182
|
+
height: 1
|
|
183
|
+
anchors.left: parent.left
|
|
184
|
+
|
|
185
|
+
Rectangle {
|
|
186
|
+
anchors.fill: parent
|
|
187
|
+
color: root.plot.tick_color
|
|
188
|
+
}
|
|
189
|
+
Text {
|
|
190
|
+
text: modelData[1]
|
|
191
|
+
color: root.plot.text_color
|
|
192
|
+
font.pixelSize: root.plot.tick_font_size
|
|
193
|
+
x: root.plot.tick_length + 3
|
|
194
|
+
y: -Math.round(height / 2)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
Text {
|
|
201
|
+
anchors.horizontalCenter: parent.horizontalCenter
|
|
202
|
+
anchors.top: parent.top
|
|
203
|
+
anchors.topMargin: 6
|
|
204
|
+
|
|
205
|
+
text: root.plot.title
|
|
206
|
+
visible: text !== ""
|
|
207
|
+
color: root.plot.title_color
|
|
208
|
+
font.pixelSize: root.plot.title_font_size
|
|
209
|
+
font.bold: root.plot.title_bold
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
MouseArea {
|
|
213
|
+
anchors.fill: parent
|
|
214
|
+
acceptedButtons: Qt.LeftButton
|
|
215
|
+
enabled: root.plot.camera.panEnabled
|
|
216
|
+
// Panning must win over any ancestor Flickable/ScrollView: without this the Flickable
|
|
217
|
+
// steals the grab once the drag passes its threshold, and the plot only moves a few
|
|
218
|
+
// pixels before panning stops.
|
|
219
|
+
preventStealing: true
|
|
220
|
+
|
|
221
|
+
property real lastX: 0
|
|
222
|
+
property real lastY: 0
|
|
223
|
+
|
|
224
|
+
onPressed: (mouse) => {
|
|
225
|
+
lastX = mouse.x;
|
|
226
|
+
lastY = mouse.y;
|
|
227
|
+
}
|
|
228
|
+
onPositionChanged: (mouse) => {
|
|
229
|
+
root.plot.camera.pan_pixels(mouse.x - lastX, mouse.y - lastY);
|
|
230
|
+
lastX = mouse.x;
|
|
231
|
+
lastY = mouse.y;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
WheelHandler {
|
|
236
|
+
enabled: root.plot.camera.zoomEnabled
|
|
237
|
+
|
|
238
|
+
// The cursor position is the anchor; delta is the raw wheel delta, 120 = one notch.
|
|
239
|
+
// Note: Qt 6's QML WheelEvent has no position, only x/y (relative to this item).
|
|
240
|
+
onWheel: (event) => {
|
|
241
|
+
const d = event.angleDelta.y !== 0 ? event.angleDelta.y : event.pixelDelta.y;
|
|
242
|
+
root.plot.camera.zoom_by(d, event.x / root.width, event.y / root.height,
|
|
243
|
+
root.width, root.height);
|
|
244
|
+
event.accepted = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
qmlmathplot/qsb.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Bake runtime-generated GLSL into .qsb (Qt 6's ShaderEffect / custom materials only take .qsb).
|
|
2
|
+
|
|
3
|
+
Why it must be baked at runtime: the shader carries the user-entered expression (generated by
|
|
4
|
+
sympy), while Qt's official workflow bakes with qsb at build time and ships the .qsb as a
|
|
5
|
+
resource — impossible here. The baked result is cached in a temp directory keyed by the source
|
|
6
|
+
sha256, so only the first use of an expression pays for the bake and the frame loop makes no
|
|
7
|
+
calls at all.
|
|
8
|
+
|
|
9
|
+
Cross-platform: on Windows the wheel ships ``PySide6/qsb.exe``; PySide6's packaging script has no
|
|
10
|
+
platform condition for it, so all three platforms are expected to have it, but only Windows has
|
|
11
|
+
been confirmed. If it is not found we fall back to PATH, and otherwise raise with the ways to
|
|
12
|
+
obtain it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
__all__ = ["bake", "qsb_binary"]
|
|
25
|
+
|
|
26
|
+
_EXE = "qsb.exe" if os.name == "nt" else "qsb"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _candidates() -> list[Path]:
|
|
30
|
+
"""Places qsb may live: the PySide6 package directory (layout differs per platform), Qt's
|
|
31
|
+
libexec, and PATH."""
|
|
32
|
+
cands: list[Path] = []
|
|
33
|
+
try:
|
|
34
|
+
import PySide6 # lazy import: only the QML front-end needs to bake shaders
|
|
35
|
+
|
|
36
|
+
pkg = Path(PySide6.__file__).parent
|
|
37
|
+
cands += [pkg / _EXE, pkg / "Qt" / "bin" / _EXE, pkg / "Qt" / "libexec" / _EXE]
|
|
38
|
+
except ImportError: # without PySide6, a qsb on PATH is still allowed
|
|
39
|
+
pass
|
|
40
|
+
found = shutil.which("qsb")
|
|
41
|
+
if found:
|
|
42
|
+
cands.append(Path(found))
|
|
43
|
+
return cands
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def qsb_binary() -> Path:
|
|
47
|
+
"""Locate qsb (the Qt Shader Baker)."""
|
|
48
|
+
for cand in _candidates():
|
|
49
|
+
if cand.exists():
|
|
50
|
+
return cand
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
"cannot find qsb (Qt Shader Baker); the QML front-end cannot bake its shaders. "
|
|
53
|
+
"Three ways to get it:\n"
|
|
54
|
+
" 1) use the PySide6 wheel (ships qsb.exe on Windows; check other platforms first)\n"
|
|
55
|
+
" 2) install Qt Shader Tools: a distribution package (e.g. qt6-shadertools) or the "
|
|
56
|
+
"Qt online installer\n"
|
|
57
|
+
" 3) if you already have qsb, put it on PATH\n"
|
|
58
|
+
"see https://doc.qt.io/qt-6/qtshadertools-index.html"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def bake(source: str, stage: str, cache_dir: Path | str | None = None) -> Path:
|
|
63
|
+
"""Bake one GLSL source into .qsb and return the file path (cached by source hash).
|
|
64
|
+
|
|
65
|
+
stage: "vert" or "frag"
|
|
66
|
+
"""
|
|
67
|
+
if stage not in ("vert", "frag"):
|
|
68
|
+
raise ValueError(f"stage must be 'vert' or 'frag', got {stage!r}")
|
|
69
|
+
|
|
70
|
+
# v2: the source file must use a .vert/.frag suffix — qsb detects the stage from the
|
|
71
|
+
# suffix, so a .glsl file is treated as a vertex shader. The version goes into the
|
|
72
|
+
# hash, which invalidates old cache entries.
|
|
73
|
+
key = hashlib.sha256(f"v2\0{stage}\0{source}".encode()).hexdigest()[:16]
|
|
74
|
+
out_dir = Path(cache_dir) if cache_dir else Path(tempfile.gettempdir()) / "qmlmathplot-qsb"
|
|
75
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
out_path = out_dir / f"{stage}-{key}.qsb"
|
|
77
|
+
if out_path.exists():
|
|
78
|
+
return out_path
|
|
79
|
+
|
|
80
|
+
src_path = out_dir / f"{stage}-{key}.{stage}"
|
|
81
|
+
src_path.write_text(source, encoding="utf-8")
|
|
82
|
+
tmp_out = out_path.with_suffix(".qsb.tmp")
|
|
83
|
+
# --qt6 == --glsl "100 es,120,150" --hlsl 50 --msl 12, the standard target set for Qt Quick
|
|
84
|
+
# materials
|
|
85
|
+
proc = subprocess.run( # noqa: S603
|
|
86
|
+
[str(qsb_binary()), "--qt6", "-o", str(tmp_out), str(src_path)],
|
|
87
|
+
capture_output=True,
|
|
88
|
+
text=True,
|
|
89
|
+
check=False,
|
|
90
|
+
)
|
|
91
|
+
if proc.returncode != 0 or not tmp_out.exists():
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
f"qsb bake failed ({stage}, rc={proc.returncode}):\n{proc.stdout}\n{proc.stderr}"
|
|
94
|
+
)
|
|
95
|
+
tmp_out.replace(out_path) # atomic replace: no half-written file on interrupt/concurrency
|
|
96
|
+
return out_path
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
License agreement for matplotlib versions 1.3.0 and later
|
|
2
|
+
=========================================================
|
|
3
|
+
|
|
4
|
+
1. This LICENSE AGREEMENT is between the Matplotlib Development Team
|
|
5
|
+
("MDT"), and the Individual or Organization ("Licensee") accessing and
|
|
6
|
+
otherwise using matplotlib software in source or binary form and its
|
|
7
|
+
associated documentation.
|
|
8
|
+
|
|
9
|
+
2. Subject to the terms and conditions of this License Agreement, MDT
|
|
10
|
+
hereby grants Licensee a nonexclusive, royalty-free, world-wide license
|
|
11
|
+
to reproduce, analyze, test, perform and/or display publicly, prepare
|
|
12
|
+
derivative works, distribute, and otherwise use matplotlib
|
|
13
|
+
alone or in any derivative version, provided, however, that MDT's
|
|
14
|
+
License Agreement and MDT's notice of copyright, i.e., "Copyright (c)
|
|
15
|
+
2012- Matplotlib Development Team; All Rights Reserved" are retained in
|
|
16
|
+
matplotlib alone or in any derivative version prepared by
|
|
17
|
+
Licensee.
|
|
18
|
+
|
|
19
|
+
3. In the event Licensee prepares a derivative work that is based on or
|
|
20
|
+
incorporates matplotlib or any part thereof, and wants to
|
|
21
|
+
make the derivative work available to others as provided herein, then
|
|
22
|
+
Licensee hereby agrees to include in any such work a brief summary of
|
|
23
|
+
the changes made to matplotlib .
|
|
24
|
+
|
|
25
|
+
4. MDT is making matplotlib available to Licensee on an "AS
|
|
26
|
+
IS" basis. MDT MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
|
27
|
+
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, MDT MAKES NO AND
|
|
28
|
+
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
|
29
|
+
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB
|
|
30
|
+
WILL NOT INFRINGE ANY THIRD PARTY RIGHTS.
|
|
31
|
+
|
|
32
|
+
5. MDT SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB
|
|
33
|
+
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR
|
|
34
|
+
LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING
|
|
35
|
+
MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF
|
|
36
|
+
THE POSSIBILITY THEREOF.
|
|
37
|
+
|
|
38
|
+
6. This License Agreement will automatically terminate upon a material
|
|
39
|
+
breach of its terms and conditions.
|
|
40
|
+
|
|
41
|
+
7. Nothing in this License Agreement shall be deemed to create any
|
|
42
|
+
relationship of agency, partnership, or joint venture between MDT and
|
|
43
|
+
Licensee. This License Agreement does not grant permission to use MDT
|
|
44
|
+
trademarks or trade name in a trademark sense to endorse or promote
|
|
45
|
+
products or services of Licensee, or any third party.
|
|
46
|
+
|
|
47
|
+
8. By copying, installing or otherwise using matplotlib ,
|
|
48
|
+
Licensee agrees to be bound by the terms and conditions of this License
|
|
49
|
+
Agreement.
|
|
50
|
+
|
|
51
|
+
License agreement for matplotlib versions prior to 1.3.0
|
|
52
|
+
========================================================
|
|
53
|
+
|
|
54
|
+
1. This LICENSE AGREEMENT is between John D. Hunter ("JDH"), and the
|
|
55
|
+
Individual or Organization ("Licensee") accessing and otherwise using
|
|
56
|
+
matplotlib software in source or binary form and its associated
|
|
57
|
+
documentation.
|
|
58
|
+
|
|
59
|
+
2. Subject to the terms and conditions of this License Agreement, JDH
|
|
60
|
+
hereby grants Licensee a nonexclusive, royalty-free, world-wide license
|
|
61
|
+
to reproduce, analyze, test, perform and/or display publicly, prepare
|
|
62
|
+
derivative works, distribute, and otherwise use matplotlib
|
|
63
|
+
alone or in any derivative version, provided, however, that JDH's
|
|
64
|
+
License Agreement and JDH's notice of copyright, i.e., "Copyright (c)
|
|
65
|
+
2002-2011 John D. Hunter; All Rights Reserved" are retained in
|
|
66
|
+
matplotlib alone or in any derivative version prepared by
|
|
67
|
+
Licensee.
|
|
68
|
+
|
|
69
|
+
3. In the event Licensee prepares a derivative work that is based on or
|
|
70
|
+
incorporates matplotlib or any part thereof, and wants to
|
|
71
|
+
make the derivative work available to others as provided herein, then
|
|
72
|
+
Licensee hereby agrees to include in any such work a brief summary of
|
|
73
|
+
the changes made to matplotlib.
|
|
74
|
+
|
|
75
|
+
4. JDH is making matplotlib available to Licensee on an "AS
|
|
76
|
+
IS" basis. JDH MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
|
77
|
+
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, JDH MAKES NO AND
|
|
78
|
+
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
|
79
|
+
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB
|
|
80
|
+
WILL NOT INFRINGE ANY THIRD PARTY RIGHTS.
|
|
81
|
+
|
|
82
|
+
5. JDH SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB
|
|
83
|
+
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR
|
|
84
|
+
LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING
|
|
85
|
+
MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF
|
|
86
|
+
THE POSSIBILITY THEREOF.
|
|
87
|
+
|
|
88
|
+
6. This License Agreement will automatically terminate upon a material
|
|
89
|
+
breach of its terms and conditions.
|
|
90
|
+
|
|
91
|
+
7. Nothing in this License Agreement shall be deemed to create any
|
|
92
|
+
relationship of agency, partnership, or joint venture between JDH and
|
|
93
|
+
Licensee. This License Agreement does not grant permission to use JDH
|
|
94
|
+
trademarks or trade name in a trademark sense to endorse or promote
|
|
95
|
+
products or services of Licensee, or any third party.
|
|
96
|
+
|
|
97
|
+
8. By copying, installing or otherwise using matplotlib,
|
|
98
|
+
Licensee agrees to be bound by the terms and conditions of this License
|
|
99
|
+
Agreement.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Vendored Matplotlib style sheets
|
|
2
|
+
|
|
3
|
+
Origin: [matplotlib](https://github.com/matplotlib/matplotlib), path
|
|
4
|
+
`lib/matplotlib/mpl-data/stylelib/<name>.mplstyle`, from branch `main` at commit
|
|
5
|
+
`4dd8b5c29a0cf54a4fcef5012ee3c54a26d252ce` (fetched 2026-10-08). The files are copied
|
|
6
|
+
verbatim; only line endings were normalised to LF.
|
|
7
|
+
|
|
8
|
+
They are Matplotlib's own style sheets, redistributed here under Matplotlib's BSD
|
|
9
|
+
license — see `LICENSE.matplotlib` in this directory.
|
|
10
|
+
|
|
11
|
+
Our loader converts the `rcParams` keys found in these files into plot style
|
|
12
|
+
properties and silently ignores keys it does not recognise.
|
|
13
|
+
|
|
14
|
+
Dropping a new `*.mplstyle` file into this directory makes it selectable by its file
|
|
15
|
+
name (the name without the extension), with no registration step.
|