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 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 parsed(value: str | sp.Expr) -> tuple[str, sp.Expr]:
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). The canonical string is what identifies the expression.
104
+ Expr`` round trip). ``Eq(lhs, rhs)`` is normalized to ``lhs - rhs``. The canonical string
105
+ is what identifies the expression.
88
106
  """
89
- if isinstance(value, sp.Basic):
90
- return str(value), value
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.Expr,
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 = parsed(expression)
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
- cls: type[Curve] = ImplicitCurve if (implicit or "y" in names) else Curve
119
- return cls(expr, color=color, line_width=line_width, label=label, parent=parent)
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. ``ImplicitCurve`` forces the level-set template regardless of the symbols.
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.Expr = "sin(x)",
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.Expr, *, synchronous: bool = False) -> None:
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
- if isinstance(source, sp.Basic): # already an expression: use it as it is
200
- expr: sp.Expr = source
201
- else:
202
- try:
203
- expr = sp.sympify(source, locals={"x": self._symbol, "y": sp.Symbol("y")})
204
- except Exception as exc:
205
- # Keep the last drawn expression: the shaders stay, `expression` is what was
206
- # typed and `error` says why.
207
- self._expression = str(canonical)
208
- self._error = f"{type(exc).__name__}: {exc}"
209
- self.expressionChanged.emit()
210
- self.errorChanged.emit()
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(generation, expr, synchronous=synchronous)
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.Expr = Property("QVariant", _get_expression, _set_expression,
221
- notify=expressionChanged)
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(self, expr: sp.Expr) -> tuple[str, str]:
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
- return implicit_shader_sources(expr) if (self._force_implicit or "y" in names) \
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(self, generation: int, expr: sp.Expr, *, synchronous: bool) -> None:
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(expr)
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.Expr,
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.Expr,
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
- self.model.grid = True if visible is None else bool(visible)
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
- # ------------------------------------------------------ explicitly absent
264
+ # ------------------------------------------------------- text and annotation
259
265
  def legend(self, *args: object, **kwargs: object) -> None:
260
- raise _unsupported(
261
- "legend()", "chrome belongs to the host: use Curve.label and a PlotView.overlay"
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(self, *args: object, **kwargs: object) -> None:
271
- raise _unsupported("annotate()", "annotations are not implemented")
272
-
273
- def text(self, *args: object, **kwargs: object) -> None:
274
- raise _unsupported("text()", "annotations are not implemented")
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 set_xticks(self, *args: object, **kwargs: object) -> None:
277
- raise _unsupported("set_xticks()", "ticks are automatic nice numbers today")
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 set_yticks(self, *args: object, **kwargs: object) -> None:
280
- raise _unsupported("set_yticks()", "ticks are automatic nice numbers today")
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
- IVal gate = FI(x - R * dx, x + R * dx);
447
- if (col.kind == IK_EMPTY && gate.kind != IK_EMPTY) {
448
- // The stroke's outer half may cross the domain boundary by up to one radius.
449
- col = gate;
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
- // 16 samples over ±2 columns (0.25-column spacing) cover the perpendicular foot even at
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 = 16;
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(True, color=plot.grid_color.name(),
159
- linewidth=plot.grid_width * PX_TO_PT, alpha=plot.grid_alpha,
160
- linestyle=plot.grid_style)
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(colors=plot.tick_color.name(), labelcolor=plot.text_color.name(),
165
- labelsize=plot.tick_font_size * PX_TO_PT)
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