qmlmathplot 0.4.1__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
@@ -431,6 +431,7 @@ class CurveListModel(QAbstractListModel):
431
431
  VisibleRole = CurveRole + 4
432
432
  VertexShaderRole = CurveRole + 5
433
433
  FragmentShaderRole = CurveRole + 6
434
+ LabelRole = CurveRole + 7
434
435
 
435
436
  _ROLES = {
436
437
  CurveRole: b"curve",
@@ -440,6 +441,7 @@ class CurveListModel(QAbstractListModel):
440
441
  VisibleRole: b"visible",
441
442
  VertexShaderRole: b"vertexShader",
442
443
  FragmentShaderRole: b"fragmentShader",
444
+ LabelRole: b"label",
443
445
  }
444
446
 
445
447
  def __init__(
@@ -478,6 +480,8 @@ class CurveListModel(QAbstractListModel):
478
480
  return curve.vertexShader
479
481
  if role == self.FragmentShaderRole:
480
482
  return curve.fragmentShader
483
+ if role == self.LabelRole:
484
+ return curve.label
481
485
  return None
482
486
 
483
487
  def _notify(self, curve: Curve, roles: tuple[int, ...]) -> None:
@@ -498,6 +502,7 @@ class CurveListModel(QAbstractListModel):
498
502
  lambda c=curve: self._notify(c, (self.ColorRole, self.LineWidthRole))
499
503
  )
500
504
  curve.visibleChanged.connect(lambda c=curve: self._notify(c, (self.VisibleRole,)))
505
+ curve.labelChanged.connect(lambda c=curve: self._notify(c, (self.LabelRole,)))
501
506
 
502
507
  # ---------------------------------------------------------------- public
503
508
  def _defaults(self, color: object, line_width: object) -> tuple[QColor, float]:
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/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