qmlmathplot 0.4.0__py3-none-any.whl → 0.4.2__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 +3 -0
- qmlmathplot/curve.py +88 -40
- qmlmathplot/export.py +5 -1
- qmlmathplot/figure.py +88 -14
- qmlmathplot/input.py +22 -0
- qmlmathplot/model.py +29 -7
- qmlmathplot/mpl.py +67 -6
- qmlmathplot/overlay.py +426 -0
- qmlmathplot/plot.py +238 -7
- qmlmathplot/pyplot.py +40 -0
- qmlmathplot/qml/PlotFurniture.qml +156 -20
- qmlmathplot/qml/PlotInput.qml +4 -0
- qmlmathplot/qml/PlotLegend.qml +66 -0
- qmlmathplot/qml/PlotOverlay.qml +111 -0
- qmlmathplot/qml/PlotView.qml +189 -37
- qmlmathplot/qsb.py +6 -0
- qmlmathplot/widget.py +3 -3
- {qmlmathplot-0.4.0.dist-info → qmlmathplot-0.4.2.dist-info}/METADATA +127 -28
- {qmlmathplot-0.4.0.dist-info → qmlmathplot-0.4.2.dist-info}/RECORD +21 -18
- {qmlmathplot-0.4.0.dist-info → qmlmathplot-0.4.2.dist-info}/WHEEL +0 -0
- {qmlmathplot-0.4.0.dist-info → qmlmathplot-0.4.2.dist-info}/entry_points.txt +0 -0
qmlmathplot/__init__.py
CHANGED
|
@@ -54,6 +54,7 @@ from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
|
|
|
54
54
|
from .curve import Curve, CurveListModel, ImplicitCurve
|
|
55
55
|
from .figure import Axes, Figure
|
|
56
56
|
from .input import ViewInput
|
|
57
|
+
from .overlay import Annotation, Guide
|
|
57
58
|
from .plot import Plot
|
|
58
59
|
from .qsb import cache_dir, cache_info, clear_cache, prune_cache
|
|
59
60
|
from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
|
|
@@ -63,6 +64,7 @@ if TYPE_CHECKING: # only for type checkers; at runtime the module-level __getat
|
|
|
63
64
|
from .widget import MathPlotWidget
|
|
64
65
|
|
|
65
66
|
__all__ = [
|
|
67
|
+
"Annotation",
|
|
66
68
|
"Axes",
|
|
67
69
|
"cache_dir",
|
|
68
70
|
"cache_info",
|
|
@@ -72,6 +74,7 @@ __all__ = [
|
|
|
72
74
|
"CurveListModel",
|
|
73
75
|
"Figure",
|
|
74
76
|
"FigureCanvasQt",
|
|
77
|
+
"Guide",
|
|
75
78
|
"HOME_SIZE",
|
|
76
79
|
"HOME_VIEW",
|
|
77
80
|
"ImplicitCurve",
|
qmlmathplot/curve.py
CHANGED
|
@@ -80,19 +80,36 @@ def _color(value: object) -> QColor:
|
|
|
80
80
|
return QColor(value) if not isinstance(value, QColor) else QColor(value)
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
def
|
|
83
|
+
def _parse(value: str | sp.Basic) -> tuple[str, sp.Expr, bool]:
|
|
84
|
+
"""Canonical text, normalized expression and whether the input was an equation.
|
|
85
|
+
|
|
86
|
+
``Eq(lhs, rhs)`` becomes the level set ``lhs - rhs = 0`` and is flagged as implicit even
|
|
87
|
+
when only ``x`` is free (``Eq(x**2, 1)`` is two vertical lines, not ``y = x**2 - 1``).
|
|
88
|
+
"""
|
|
89
|
+
if isinstance(value, sp.Basic):
|
|
90
|
+
text = str(value)
|
|
91
|
+
expr = value
|
|
92
|
+
else:
|
|
93
|
+
text = str(value)
|
|
94
|
+
expr = sp.sympify(value, locals={"x": sp.Symbol("x"), "y": sp.Symbol("y")})
|
|
95
|
+
if isinstance(expr, sp.Eq):
|
|
96
|
+
return text, expr.lhs - expr.rhs, True
|
|
97
|
+
return text, expr, False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parsed(value: str | sp.Basic) -> tuple[str, sp.Expr]:
|
|
84
101
|
"""The canonical string and the parsed expression of a curve input.
|
|
85
102
|
|
|
86
103
|
A string is parsed here, once; a sympy expression is used as it is (no ``Expr -> str ->
|
|
87
|
-
Expr`` round trip).
|
|
104
|
+
Expr`` round trip). ``Eq(lhs, rhs)`` is normalized to ``lhs - rhs``. The canonical string
|
|
105
|
+
is what identifies the expression.
|
|
88
106
|
"""
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return str(value), sp.sympify(value, locals={"x": sp.Symbol("x"), "y": sp.Symbol("y")})
|
|
107
|
+
text, expr, _ = _parse(value)
|
|
108
|
+
return text, expr
|
|
92
109
|
|
|
93
110
|
|
|
94
111
|
def curve_for(
|
|
95
|
-
expression: str | sp.
|
|
112
|
+
expression: str | sp.Basic,
|
|
96
113
|
*,
|
|
97
114
|
implicit: bool | None = None,
|
|
98
115
|
color: object = None,
|
|
@@ -106,17 +123,25 @@ def curve_for(
|
|
|
106
123
|
is an implicit curve (the level set ``F(x, y) = 0``), and anything else raises — a parameter
|
|
107
124
|
must be substituted before it is plotted, and silently evaluating it as a pixel coordinate is
|
|
108
125
|
exactly the trap this avoids. ``implicit=True`` picks the level-set artist regardless of the
|
|
109
|
-
symbols (``add_implicit``).
|
|
126
|
+
symbols (``add_implicit``). A sympy ``Eq`` is always a level set (``lhs - rhs = 0``), even
|
|
127
|
+
when only ``x`` is free; it keeps the ordinary ``Curve`` class so a later assignment can
|
|
128
|
+
re-route it.
|
|
110
129
|
"""
|
|
111
|
-
text, expr =
|
|
130
|
+
text, expr, is_equation = _parse(expression)
|
|
112
131
|
names = {symbol.name for symbol in expr.free_symbols}
|
|
113
132
|
if names - {"x", "y"}:
|
|
114
133
|
raise ValueError(
|
|
115
134
|
f"{sorted(names - {'x', 'y'})} must be substituted first: only x (or x and y) may "
|
|
116
135
|
f"be free, got {text!r}"
|
|
117
136
|
)
|
|
118
|
-
|
|
119
|
-
|
|
137
|
+
if implicit:
|
|
138
|
+
cls: type[Curve] = ImplicitCurve
|
|
139
|
+
elif is_equation:
|
|
140
|
+
cls = Curve
|
|
141
|
+
else:
|
|
142
|
+
cls = ImplicitCurve if "y" in names else Curve
|
|
143
|
+
source = expression if is_equation else expr
|
|
144
|
+
return cls(source, color=color, line_width=line_width, label=label, parent=parent)
|
|
120
145
|
|
|
121
146
|
|
|
122
147
|
class Curve(QObject):
|
|
@@ -125,7 +150,8 @@ class Curve(QObject):
|
|
|
125
150
|
The fragment shader follows the expression: ``y = f(x)`` while only ``x`` (or nothing) is
|
|
126
151
|
free, the level set ``F(x, y) = 0`` as soon as ``y`` appears — so ``add_curve`` and assigning
|
|
127
152
|
to ``expression`` both route themselves, and a text field can turn a function into a level
|
|
128
|
-
set. ``
|
|
153
|
+
set. A sympy ``Eq(lhs, rhs)`` is always a level set (``lhs - rhs = 0``), even when only ``x``
|
|
154
|
+
is free. ``ImplicitCurve`` forces the level-set template regardless of the symbols.
|
|
129
155
|
"""
|
|
130
156
|
|
|
131
157
|
#: Forced to the level-set template (see ``ImplicitCurve``).
|
|
@@ -145,7 +171,7 @@ class Curve(QObject):
|
|
|
145
171
|
|
|
146
172
|
def __init__(
|
|
147
173
|
self,
|
|
148
|
-
expression: str | sp.
|
|
174
|
+
expression: str | sp.Basic = "sin(x)",
|
|
149
175
|
color: str | QColor = DEFAULT_COLOR,
|
|
150
176
|
line_width: float = 1.5,
|
|
151
177
|
label: str = "",
|
|
@@ -164,6 +190,7 @@ class Curve(QObject):
|
|
|
164
190
|
self._fragment = QUrl()
|
|
165
191
|
self._generation = 0
|
|
166
192
|
self._pending_expr: sp.Expr | None = None
|
|
193
|
+
self._pending_implicit: bool | None = None
|
|
167
194
|
# Not parented: a job may outlive the curve; the signal object must stay valid for the
|
|
168
195
|
# queued connection (Qt disconnects the receiver when the curve is destroyed).
|
|
169
196
|
self._bake_signals = _BakeSignals()
|
|
@@ -174,51 +201,50 @@ class Curve(QObject):
|
|
|
174
201
|
def _get_expression(self) -> str:
|
|
175
202
|
return self._expression
|
|
176
203
|
|
|
177
|
-
def _set_expression(self, source: str | sp.
|
|
204
|
+
def _set_expression(self, source: str | sp.Basic, *, synchronous: bool = False) -> None:
|
|
178
205
|
"""Set the expression from a string (parsed here, once) or from a sympy expression.
|
|
179
206
|
|
|
180
207
|
The canonical **string** is what identifies the expression — ``sp.sin(x)`` and
|
|
181
208
|
``"sin(x)"`` are the same, and re-setting either is free — while the parsed
|
|
182
209
|
``sp.Expr`` is cached for the bake and for the matplotlib hand-off, so neither ever
|
|
183
|
-
stringifies and re-parses again.
|
|
210
|
+
stringifies and re-parses again. ``Eq(lhs, rhs)`` is normalized to ``lhs - rhs`` and
|
|
211
|
+
drawn as the level set, even when only ``x`` is free.
|
|
184
212
|
|
|
185
213
|
Parsing and source generation are synchronous (microseconds); the qsb subprocess runs
|
|
186
214
|
on the thread pool for later assignments. ``expr`` keeps the last successfully baked
|
|
187
215
|
expression until the new shaders arrive, so a failed or pending edit never draws an
|
|
188
216
|
incomplete curve.
|
|
189
217
|
"""
|
|
190
|
-
if isinstance(source, str)
|
|
191
|
-
canonical = source
|
|
192
|
-
else:
|
|
193
|
-
canonical = str(source)
|
|
218
|
+
canonical = source if isinstance(source, str) else str(source)
|
|
194
219
|
if canonical == self._expression:
|
|
195
220
|
return
|
|
196
221
|
self._generation += 1
|
|
197
222
|
generation = self._generation
|
|
198
223
|
self._pending_expr = None
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return
|
|
224
|
+
self._pending_implicit = None
|
|
225
|
+
try:
|
|
226
|
+
_, expr, is_equation = _parse(source)
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
# Keep the last drawn expression: the shaders stay, `expression` is what was
|
|
229
|
+
# typed and `error` says why.
|
|
230
|
+
self._expression = str(canonical)
|
|
231
|
+
self._error = f"{type(exc).__name__}: {exc}"
|
|
232
|
+
self.expressionChanged.emit()
|
|
233
|
+
self.errorChanged.emit()
|
|
234
|
+
return
|
|
235
|
+
forced_implicit = type(self)._force_implicit or is_equation
|
|
212
236
|
self._expression = str(canonical)
|
|
213
237
|
self.expressionChanged.emit()
|
|
214
|
-
self._start_bake(
|
|
238
|
+
self._start_bake(
|
|
239
|
+
generation, expr, synchronous=synchronous, force_implicit=forced_implicit
|
|
240
|
+
)
|
|
215
241
|
|
|
216
242
|
# Annotate the value type (PySide6 idiom): Property is a descriptor, so without the
|
|
217
243
|
# annotation a type checker only sees the Property object and flags every read and write.
|
|
218
244
|
# Declared "QVariant" so an ``sp.Expr`` reaches the setter unconverted; the getter is always
|
|
219
245
|
# the canonical string (QML, a project file and the text field all want one).
|
|
220
|
-
expression: str | sp.
|
|
221
|
-
|
|
246
|
+
expression: str | sp.Basic = Property("QVariant", _get_expression, _set_expression,
|
|
247
|
+
notify=expressionChanged)
|
|
222
248
|
|
|
223
249
|
def _get_expr(self) -> sp.Expr:
|
|
224
250
|
return self._expr
|
|
@@ -302,7 +328,9 @@ class Curve(QObject):
|
|
|
302
328
|
"""
|
|
303
329
|
return self._force_implicit or "y" in {symbol.name for symbol in self._expr.free_symbols}
|
|
304
330
|
|
|
305
|
-
def _sources(
|
|
331
|
+
def _sources(
|
|
332
|
+
self, expr: sp.Expr, *, force_implicit: bool | None = None
|
|
333
|
+
) -> tuple[str, str]:
|
|
306
334
|
"""The shader sources for this expression; anything unrenderable raises ``ValueError``
|
|
307
335
|
(which ``_start_bake`` turns into ``error``, keeping the last working shaders)."""
|
|
308
336
|
names = {symbol.name for symbol in expr.free_symbols}
|
|
@@ -311,22 +339,33 @@ class Curve(QObject):
|
|
|
311
339
|
f"only x (or x and y) may be free, got {sorted(names)}: substitute parameters "
|
|
312
340
|
"first"
|
|
313
341
|
)
|
|
314
|
-
|
|
342
|
+
implicit = self._force_implicit if force_implicit is None else force_implicit
|
|
343
|
+
return implicit_shader_sources(expr) if (implicit or "y" in names) \
|
|
315
344
|
else shader_sources(expr)
|
|
316
345
|
|
|
317
|
-
def _start_bake(
|
|
346
|
+
def _start_bake(
|
|
347
|
+
self,
|
|
348
|
+
generation: int,
|
|
349
|
+
expr: sp.Expr,
|
|
350
|
+
*,
|
|
351
|
+
synchronous: bool,
|
|
352
|
+
force_implicit: bool | None = None,
|
|
353
|
+
) -> None:
|
|
318
354
|
"""Generate the GLSL now and bake it on the pool unless this curve is synchronous.
|
|
319
355
|
|
|
320
356
|
Source generation is cheap and stays on the caller's thread; only the qsb subprocess
|
|
321
357
|
(the ~126 ms cache miss) leaves it.
|
|
322
358
|
"""
|
|
323
359
|
try:
|
|
324
|
-
vertex_source, fragment_source = self._sources(
|
|
360
|
+
vertex_source, fragment_source = self._sources(
|
|
361
|
+
expr, force_implicit=force_implicit
|
|
362
|
+
)
|
|
325
363
|
except Exception as exc: # noqa: BLE001 - reported through error, previous shaders stay
|
|
326
364
|
self._error = f"{type(exc).__name__}: {exc}"
|
|
327
365
|
self.errorChanged.emit()
|
|
328
366
|
return
|
|
329
367
|
self._pending_expr = expr
|
|
368
|
+
self._pending_implicit = force_implicit
|
|
330
369
|
if synchronous or not self.bake_async or QCoreApplication.instance() is None:
|
|
331
370
|
self._run_bake(generation, vertex_source, fragment_source)
|
|
332
371
|
return
|
|
@@ -349,7 +388,9 @@ class Curve(QObject):
|
|
|
349
388
|
if generation != self._generation:
|
|
350
389
|
return
|
|
351
390
|
expr = self._pending_expr
|
|
391
|
+
implicit = self._pending_implicit
|
|
352
392
|
self._pending_expr = None
|
|
393
|
+
self._pending_implicit = None
|
|
353
394
|
if isinstance(result, Exception):
|
|
354
395
|
self._error = f"{type(result).__name__}: {result}"
|
|
355
396
|
self.errorChanged.emit()
|
|
@@ -358,6 +399,8 @@ class Curve(QObject):
|
|
|
358
399
|
return
|
|
359
400
|
vertex, fragment = result # type: ignore[misc]
|
|
360
401
|
self._expr = expr
|
|
402
|
+
if implicit is not None:
|
|
403
|
+
self._force_implicit = bool(implicit)
|
|
361
404
|
self._error = ""
|
|
362
405
|
self._vertex = QUrl.fromLocalFile(str(vertex))
|
|
363
406
|
self._fragment = QUrl.fromLocalFile(str(fragment))
|
|
@@ -388,6 +431,7 @@ class CurveListModel(QAbstractListModel):
|
|
|
388
431
|
VisibleRole = CurveRole + 4
|
|
389
432
|
VertexShaderRole = CurveRole + 5
|
|
390
433
|
FragmentShaderRole = CurveRole + 6
|
|
434
|
+
LabelRole = CurveRole + 7
|
|
391
435
|
|
|
392
436
|
_ROLES = {
|
|
393
437
|
CurveRole: b"curve",
|
|
@@ -397,6 +441,7 @@ class CurveListModel(QAbstractListModel):
|
|
|
397
441
|
VisibleRole: b"visible",
|
|
398
442
|
VertexShaderRole: b"vertexShader",
|
|
399
443
|
FragmentShaderRole: b"fragmentShader",
|
|
444
|
+
LabelRole: b"label",
|
|
400
445
|
}
|
|
401
446
|
|
|
402
447
|
def __init__(
|
|
@@ -435,6 +480,8 @@ class CurveListModel(QAbstractListModel):
|
|
|
435
480
|
return curve.vertexShader
|
|
436
481
|
if role == self.FragmentShaderRole:
|
|
437
482
|
return curve.fragmentShader
|
|
483
|
+
if role == self.LabelRole:
|
|
484
|
+
return curve.label
|
|
438
485
|
return None
|
|
439
486
|
|
|
440
487
|
def _notify(self, curve: Curve, roles: tuple[int, ...]) -> None:
|
|
@@ -455,6 +502,7 @@ class CurveListModel(QAbstractListModel):
|
|
|
455
502
|
lambda c=curve: self._notify(c, (self.ColorRole, self.LineWidthRole))
|
|
456
503
|
)
|
|
457
504
|
curve.visibleChanged.connect(lambda c=curve: self._notify(c, (self.VisibleRole,)))
|
|
505
|
+
curve.labelChanged.connect(lambda c=curve: self._notify(c, (self.LabelRole,)))
|
|
458
506
|
|
|
459
507
|
# ---------------------------------------------------------------- public
|
|
460
508
|
def _defaults(self, color: object, line_width: object) -> tuple[QColor, float]:
|
|
@@ -475,7 +523,7 @@ class CurveListModel(QAbstractListModel):
|
|
|
475
523
|
@Slot("QVariant", result=QObject)
|
|
476
524
|
def add_curve(
|
|
477
525
|
self,
|
|
478
|
-
expression: str | sp.
|
|
526
|
+
expression: str | sp.Basic,
|
|
479
527
|
color: object = None,
|
|
480
528
|
line_width: object = None,
|
|
481
529
|
label: str = "",
|
|
@@ -491,7 +539,7 @@ class CurveListModel(QAbstractListModel):
|
|
|
491
539
|
@Slot("QVariant", result=QObject)
|
|
492
540
|
def add_implicit(
|
|
493
541
|
self,
|
|
494
|
-
expression: str | sp.
|
|
542
|
+
expression: str | sp.Basic,
|
|
495
543
|
color: object = None,
|
|
496
544
|
line_width: object = None,
|
|
497
545
|
label: str = "",
|
qmlmathplot/export.py
CHANGED
|
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
from collections.abc import Sequence
|
|
15
15
|
from typing import TYPE_CHECKING
|
|
16
16
|
|
|
17
|
-
from PySide6.QtCore import QSize, Qt, QUrl
|
|
17
|
+
from PySide6.QtCore import QCoreApplication, QSize, Qt, QUrl
|
|
18
18
|
from PySide6.QtGui import QColor, QImage
|
|
19
19
|
|
|
20
20
|
if TYPE_CHECKING: # pragma: no cover - typing only; the real imports stay lazy
|
|
@@ -155,6 +155,10 @@ def render(
|
|
|
155
155
|
|
|
156
156
|
camera.set_viewport(logical[0], logical[1])
|
|
157
157
|
camera.set_view(centre[0], centre[1], scale[0], scale[1])
|
|
158
|
+
# Let QML's Canvas-based furniture (grid, guides) handle its queued paint before the
|
|
159
|
+
# synchronous readback; the scene-graph items are already ready, Canvas is not.
|
|
160
|
+
for _ in range(3):
|
|
161
|
+
QCoreApplication.processEvents()
|
|
158
162
|
image = widget.grabFramebuffer()
|
|
159
163
|
finally:
|
|
160
164
|
# Restore before the event loop runs again, so no live view repaints the export state.
|
qmlmathplot/figure.py
CHANGED
|
@@ -213,7 +213,13 @@ class Axes:
|
|
|
213
213
|
|
|
214
214
|
# -------------------------------------------------------------- dressing
|
|
215
215
|
def grid(self, visible: bool | None = True, **kwargs: object) -> None:
|
|
216
|
-
|
|
216
|
+
axis = str(kwargs.pop("axis", "both")).lower()
|
|
217
|
+
enabled = True if visible is None else bool(visible)
|
|
218
|
+
self.model.grid = enabled
|
|
219
|
+
if axis in ("x", "both"):
|
|
220
|
+
self.model.grid_x = enabled
|
|
221
|
+
if axis in ("y", "both"):
|
|
222
|
+
self.model.grid_y = enabled
|
|
217
223
|
if "color" in kwargs:
|
|
218
224
|
self.model.grid_color = kwargs["color"] # type: ignore[assignment]
|
|
219
225
|
width = kwargs.get("linewidth", kwargs.get("lw"))
|
|
@@ -255,11 +261,19 @@ class Axes:
|
|
|
255
261
|
def clear(self) -> None:
|
|
256
262
|
self._figure.clf()
|
|
257
263
|
|
|
258
|
-
#
|
|
264
|
+
# ------------------------------------------------------- text and annotation
|
|
259
265
|
def legend(self, *args: object, **kwargs: object) -> None:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
266
|
+
if args and args[0] in (False, None):
|
|
267
|
+
self.model.legend = False
|
|
268
|
+
return None
|
|
269
|
+
loc = kwargs.get("loc", kwargs.get("location"))
|
|
270
|
+
if loc is not None:
|
|
271
|
+
text = str(loc).replace("_", " ").lower()
|
|
272
|
+
if text not in self.model._LEGEND_POSITIONS:
|
|
273
|
+
raise ValueError(f"unsupported legend location {loc!r}")
|
|
274
|
+
self.model.legend_position = text
|
|
275
|
+
self.model.legend = True
|
|
276
|
+
return None
|
|
263
277
|
|
|
264
278
|
def set_xscale(self, value: object, **kwargs: object) -> None:
|
|
265
279
|
raise _unsupported("log axes", "the shader mapping is linear today")
|
|
@@ -267,17 +281,77 @@ class Axes:
|
|
|
267
281
|
def set_yscale(self, value: object, **kwargs: object) -> None:
|
|
268
282
|
raise _unsupported("log axes", "the shader mapping is linear today")
|
|
269
283
|
|
|
270
|
-
def annotate(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
284
|
+
def annotate(
|
|
285
|
+
self,
|
|
286
|
+
text: object,
|
|
287
|
+
xy: object,
|
|
288
|
+
xytext: object = (24, -18),
|
|
289
|
+
arrowprops: dict[str, object] | None = None,
|
|
290
|
+
**kwargs: object,
|
|
291
|
+
):
|
|
292
|
+
color = kwargs.pop("color", None)
|
|
293
|
+
fontsize = kwargs.pop("fontsize", kwargs.pop("size", None))
|
|
294
|
+
if arrowprops and "color" in arrowprops:
|
|
295
|
+
color = arrowprops["color"]
|
|
296
|
+
return self.model.add_annotation(
|
|
297
|
+
"" if text is None else str(text),
|
|
298
|
+
xy,
|
|
299
|
+
xytext,
|
|
300
|
+
arrowprops is not None,
|
|
301
|
+
color,
|
|
302
|
+
None if fontsize is None else float(fontsize), # type: ignore[arg-type]
|
|
303
|
+
)
|
|
275
304
|
|
|
276
|
-
def
|
|
277
|
-
|
|
305
|
+
def text(self, x: float, y: float, s: object, fontdict: object = None, **kwargs: object):
|
|
306
|
+
color = kwargs.pop("color", None)
|
|
307
|
+
size = kwargs.pop("fontsize", kwargs.pop("size", None))
|
|
308
|
+
return self.model.add_text(x, y, "" if s is None else str(s), color=color,
|
|
309
|
+
font_size=None if size is None else float(size))
|
|
310
|
+
|
|
311
|
+
def set_xticks(self, ticks: object, labels: object = None, **kwargs: object) -> None:
|
|
312
|
+
self.model.set_xticks(ticks, labels)
|
|
313
|
+
|
|
314
|
+
def set_yticks(self, ticks: object, labels: object = None, **kwargs: object) -> None:
|
|
315
|
+
self.model.set_yticks(ticks, labels)
|
|
316
|
+
|
|
317
|
+
def tick_params(self, axis: str = "both", **kwargs: object) -> None:
|
|
318
|
+
axis = str(axis).lower()
|
|
319
|
+
if "length" in kwargs:
|
|
320
|
+
self.model.tick_length = float(kwargs["length"]) # type: ignore[arg-type]
|
|
321
|
+
if "width" in kwargs:
|
|
322
|
+
self.model.tick_width = float(kwargs["width"]) # type: ignore[arg-type]
|
|
323
|
+
if "pad" in kwargs:
|
|
324
|
+
self.model.tick_pad = float(kwargs["pad"]) # type: ignore[arg-type]
|
|
325
|
+
if "labelsize" in kwargs:
|
|
326
|
+
self.model.tick_font_size = float(kwargs["labelsize"]) # type: ignore[arg-type]
|
|
327
|
+
colors = kwargs.get("colors", kwargs.get("color"))
|
|
328
|
+
if colors is not None:
|
|
329
|
+
self.model.tick_color = colors # type: ignore[assignment]
|
|
330
|
+
labelcolor = kwargs.get("labelcolor")
|
|
331
|
+
if labelcolor is not None:
|
|
332
|
+
self.model.text_color = labelcolor # type: ignore[assignment]
|
|
333
|
+
if "top" in kwargs and axis in ("x", "both"):
|
|
334
|
+
self.model.ticks_top = bool(kwargs["top"])
|
|
335
|
+
if "right" in kwargs and axis in ("y", "both"):
|
|
336
|
+
self.model.ticks_right = bool(kwargs["right"])
|
|
337
|
+
if str(kwargs.get("which", "")).lower() == "minor":
|
|
338
|
+
self.model.minor_ticks = True
|
|
339
|
+
|
|
340
|
+
def axhline(self, y: float = 0.0, **kwargs: object):
|
|
341
|
+
return self.model.add_hline(
|
|
342
|
+
y,
|
|
343
|
+
color=kwargs.get("color", kwargs.get("c")),
|
|
344
|
+
width=float(kwargs.get("linewidth", kwargs.get("lw", 1.0))), # type: ignore[arg-type]
|
|
345
|
+
style=str(kwargs.get("linestyle", kwargs.get("ls", "--"))),
|
|
346
|
+
)
|
|
278
347
|
|
|
279
|
-
def
|
|
280
|
-
|
|
348
|
+
def axvline(self, x: float = 0.0, **kwargs: object):
|
|
349
|
+
return self.model.add_vline(
|
|
350
|
+
x,
|
|
351
|
+
color=kwargs.get("color", kwargs.get("c")),
|
|
352
|
+
width=float(kwargs.get("linewidth", kwargs.get("lw", 1.0))), # type: ignore[arg-type]
|
|
353
|
+
style=str(kwargs.get("linestyle", kwargs.get("ls", "--"))),
|
|
354
|
+
)
|
|
281
355
|
|
|
282
356
|
|
|
283
357
|
class Figure:
|
qmlmathplot/input.py
CHANGED
|
@@ -39,12 +39,14 @@ class ViewInput(QObject):
|
|
|
39
39
|
|
|
40
40
|
keyboardEnabledChanged = Signal()
|
|
41
41
|
keyPanStepChanged = Signal()
|
|
42
|
+
keyPanRepeatIntervalChanged = Signal()
|
|
42
43
|
shortcutsChanged = Signal()
|
|
43
44
|
|
|
44
45
|
def __init__(self, parent: QObject | None = None) -> None:
|
|
45
46
|
super().__init__(parent)
|
|
46
47
|
self._keyboard_enabled = True
|
|
47
48
|
self._key_pan_step = 0.1
|
|
49
|
+
self._key_pan_repeat_interval = 100
|
|
48
50
|
self._pan_left = "Left"
|
|
49
51
|
self._pan_right = "Right"
|
|
50
52
|
self._pan_up = "Up"
|
|
@@ -85,6 +87,26 @@ class ViewInput(QObject):
|
|
|
85
87
|
float, _get_key_pan_step, _set_key_pan_step, notify=keyPanStepChanged
|
|
86
88
|
)
|
|
87
89
|
|
|
90
|
+
def _get_key_pan_repeat_interval(self) -> int:
|
|
91
|
+
return self._key_pan_repeat_interval
|
|
92
|
+
|
|
93
|
+
def _set_key_pan_repeat_interval(self, value: int) -> None:
|
|
94
|
+
value = int(value)
|
|
95
|
+
if value <= 0:
|
|
96
|
+
raise ValueError(f"keyPanRepeatInterval must be positive, got {value!r}")
|
|
97
|
+
if value == self._key_pan_repeat_interval:
|
|
98
|
+
return
|
|
99
|
+
self._key_pan_repeat_interval = value
|
|
100
|
+
self.keyPanRepeatIntervalChanged.emit()
|
|
101
|
+
|
|
102
|
+
#: Milliseconds per held-key step (the frame timer integrates at dt / this value).
|
|
103
|
+
keyPanRepeatInterval: int = Property(
|
|
104
|
+
int,
|
|
105
|
+
_get_key_pan_repeat_interval,
|
|
106
|
+
_set_key_pan_repeat_interval,
|
|
107
|
+
notify=keyPanRepeatIntervalChanged,
|
|
108
|
+
)
|
|
109
|
+
|
|
88
110
|
#: Key sequences (QKeySequence text) for the six view commands. ``|`` separates
|
|
89
111
|
#: alternatives, so the default zoom-in is ``"+|="`` (Shift+= and the unshifted key).
|
|
90
112
|
panLeftShortcut: str = _shortcut_property("_pan_left", shortcutsChanged)
|
qmlmathplot/model.py
CHANGED
|
@@ -440,21 +440,43 @@ void main() {
|
|
|
440
440
|
float y_bot = camera.y - 0.5 * spany;
|
|
441
441
|
|
|
442
442
|
#if USE_INTERVAL
|
|
443
|
+
// Conservative reject: every sample/segment used below lies within ±2 columns, so the
|
|
444
|
+
// interval over that window bounds the curve's y values there. A pixel farther than the
|
|
445
|
+
// stroke radius from that range cannot be painted; skip the 14 F evaluations and the
|
|
446
|
+
// column/stroke-footprint intervals. This is the cheap analogue of a column cache: one
|
|
447
|
+
// interval evaluation instead of the whole per-pixel sample loop. Whole/infinite columns
|
|
448
|
+
// do not reject (the envelope may fill the viewport); empty columns have no curve at all.
|
|
449
|
+
IVal wide = FI(x - 2.0 * dx, x + 2.0 * dx);
|
|
450
|
+
if (wide.kind == IK_EMPTY) {
|
|
451
|
+
fragColor = vec4(0.0);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (wide.kind == IK_CONNECTED && wide.lo > -IINF && wide.hi < IINF) {
|
|
455
|
+
float pad = R * camera.w;
|
|
456
|
+
if (y < wide.lo - pad || y > wide.hi + pad) {
|
|
457
|
+
fragColor = vec4(0.0);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
443
462
|
IVal own = FI(x - 0.5 * dx, x + 0.5 * dx);
|
|
444
|
-
bool own_empty = own.kind == IK_EMPTY;
|
|
445
463
|
IVal col = own;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
464
|
+
if (col.kind == IK_EMPTY) {
|
|
465
|
+
// The stroke's outer half may cross the domain boundary by up to one radius. Only a
|
|
466
|
+
// column with no in-domain value needs this wider footprint; skipping it for every
|
|
467
|
+
// other pixel removes one interval evaluation from the common path.
|
|
468
|
+
IVal gate = FI(x - R * dx, x + R * dx);
|
|
469
|
+
if (gate.kind != IK_EMPTY) {
|
|
470
|
+
col = gate;
|
|
471
|
+
}
|
|
450
472
|
}
|
|
451
473
|
|
|
452
474
|
// ---- fixed-x sub-pixel polyline (GeoGebra's sampled polyline, on the GPU) ----
|
|
453
|
-
//
|
|
475
|
+
// 14 samples over ±2 columns (0.286-column spacing) cover the perpendicular foot even at
|
|
454
476
|
// 89°: a ±1-column window lost 42 % of the width at 84°. Joining the samples gives a
|
|
455
477
|
// polyline whose segments are diagonal, so the stroke is smooth at any zoom. The interval
|
|
456
478
|
// band stays separate (below); it no longer has to be inferred from this window.
|
|
457
|
-
const int NS =
|
|
479
|
+
const int NS = 14;
|
|
458
480
|
float smp[NS];
|
|
459
481
|
float smpx[NS];
|
|
460
482
|
bool ok[NS];
|
qmlmathplot/mpl.py
CHANGED
|
@@ -149,31 +149,92 @@ def figure_for(
|
|
|
149
149
|
axes.spines["bottom"].set_position("zero")
|
|
150
150
|
axes.spines["top"].set_visible(False)
|
|
151
151
|
axes.spines["right"].set_visible(False)
|
|
152
|
+
elif plot.axes_position == "box":
|
|
153
|
+
for side, visible in (
|
|
154
|
+
("top", plot.spine_top), ("bottom", plot.spine_bottom),
|
|
155
|
+
("left", plot.spine_left), ("right", plot.spine_right),
|
|
156
|
+
):
|
|
157
|
+
axes.spines[side].set_visible(visible)
|
|
152
158
|
for spine in axes.spines.values():
|
|
153
159
|
spine.set_color(plot.axis_color.name())
|
|
154
160
|
spine.set_linewidth(plot.axis_width * PX_TO_PT)
|
|
155
161
|
|
|
162
|
+
if plot.xticks:
|
|
163
|
+
axes.set_xticks([float(value) for value, _label in plot.xticks],
|
|
164
|
+
labels=[label for _value, label in plot.xticks])
|
|
165
|
+
if plot.yticks:
|
|
166
|
+
axes.set_yticks([float(value) for value, _label in plot.yticks],
|
|
167
|
+
labels=[label for _value, label in plot.yticks])
|
|
168
|
+
|
|
156
169
|
axes.set_axisbelow(True) # the Qt view paints its grid below the curves
|
|
170
|
+
grid_kwargs = {
|
|
171
|
+
"color": plot.grid_color.name(),
|
|
172
|
+
"linewidth": plot.grid_width * PX_TO_PT,
|
|
173
|
+
"alpha": plot.grid_alpha,
|
|
174
|
+
"linestyle": plot.grid_style,
|
|
175
|
+
}
|
|
157
176
|
if plot.grid:
|
|
158
|
-
axes.grid(
|
|
159
|
-
|
|
160
|
-
|
|
177
|
+
axes.grid(False)
|
|
178
|
+
if plot.grid_x:
|
|
179
|
+
axes.xaxis.grid(True, **grid_kwargs)
|
|
180
|
+
if plot.grid_y:
|
|
181
|
+
axes.yaxis.grid(True, **grid_kwargs)
|
|
161
182
|
else:
|
|
162
|
-
# Passing line properties together with `False` makes matplotlib *enable* the grid.
|
|
163
183
|
axes.grid(False)
|
|
164
|
-
axes.tick_params(
|
|
165
|
-
|
|
184
|
+
axes.tick_params(
|
|
185
|
+
colors=plot.tick_color.name(),
|
|
186
|
+
labelcolor=plot.text_color.name(),
|
|
187
|
+
labelsize=plot.tick_font_size * PX_TO_PT,
|
|
188
|
+
width=plot.tick_width * PX_TO_PT,
|
|
189
|
+
pad=plot.tick_pad * PX_TO_PT,
|
|
190
|
+
top=plot.ticks_top,
|
|
191
|
+
right=plot.ticks_right,
|
|
192
|
+
)
|
|
193
|
+
if plot.minor_ticks:
|
|
194
|
+
axes.minorticks_on()
|
|
166
195
|
axes.xaxis.label.set_fontsize(plot.font_size * PX_TO_PT)
|
|
167
196
|
axes.yaxis.label.set_fontsize(plot.font_size * PX_TO_PT)
|
|
197
|
+
if plot.xlabel:
|
|
198
|
+
axes.set_xlabel(plot.xlabel, color=plot.text_color.name())
|
|
199
|
+
if plot.ylabel:
|
|
200
|
+
axes.set_ylabel(plot.ylabel, color=plot.text_color.name())
|
|
168
201
|
if plot.title:
|
|
169
202
|
axes.set_title(plot.title, color=plot.title_color.name(),
|
|
170
203
|
fontsize=plot.title_font_size * PX_TO_PT,
|
|
171
204
|
fontweight="bold" if plot.title_bold else "normal")
|
|
172
205
|
|
|
206
|
+
for index in range(plot.annotations.rowCount()):
|
|
207
|
+
annotation = plot.annotations.at(index)
|
|
208
|
+
if not annotation.visible:
|
|
209
|
+
continue
|
|
210
|
+
options: dict[str, object] = {
|
|
211
|
+
"xycoords": "data",
|
|
212
|
+
"textcoords": "offset points",
|
|
213
|
+
"xytext": (annotation.xytext.x(), annotation.xytext.y()),
|
|
214
|
+
"color": annotation.color.name(),
|
|
215
|
+
"fontsize": annotation.fontSize * PX_TO_PT,
|
|
216
|
+
}
|
|
217
|
+
if annotation.arrow:
|
|
218
|
+
options["arrowprops"] = {"arrowstyle": "->", "color": annotation.color.name()}
|
|
219
|
+
axes.annotate(annotation.text, (annotation.xy.x(), annotation.xy.y()), **options)
|
|
220
|
+
|
|
221
|
+
for index in range(plot.guides.rowCount()):
|
|
222
|
+
guide = plot.guides.at(index)
|
|
223
|
+
if not guide.visible:
|
|
224
|
+
continue
|
|
225
|
+
if guide.kind == "h":
|
|
226
|
+
axes.axhline(guide.value, color=guide.color.name(),
|
|
227
|
+
linewidth=guide.width * PX_TO_PT, linestyle=guide.style)
|
|
228
|
+
else:
|
|
229
|
+
axes.axvline(guide.value, color=guide.color.name(),
|
|
230
|
+
linewidth=guide.width * PX_TO_PT, linestyle=guide.style)
|
|
231
|
+
|
|
173
232
|
for curve in _visible_curves(plot):
|
|
174
233
|
xs, ys = _sample(curve, x_range[0], x_range[1], samples)
|
|
175
234
|
axes.plot(xs, ys, color=curve.color.name(), linewidth=curve.lineWidth * PX_TO_PT,
|
|
176
235
|
label=curve.label or curve.expression)
|
|
236
|
+
if plot.legend and any(curve.label for curve in _visible_curves(plot)):
|
|
237
|
+
axes.legend(loc=plot.legend_position)
|
|
177
238
|
return figure
|
|
178
239
|
|
|
179
240
|
|