plotpress 0.23.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.
plotpress/qt.py ADDED
@@ -0,0 +1,427 @@
1
+ """Embed interactive plotpress figures in PyQt / PySide apps.
2
+
3
+ A plotpress figure already knows how to render itself as a self-contained
4
+ interactive HTML document (the same vector SVG plus a vanilla-JS toolbar: span /
5
+ pan, data-space zoom, point-picking, annotation, sliders, and marker extract).
6
+ This module drops that document into a ``QWebEngineView`` so the *whole* toolbar
7
+ works inside a native Qt app -- nothing is reimplemented.
8
+
9
+ Works with **PyQt6**, **PySide6**, or **PyQt5** (whichever is installed, tried in
10
+ that order). Install one with the ``qt`` extra::
11
+
12
+ pip install plotpress[qt] # PyQt6 + PyQt6-WebEngine
13
+
14
+ Embed it like any other widget
15
+ ------------------------------
16
+
17
+ ``PlotPressWidget`` is a plain ``QWidget`` subclass -- add it to a layout, give
18
+ it a parent, restyle it, swap the figure at runtime::
19
+
20
+ from plotpress.qt import PlotPressWidget
21
+
22
+ plot = PlotPressWidget(fig) # or PlotPressWidget() then plot.set_figure(fig)
23
+ my_layout.addWidget(plot)
24
+ ...
25
+ plot.set_figure(other_fig) # redraw with a new figure
26
+ plot.markers(print) # async: hand the picked markers to a callback
27
+
28
+ Quick standalone window
29
+ -----------------------
30
+
31
+ import plotpress.qt as spqt
32
+ spqt.view(fig) # opens a window, blocks until closed
33
+
34
+ or, equivalently, ``fig.show_qt()``.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import atexit
40
+ import json
41
+ import os
42
+ import sys
43
+ import tempfile
44
+ import types
45
+
46
+ # Temp HTML files backing the views; QWebEngineView.load() is async, so each
47
+ # file must outlive the load. Cleaned up per-widget and again at interpreter exit.
48
+ _TEMP_FILES: set[str] = set()
49
+
50
+
51
+ def _load_binding():
52
+ """Import a Qt binding + its WebEngine widget; return them in a namespace.
53
+
54
+ Tries PyQt6, then PySide6, then PyQt5. Raises a friendly ImportError naming
55
+ the ``qt`` extra if none is importable.
56
+ """
57
+ tried = []
58
+ for name in ("PyQt6", "PySide6", "PyQt5"):
59
+ try:
60
+ widgets = __import__(name + ".QtWidgets", fromlist=["*"])
61
+ webengine = __import__(name + ".QtWebEngineWidgets", fromlist=["*"])
62
+ core = __import__(name + ".QtCore", fromlist=["*"])
63
+ return types.SimpleNamespace(
64
+ name=name,
65
+ QApplication=widgets.QApplication,
66
+ QWidget=widgets.QWidget,
67
+ QVBoxLayout=widgets.QVBoxLayout,
68
+ QWebEngineView=webengine.QWebEngineView,
69
+ QUrl=core.QUrl,
70
+ Qt=core.Qt,
71
+ )
72
+ except ImportError as exc: # binding (or its WebEngine module) missing
73
+ tried.append(f"{name}: {exc}")
74
+ raise ImportError(
75
+ "plotpress.qt needs a Qt binding with WebEngine. Install one:\n"
76
+ " pip install plotpress[qt] # PyQt6 + PyQt6-WebEngine\n"
77
+ " # or PySide6, or PyQt5 + PyQtWebEngine\n"
78
+ "tried:\n " + "\n ".join(tried)
79
+ )
80
+
81
+
82
+ _QT = _load_binding()
83
+
84
+
85
+ def _remove_temp(path):
86
+ try:
87
+ os.remove(path)
88
+ except OSError:
89
+ pass
90
+ _TEMP_FILES.discard(path)
91
+
92
+
93
+ @atexit.register
94
+ def _cleanup_all_temps():
95
+ for path in list(_TEMP_FILES):
96
+ _remove_temp(path)
97
+
98
+
99
+ class PlotPressWidget(_QT.QWidget):
100
+ """A ``QWidget`` that renders a plotpress :class:`~plotpress.Figure`
101
+ as an interactive figure.
102
+
103
+ Parameters
104
+ ----------
105
+ figure : plotpress.Figure, optional
106
+ Figure to display now. Omit and call :meth:`set_figure` later.
107
+ parent : QWidget, optional
108
+ Standard Qt parent.
109
+ interactive : bool, default True
110
+ Include the JS toolbar. ``False`` embeds a static (but still crisp,
111
+ zoomable-by-Qt) SVG document.
112
+ pick_precision : int, default 6
113
+ Decimal places for the embedded point-pick data (see
114
+ :meth:`plotpress.Figure.to_html`). Lower it to shrink mesh-heavy
115
+ figures.
116
+ """
117
+
118
+ def __init__(self, figure=None, parent=None, interactive=True,
119
+ pick_precision=6):
120
+ super().__init__(parent)
121
+ self._interactive = interactive
122
+ self._pick_precision = pick_precision
123
+ self._temp = None
124
+
125
+ self._view = _QT.QWebEngineView(self)
126
+ layout = _QT.QVBoxLayout(self)
127
+ layout.setContentsMargins(0, 0, 0, 0)
128
+ layout.addWidget(self._view)
129
+
130
+ if figure is not None:
131
+ self.set_figure(figure)
132
+
133
+ # -- public API ---------------------------------------------------------
134
+ def set_figure(self, figure, interactive=None, pick_precision=None):
135
+ """Render ``figure`` into the view, replacing any current one."""
136
+ if interactive is not None:
137
+ self._interactive = interactive
138
+ if pick_precision is not None:
139
+ self._pick_precision = pick_precision
140
+ html = figure.to_html(interactive=self._interactive,
141
+ pick_precision=self._pick_precision)
142
+ self._load_html(html)
143
+ # A sensible default size from the figure's pixel dimensions.
144
+ w = int(figure.figsize[0] * figure.style.dpi)
145
+ h = int(figure.figsize[1] * figure.style.dpi)
146
+ self._view.setMinimumSize(200, 150)
147
+ self.resize(w, h)
148
+
149
+ @property
150
+ def view(self):
151
+ """The underlying ``QWebEngineView`` (for advanced customization)."""
152
+ return self._view
153
+
154
+ def markers(self, callback):
155
+ """Asynchronously fetch the picked markers, then call ``callback(list)``.
156
+
157
+ Each marker is a dict of values (``x``, ``y``, any extra dims like ``z``
158
+ / ``c``, plus ``axes`` and ``kind``) -- the same records the in-figure
159
+ **Extract** button produces. Async because Qt runs page JS off-thread.
160
+ """
161
+ js = ("JSON.stringify(window.plotpressGetMarkers ? "
162
+ "window.plotpressGetMarkers() : [])")
163
+
164
+ def _done(result):
165
+ try:
166
+ callback(json.loads(result) if result else [])
167
+ except (ValueError, TypeError):
168
+ callback([])
169
+
170
+ self._view.page().runJavaScript(js, _done)
171
+
172
+ # -- internals ----------------------------------------------------------
173
+ def _load_html(self, html):
174
+ # QWebEngineView.setHtml() silently truncates content over ~2 MB, which
175
+ # the mesh-heavy figures blow past. Writing to a temp file and loading by
176
+ # URL has no size limit; the document is self-contained (data: URIs), so
177
+ # a file:// base URL resolves everything.
178
+ self._drop_temp()
179
+ fd, path = tempfile.mkstemp(suffix=".html", prefix="plotpress_")
180
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
181
+ f.write(html)
182
+ self._temp = path
183
+ _TEMP_FILES.add(path)
184
+ self._view.load(_QT.QUrl.fromLocalFile(path))
185
+
186
+ def _drop_temp(self):
187
+ if self._temp:
188
+ _remove_temp(self._temp)
189
+ self._temp = None
190
+
191
+ def closeEvent(self, event): # noqa: N802 (Qt naming)
192
+ self._drop_temp()
193
+ super().closeEvent(event)
194
+
195
+
196
+ class LiveArtist:
197
+ """A line or mesh that redraws in place, without reloading the page.
198
+
199
+ ``PlotPressWidget.set_figure()`` is a full ``QWebEngineView`` navigation
200
+ -- a fresh temp file, page teardown/setup, the toolbar JS re-running from
201
+ scratch -- which tops out around 4-5 Hz regardless of data size, since
202
+ that cost is dominated by the navigation itself rather than by rendering.
203
+ ``LiveArtist`` loads the figure once (a normal, full ``set_figure()``
204
+ call, needed to get the toolbar/JS/pick-data in place) and every update
205
+ after that patches the already-loaded page instead: the visible SVG's
206
+ content is swapped for a fresh render, and the point-pick payload for
207
+ this artist's axes is refreshed to match, both in one round trip.
208
+ Measured (offscreen ``QWebEngineView``, PyQt6) at roughly 55 Hz sustained
209
+ for a 50,000-point line and 140 Hz for a 100x100 mesh, against a
210
+ full-reload ceiling around 4-5 Hz regardless of data size -- since that
211
+ ceiling comes from the page navigation itself, not from rendering, it
212
+ doesn't move no matter how much data is on the figure.
213
+
214
+ A NaN-heavy or sparsely/progressively collected mesh -- the common case
215
+ for a real 2-D instrument sweep, most of the grid unmeasured at first --
216
+ needs no special handling: NaN already renders as "no data" and reports
217
+ as such on pick, the same as a static figure's masked region.
218
+
219
+ Parameters
220
+ ----------
221
+ widget : PlotPressWidget
222
+ Must already be showing ``fig`` (or about to -- the first
223
+ :meth:`update` call does that itself).
224
+ fig : plotpress.Figure
225
+ ax : plotpress.Axes
226
+ Must belong to ``fig``.
227
+ on_complete : callable, optional
228
+ Called after each :meth:`update` finishes, with a single argument
229
+ that means different things depending on which path that call took:
230
+ for the very first call (a full page load) it is ``True``, fired
231
+ *synchronously* right after the (async) navigation is started, not
232
+ when the page actually finishes loading -- there is no callback for
233
+ that from a fire-and-forget ``set_figure()``. For every call after
234
+ that (the patched-page path), it fires *asynchronously*, once the
235
+ ``page().runJavaScript()`` round trip genuinely completes, with the
236
+ JS return value (truthy on success).
237
+ **plot_kwargs
238
+ Forwarded to ``ax.plot()`` (a 2-argument :meth:`update`) or
239
+ ``ax.pcolormesh()`` (a 3-argument one) on every call -- ``color``,
240
+ ``cmap``, ``vmin``/``vmax``, etc.
241
+
242
+ Attributes
243
+ ----------
244
+ last_artist : Line2D or QuadMesh or None
245
+ Whatever the most recent :meth:`update` drew -- ``None`` before the
246
+ first call. Since ``update()`` clears the whole axes on every call,
247
+ a colorbar for an autoscaled mesh (no fixed ``vmin``/``vmax``) has
248
+ to be dropped and redrawn from the new mappable each time too; this
249
+ is what to hand ``fig.colorbar()`` for that::
250
+
251
+ mesh = LiveArtist(widget, fig, ax, cmap="viridis")
252
+ cbar_ax = None
253
+
254
+ def on_new_frame(x, y, c):
255
+ global cbar_ax
256
+ mesh.update(x, y, c)
257
+ if cbar_ax is not None:
258
+ fig.delaxes(cbar_ax)
259
+ cbar_ax = fig.colorbar(mesh.last_artist, ax=ax)
260
+
261
+ Examples
262
+ --------
263
+ A rolling line, one new sample per call::
264
+
265
+ from collections import deque
266
+ from plotpress.qt import PlotPressWidget, LiveArtist
267
+
268
+ fig, ax = plotpress.subplots()
269
+ widget = PlotPressWidget(fig)
270
+ line = LiveArtist(widget, fig, ax)
271
+ xs, ys = deque(maxlen=500), deque(maxlen=500)
272
+
273
+ def on_new_sample(x, y):
274
+ xs.append(x); ys.append(y)
275
+ line.update(np.fromiter(xs, float), np.fromiter(ys, float))
276
+
277
+ A mesh filled in as a 2-D sweep collects, starting all-NaN::
278
+
279
+ mesh = LiveArtist(widget, fig, ax, cmap="viridis", vmin=0, vmax=1)
280
+ grid = np.full((ny, nx), np.nan)
281
+
282
+ def on_new_point(row, col, value):
283
+ grid[row, col] = value
284
+ mesh.update(x_edges, y_edges, grid) # 3 args -> pcolormesh
285
+ """
286
+
287
+ def __init__(self, widget, fig, ax, on_complete=None, **plot_kwargs):
288
+ self.widget = widget
289
+ self.fig = fig
290
+ self.ax = ax
291
+ self.plot_kwargs = plot_kwargs
292
+ self.on_complete = on_complete or (lambda _result: None)
293
+ self.last_artist = None
294
+ self._loaded = False
295
+
296
+ def update(self, *data):
297
+ """Redraw with new data: ``update(x, y)`` for a line, ``update(x, y,
298
+ C)`` for a mesh -- same argument shape as ``ax.plot()``/
299
+ ``ax.pcolormesh()``, which is exactly what this calls after clearing
300
+ the axes. The very first call goes through a full page load (needed
301
+ once to get the toolbar/JS in place); every call after that patches
302
+ the already-loaded page instead.
303
+ """
304
+ if len(data) == 2:
305
+ x, y = data
306
+ self.ax.cla()
307
+ self.last_artist = self.ax.plot(x, y, **self.plot_kwargs)
308
+ if len(x):
309
+ self.ax.set_xlim(float(min(x)), float(max(x)))
310
+ elif len(data) == 3:
311
+ x, y, c = data
312
+ self.ax.cla()
313
+ self.last_artist = self.ax.pcolormesh(x, y, c, **self.plot_kwargs)
314
+ else:
315
+ raise TypeError(
316
+ "update() takes (x, y) for a line or (x, y, C) for a mesh, "
317
+ f"got {len(data)} arguments")
318
+
319
+ if not self._loaded:
320
+ self.widget.set_figure(self.fig)
321
+ self._loaded = True
322
+ self.on_complete(True)
323
+ else:
324
+ from .figure import _sanitize_nan
325
+ from .svg import pick_data
326
+ svg = self.fig.to_svg()
327
+ axes_index = self.fig.axes.index(self.ax)
328
+ entry = pick_data(self.fig).get(
329
+ axes_index, {"series": [], "meshes": [], "pies": []})
330
+ self.widget.view.page().runJavaScript(
331
+ _live_update_js(svg, axes_index, _sanitize_nan(entry)), self.on_complete)
332
+
333
+
334
+ def _live_update_js(svg_text, axes_index, pick_entry):
335
+ """Swap #plotpress-svg's *children* for a fresh render and refresh that
336
+ axes' embedded pick-data entry, in one round trip.
337
+
338
+ Keeps the same outer <svg> node object alive rather than replacing it:
339
+ the toolbar JS captures ``document.getElementById('plotpress-svg')``
340
+ once into a closure at load time, so replacing the node itself would
341
+ silently detach pan/zoom/pick from what's actually on screen after the
342
+ first update. Two attributes on that node are deliberately left as they
343
+ are rather than copied from the fresh render: ``id`` (obviously), and
344
+ ``viewBox`` -- the toolbar's pan/zoom state lives in a JS closure variable
345
+ captured once at load, not re-read from the DOM, so overwriting the
346
+ live attribute out from under it would visually snap a panned/zoomed
347
+ view back to home while leaving that JS state stale and now out of sync
348
+ with what's on screen. User-placed pins (Point Picking markers, Annotation
349
+ notes -- all rendered as direct <svg> children tagged
350
+ ``plotpress-pin``, never part of the server-rendered SVG) are lifted out
351
+ before the swap and reattached after, so a live update doesn't silently
352
+ wipe them the way a full page reload already would. A pin's position is
353
+ an SVG-user-space point, the same space pan/zoom moves it through, so it
354
+ stays correct across pan/zoom; it is *not* re-anchored to its data
355
+ coordinate if this update also changed that axes' limits (a growing-axis
356
+ figure), the one case where a preserved pin can end up pointing at the
357
+ wrong pixel for what it labeled.
358
+
359
+ ``pick_entry`` must already be finite -- run it through
360
+ :func:`plotpress.figure._sanitize_nan` first. A bare NaN/Infinity is a
361
+ valid Python float but not valid JSON; ``json.dumps`` emits it as an
362
+ unquoted token the browser's strict ``JSON.parse`` throws on, which would
363
+ otherwise silently break *only* live-updated picking (the initial static
364
+ payload already goes through this same sanitizing step).
365
+ """
366
+ return """
367
+ (function() {
368
+ var old = document.getElementById('plotpress-svg');
369
+ if (!old) return false;
370
+ var pins = [];
371
+ for (var p = 0; p < old.children.length; p++) {
372
+ if (old.children[p].classList.contains('plotpress-pin')) pins.push(old.children[p]);
373
+ }
374
+ var doc = new DOMParser().parseFromString(%s, 'image/svg+xml');
375
+ var fresh = doc.documentElement;
376
+ while (old.firstChild) old.removeChild(old.firstChild);
377
+ while (fresh.firstChild) old.appendChild(fresh.firstChild);
378
+ pins.forEach(function(pin) { old.appendChild(pin); });
379
+ for (var i = 0; i < fresh.attributes.length; i++) {
380
+ var a = fresh.attributes[i];
381
+ if (a.name !== 'id' && a.name !== 'viewBox') old.setAttribute(a.name, a.value);
382
+ }
383
+ if (window.plotpressUpdatePick) window.plotpressUpdatePick(%d, %s);
384
+ return true;
385
+ })();
386
+ """ % (json.dumps(svg_text), axes_index, json.dumps(json.dumps(pick_entry)))
387
+
388
+
389
+ def view(figure, title="plotpress", block=True, interactive=True,
390
+ pick_precision=6):
391
+ """Open ``figure`` in a standalone Qt window.
392
+
393
+ Reuses the running ``QApplication`` if there is one (e.g. inside an existing
394
+ app or an IPython Qt event loop); otherwise creates one. With ``block=True``
395
+ (the default outside an existing app) the call blocks until the window
396
+ closes. Returns the :class:`PlotPressWidget`.
397
+ """
398
+ app = _QT.QApplication.instance()
399
+ owns_app = app is None
400
+ if owns_app:
401
+ _enable_webengine_gl()
402
+ # sys.argv, not [] -- an empty argv leaves QtWebEngine's internal
403
+ # base::CommandLine uninitialized ("the program name is not passed
404
+ # to QCoreApplication"), which breaks every QWebEngineView this
405
+ # QApplication ever creates, not just this one.
406
+ app = _QT.QApplication(sys.argv)
407
+
408
+ widget = PlotPressWidget(figure, interactive=interactive,
409
+ pick_precision=pick_precision)
410
+ widget.setWindowTitle(title)
411
+ widget.show()
412
+
413
+ if block and owns_app:
414
+ # exec() on PyQt6/PySide6; exec_() on PyQt5.
415
+ (getattr(app, "exec", None) or app.exec_)()
416
+ return widget
417
+
418
+
419
+ def _enable_webengine_gl():
420
+ """Best-effort: some platforms need shared GL contexts for WebEngine."""
421
+ attr = getattr(getattr(_QT.Qt, "ApplicationAttribute", _QT.Qt),
422
+ "AA_ShareOpenGLContexts", None)
423
+ if attr is not None:
424
+ try:
425
+ _QT.QApplication.setAttribute(attr, True)
426
+ except (TypeError, RuntimeError):
427
+ pass