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/__init__.py +108 -0
- plotpress/_interactive.py +2849 -0
- plotpress/_spectral.py +154 -0
- plotpress/_version.py +1 -0
- plotpress/artists.py +1382 -0
- plotpress/axes.py +3221 -0
- plotpress/colors.py +498 -0
- plotpress/figure.py +3084 -0
- plotpress/fonts/__init__.py +51 -0
- plotpress/fonts/families.py +192 -0
- plotpress/fonts/installed.py +82 -0
- plotpress/fonts/metrics.py +265 -0
- plotpress/png.py +93 -0
- plotpress/polar.py +240 -0
- plotpress/primitives.py +335 -0
- plotpress/qt.py +427 -0
- plotpress/raster.py +1316 -0
- plotpress/style.py +91 -0
- plotpress/svg.py +2589 -0
- plotpress/ticker.py +212 -0
- plotpress/transform.py +85 -0
- plotpress/vega.py +1324 -0
- plotpress/vega_lite.py +1199 -0
- plotpress-0.23.2.dist-info/METADATA +378 -0
- plotpress-0.23.2.dist-info/RECORD +28 -0
- plotpress-0.23.2.dist-info/WHEEL +5 -0
- plotpress-0.23.2.dist-info/licenses/LICENSE +21 -0
- plotpress-0.23.2.dist-info/top_level.txt +1 -0
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
|