anyplotlib 0.1.0__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.
- anyplotlib/__init__.py +53 -0
- anyplotlib/_base_plot.py +229 -0
- anyplotlib/_electron.py +74 -0
- anyplotlib/_repr_utils.py +345 -0
- anyplotlib/_utils.py +194 -0
- anyplotlib/axes/__init__.py +6 -0
- anyplotlib/axes/_axes.py +510 -0
- anyplotlib/axes/_inset_axes.py +126 -0
- anyplotlib/callbacks.py +328 -0
- anyplotlib/embed.py +194 -0
- anyplotlib/figure/__init__.py +7 -0
- anyplotlib/figure/_figure.py +633 -0
- anyplotlib/figure/_gridspec.py +99 -0
- anyplotlib/figure/_subplots.py +100 -0
- anyplotlib/figure_esm.js +6011 -0
- anyplotlib/markers.py +704 -0
- anyplotlib/plot1d/__init__.py +6 -0
- anyplotlib/plot1d/_plot1d.py +1376 -0
- anyplotlib/plot1d/_plotbar.py +436 -0
- anyplotlib/plot2d/__init__.py +5 -0
- anyplotlib/plot2d/_plot2d.py +726 -0
- anyplotlib/plot2d/_plotmesh.py +116 -0
- anyplotlib/plot3d/__init__.py +4 -0
- anyplotlib/plot3d/_plot3d.py +524 -0
- anyplotlib/plotxy/__init__.py +4 -0
- anyplotlib/plotxy/_plotxy.py +273 -0
- anyplotlib/sphinx_anywidget/__init__.py +177 -0
- anyplotlib/sphinx_anywidget/_directive.py +245 -0
- anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
- anyplotlib/sphinx_anywidget/_scraper.py +390 -0
- anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
- anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
- anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
- anyplotlib/widgets/__init__.py +18 -0
- anyplotlib/widgets/_base.py +218 -0
- anyplotlib/widgets/_widgets1d.py +109 -0
- anyplotlib/widgets/_widgets2d.py +141 -0
- anyplotlib/widgets/_widgets3d.py +50 -0
- anyplotlib-0.1.0.dist-info/METADATA +160 -0
- anyplotlib-0.1.0.dist-info/RECORD +42 -0
- anyplotlib-0.1.0.dist-info/WHEEL +4 -0
- anyplotlib-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""
|
|
2
|
+
figure/_figure.py
|
|
3
|
+
=================
|
|
4
|
+
Top-level Figure widget (the single anywidget.AnyWidget subclass).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import anywidget
|
|
15
|
+
import traitlets
|
|
16
|
+
|
|
17
|
+
from anyplotlib.axes import Axes, InsetAxes
|
|
18
|
+
from anyplotlib.axes._inset_axes import _plot_kind
|
|
19
|
+
from anyplotlib.figure._gridspec import SubplotSpec
|
|
20
|
+
from anyplotlib.callbacks import Event
|
|
21
|
+
from anyplotlib._repr_utils import repr_html_iframe
|
|
22
|
+
|
|
23
|
+
_HERE = pathlib.Path(__file__).parent.parent
|
|
24
|
+
_ESM_SOURCE = (_HERE / "figure_esm.js").read_text(encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Figure(anywidget.AnyWidget):
|
|
28
|
+
"""Multi-panel interactive figure widget.
|
|
29
|
+
|
|
30
|
+
The top-level container for all plots and the only ``anywidget.AnyWidget``
|
|
31
|
+
subclass in anyplotlib. It owns all traitlets and acts as the Python ↔
|
|
32
|
+
JavaScript bridge via the ``figure_esm.js`` canvas renderer.
|
|
33
|
+
|
|
34
|
+
Create via :func:`subplots` (recommended) or directly::
|
|
35
|
+
|
|
36
|
+
fig = Figure(2, 2, figsize=(800, 600))
|
|
37
|
+
ax = fig.add_subplot((0, 0))
|
|
38
|
+
v2d = ax.imshow(data)
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
nrows, ncols : int, optional
|
|
43
|
+
Grid dimensions. Default 1 row, 1 column.
|
|
44
|
+
figsize : (width, height), optional
|
|
45
|
+
Figure size in CSS pixels. Default ``(640, 480)``.
|
|
46
|
+
width_ratios : list of float, optional
|
|
47
|
+
Relative column widths. Length must equal *ncols*.
|
|
48
|
+
height_ratios : list of float, optional
|
|
49
|
+
Relative row heights. Length must equal *nrows*.
|
|
50
|
+
sharex, sharey : bool, optional
|
|
51
|
+
Link pan/zoom across all panels on the respective axis.
|
|
52
|
+
Default False (independent pan/zoom per panel).
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
See Also
|
|
56
|
+
--------
|
|
57
|
+
subplots : Recommended factory for creating Figure and Axes grid.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
layout_json = traitlets.Unicode("{}").tag(sync=True)
|
|
61
|
+
fig_width = traitlets.Int(640).tag(sync=True)
|
|
62
|
+
fig_height = traitlets.Int(480).tag(sync=True)
|
|
63
|
+
# Bidirectional JS event bus: JS writes interaction events here, Python reads them.
|
|
64
|
+
event_json = traitlets.Unicode("{}").tag(sync=True)
|
|
65
|
+
# When True the JS renderer shows a per-panel FPS / frame-time overlay.
|
|
66
|
+
display_stats = traitlets.Bool(False).tag(sync=True)
|
|
67
|
+
# Figure-level help text shown in a '?' badge overlay in JS.
|
|
68
|
+
# Empty string means no badge. Gated by apl.show_help at the Python level.
|
|
69
|
+
help_text = traitlets.Unicode("").tag(sync=True)
|
|
70
|
+
_esm = _ESM_SOURCE
|
|
71
|
+
# Static CSS injected by anywidget alongside _esm.
|
|
72
|
+
# .apl-scale-wrap — outer container; width:100% means it always fills
|
|
73
|
+
# the cell without any JS width updates.
|
|
74
|
+
# .apl-outer — the figure root; will-change:transform pre-promotes
|
|
75
|
+
# it to a GPU compositing layer so transform:scale()
|
|
76
|
+
# changes cost zero layout/paint passes.
|
|
77
|
+
_css = """\
|
|
78
|
+
.apl-scale-wrap {
|
|
79
|
+
display: block;
|
|
80
|
+
width: 100%;
|
|
81
|
+
overflow: visible;
|
|
82
|
+
position: relative;
|
|
83
|
+
line-height: 0;
|
|
84
|
+
}
|
|
85
|
+
.apl-outer {
|
|
86
|
+
display: inline-block;
|
|
87
|
+
position: relative;
|
|
88
|
+
user-select: none;
|
|
89
|
+
z-index: 1;
|
|
90
|
+
isolation: isolate;
|
|
91
|
+
will-change: transform;
|
|
92
|
+
transform-origin: top left;
|
|
93
|
+
vertical-align: top;
|
|
94
|
+
/* min-width: max-content prevents the inline-block from shrinking when
|
|
95
|
+
the parent container (scaleWrap, width:100%) narrows because the
|
|
96
|
+
Jupyter cell is narrower than the figure's native width. Without
|
|
97
|
+
this, outerDiv.offsetWidth collapses to cellW, causing _applyScale()
|
|
98
|
+
to compute s = cellW/cellW = 1.0 (no-op) instead of the correct
|
|
99
|
+
s = cellW/nativeW < 1. */
|
|
100
|
+
min-width: max-content;
|
|
101
|
+
}
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, nrows=1, ncols=1, figsize=(640, 480),
|
|
105
|
+
width_ratios=None, height_ratios=None,
|
|
106
|
+
sharex=False, sharey=False,
|
|
107
|
+
display_stats=False, help="", **kwargs):
|
|
108
|
+
super().__init__(**kwargs)
|
|
109
|
+
self._nrows = nrows
|
|
110
|
+
self._ncols = ncols
|
|
111
|
+
self._width_ratios = list(width_ratios) if width_ratios else [1] * ncols
|
|
112
|
+
self._height_ratios = list(height_ratios) if height_ratios else [1] * nrows
|
|
113
|
+
self._sharex = sharex
|
|
114
|
+
self._sharey = sharey
|
|
115
|
+
self._axes_map: dict = {}
|
|
116
|
+
self._plots_map: dict = {}
|
|
117
|
+
self._insets_map: dict = {}
|
|
118
|
+
self._hspace: float | None = None
|
|
119
|
+
self._wspace: float | None = None
|
|
120
|
+
self._batching: bool = False
|
|
121
|
+
self._batch_dirty: set = set()
|
|
122
|
+
# Geometry-channel bookkeeping (per panel id): a monotonic revision
|
|
123
|
+
# and the last geometry dict sent, so geometry is re-transmitted only
|
|
124
|
+
# when its values genuinely change.
|
|
125
|
+
self._geom_rev: dict = {}
|
|
126
|
+
self._geom_last: dict = {}
|
|
127
|
+
with self.hold_trait_notifications():
|
|
128
|
+
self.fig_width = figsize[0]
|
|
129
|
+
self.fig_height = figsize[1]
|
|
130
|
+
self.display_stats = display_stats
|
|
131
|
+
self.help_text = self._resolve_help(help)
|
|
132
|
+
self._push_layout()
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _resolve_help(text: str) -> str:
|
|
136
|
+
"""Return *text* if ``apl.show_help`` is True (default), else ``""``."""
|
|
137
|
+
try:
|
|
138
|
+
import anyplotlib as _apl
|
|
139
|
+
if not getattr(_apl, "show_help", True):
|
|
140
|
+
return ""
|
|
141
|
+
except ImportError:
|
|
142
|
+
pass
|
|
143
|
+
return text or ""
|
|
144
|
+
|
|
145
|
+
def set_help(self, text: str) -> None:
|
|
146
|
+
"""Set (or clear) the figure-level help text shown in the **?** badge.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
text : str
|
|
151
|
+
Help string displayed when the user clicks the **?** badge.
|
|
152
|
+
Pass an empty string (or ``""`` ) to remove the badge entirely.
|
|
153
|
+
Newlines (``\\n``) are respected in the card.
|
|
154
|
+
|
|
155
|
+
Examples
|
|
156
|
+
--------
|
|
157
|
+
>>> fig.set_help("Drag peak: move μ/A\\nPress f: least-squares fit")
|
|
158
|
+
>>> fig.set_help("") # hide the badge
|
|
159
|
+
"""
|
|
160
|
+
self.help_text = self._resolve_help(text)
|
|
161
|
+
|
|
162
|
+
def subplots_adjust(self, hspace: float | None = None,
|
|
163
|
+
wspace: float | None = None) -> None:
|
|
164
|
+
"""Set the spacing between subplot panels.
|
|
165
|
+
|
|
166
|
+
Only the arguments that are explicitly provided are updated; omitting
|
|
167
|
+
an argument leaves the current value unchanged.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
hspace : float, optional
|
|
172
|
+
Fraction of the average row height to use as vertical gap between
|
|
173
|
+
panels. ``0.1`` adds a gap of 10 % of the mean row height.
|
|
174
|
+
``None`` (default) leaves the current hspace unchanged.
|
|
175
|
+
wspace : float, optional
|
|
176
|
+
Fraction of the average column width to use as horizontal gap.
|
|
177
|
+
``None`` (default) leaves the current wspace unchanged.
|
|
178
|
+
"""
|
|
179
|
+
if hspace is not None:
|
|
180
|
+
self._hspace = float(hspace)
|
|
181
|
+
if wspace is not None:
|
|
182
|
+
self._wspace = float(wspace)
|
|
183
|
+
self._push_layout()
|
|
184
|
+
|
|
185
|
+
# ── subplot creation ──────────────────────────────────────────────────────
|
|
186
|
+
def add_subplot(self, spec) -> Axes:
|
|
187
|
+
"""Add a subplot cell and return its :class:`Axes`.
|
|
188
|
+
|
|
189
|
+
Parameters
|
|
190
|
+
----------
|
|
191
|
+
spec : SubplotSpec or int or tuple of (row, col)
|
|
192
|
+
Which grid cell(s) to occupy. A :class:`SubplotSpec` is used
|
|
193
|
+
directly (e.g. from ``GridSpec[r, c]``). An :class:`int` is
|
|
194
|
+
converted via ``divmod(spec, ncols)``, matching
|
|
195
|
+
``matplotlib.Figure.add_subplot`` numbering. A ``(row, col)``
|
|
196
|
+
tuple selects a single cell.
|
|
197
|
+
|
|
198
|
+
Returns
|
|
199
|
+
-------
|
|
200
|
+
Axes
|
|
201
|
+
The subplot axes object. Call plotting methods like ``.imshow()``,
|
|
202
|
+
``.plot()``, ``.bar()`` to attach data.
|
|
203
|
+
|
|
204
|
+
Raises
|
|
205
|
+
------
|
|
206
|
+
TypeError
|
|
207
|
+
If *spec* is not a SubplotSpec, int, or tuple.
|
|
208
|
+
|
|
209
|
+
Examples
|
|
210
|
+
--------
|
|
211
|
+
>>> fig = Figure(2, 2)
|
|
212
|
+
>>> ax1 = fig.add_subplot(0) # top-left (via numbering)
|
|
213
|
+
>>> ax2 = fig.add_subplot((0, 1)) # top-right (via tuple)
|
|
214
|
+
"""
|
|
215
|
+
if isinstance(spec, SubplotSpec):
|
|
216
|
+
# Auto-sync Figure grid to the parent GridSpec when the GridSpec is
|
|
217
|
+
# larger than the Figure's current dimensions. This allows the
|
|
218
|
+
# common workflow:
|
|
219
|
+
# gs = GridSpec(2, 2, height_ratios=[3, 1])
|
|
220
|
+
# fig = Figure(figsize=(...)) # defaults to nrows=1, ncols=1
|
|
221
|
+
# fig.add_subplot(gs[0, :]) # Figure adopts 2×2 from GridSpec
|
|
222
|
+
# without requiring the user to repeat nrows/ncols/ratios on Figure.
|
|
223
|
+
gs = spec._gs
|
|
224
|
+
if gs is not None:
|
|
225
|
+
if gs.nrows > self._nrows:
|
|
226
|
+
self._nrows = gs.nrows
|
|
227
|
+
self._height_ratios = list(gs.height_ratios)
|
|
228
|
+
if gs.ncols > self._ncols:
|
|
229
|
+
self._ncols = gs.ncols
|
|
230
|
+
self._width_ratios = list(gs.width_ratios)
|
|
231
|
+
elif isinstance(spec, int):
|
|
232
|
+
row, col = divmod(spec, self._ncols)
|
|
233
|
+
spec = SubplotSpec(None, row, row + 1, col, col + 1)
|
|
234
|
+
elif isinstance(spec, tuple):
|
|
235
|
+
row, col = spec
|
|
236
|
+
spec = SubplotSpec(None, row, row + 1, col, col + 1)
|
|
237
|
+
else:
|
|
238
|
+
raise TypeError(f"add_subplot: unsupported spec type {type(spec)!r}")
|
|
239
|
+
return Axes(self, spec)
|
|
240
|
+
|
|
241
|
+
# ── internal registration (called by Axes._attach) ────────────────────────
|
|
242
|
+
def _register_panel(self, ax: Axes, plot) -> None:
|
|
243
|
+
pid = plot._id
|
|
244
|
+
if not self.has_trait(f"panel_{pid}_json"):
|
|
245
|
+
self.add_traits(**{f"panel_{pid}_json": traitlets.Unicode("{}").tag(sync=True)})
|
|
246
|
+
# Plots that declare _GEOM_KEYS get a second trait carrying only the
|
|
247
|
+
# heavy geometry, re-sent only when that geometry changes. The light
|
|
248
|
+
# view trait then references it by revision so JS reuses the cached
|
|
249
|
+
# decode across view-only updates (highlight, camera, planes).
|
|
250
|
+
if getattr(plot, "_GEOM_KEYS", None) and not self.has_trait(f"panel_{pid}_geom"):
|
|
251
|
+
self.add_traits(**{f"panel_{pid}_geom": traitlets.Unicode("{}").tag(sync=True)})
|
|
252
|
+
self._geom_rev[pid] = 0
|
|
253
|
+
self._geom_last[pid] = None
|
|
254
|
+
self._plots_map[pid] = plot
|
|
255
|
+
self._axes_map[pid] = ax
|
|
256
|
+
self._push(pid)
|
|
257
|
+
self._push_layout()
|
|
258
|
+
|
|
259
|
+
def _push(self, panel_id: str) -> None:
|
|
260
|
+
"""Serialise one panel and write to its trait.
|
|
261
|
+
|
|
262
|
+
Inside a :meth:`batch` block, pushes are coalesced: each panel is
|
|
263
|
+
recorded as dirty and serialised + sent exactly once when the block
|
|
264
|
+
exits, no matter how many mutations touched it. This collapses the
|
|
265
|
+
many per-frame pushes of a linked-view update (set_data + set_title +
|
|
266
|
+
widget moves on the same panel) into one serialise/transfer per panel
|
|
267
|
+
— the dominant cost over a Pyodide comm boundary.
|
|
268
|
+
"""
|
|
269
|
+
plot = self._plots_map.get(panel_id)
|
|
270
|
+
if plot is None:
|
|
271
|
+
return
|
|
272
|
+
if self._batching:
|
|
273
|
+
self._batch_dirty.add(panel_id)
|
|
274
|
+
return
|
|
275
|
+
tname = f"panel_{panel_id}_json"
|
|
276
|
+
if not self.has_trait(tname):
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
state = plot.to_state_dict()
|
|
280
|
+
geom_keys = getattr(plot, "_GEOM_KEYS", None)
|
|
281
|
+
gname = f"panel_{panel_id}_geom"
|
|
282
|
+
if geom_keys and self.has_trait(gname):
|
|
283
|
+
# Split heavy geometry into its own channel. Detect change by
|
|
284
|
+
# comparing the geom values themselves (the b64 strings / LUT
|
|
285
|
+
# lists) against the last-sent snapshot — a reference/equality
|
|
286
|
+
# check that avoids re-serialising hundreds of KB on every
|
|
287
|
+
# view-only frame. Only on a real change do we serialise the
|
|
288
|
+
# geom blob, bump the revision, and write the geom trait.
|
|
289
|
+
geom = {k: state.pop(k) for k in geom_keys if k in state}
|
|
290
|
+
if geom != self._geom_last.get(panel_id):
|
|
291
|
+
self._geom_last[panel_id] = geom
|
|
292
|
+
self._geom_rev[panel_id] = self._geom_rev.get(panel_id, 0) + 1
|
|
293
|
+
setattr(self, gname, json.dumps(geom, sort_keys=True))
|
|
294
|
+
state["_geom_rev"] = self._geom_rev.get(panel_id, 0)
|
|
295
|
+
setattr(self, tname, json.dumps(state))
|
|
296
|
+
else:
|
|
297
|
+
setattr(self, tname, json.dumps(state))
|
|
298
|
+
|
|
299
|
+
@contextlib.contextmanager
|
|
300
|
+
def batch(self):
|
|
301
|
+
"""Coalesce all panel pushes within the block into one push per panel.
|
|
302
|
+
|
|
303
|
+
Use around multi-panel updates (e.g. a linked-view crosshair handler)
|
|
304
|
+
so a single mouse event produces one serialise + transfer per panel
|
|
305
|
+
instead of one per mutation — a large win under Pyodide / remote
|
|
306
|
+
kernels where every push crosses a comm boundary.
|
|
307
|
+
|
|
308
|
+
::
|
|
309
|
+
|
|
310
|
+
with fig.batch():
|
|
311
|
+
v_xz.set_data(slice_xz)
|
|
312
|
+
v_yz.set_data(slice_yz)
|
|
313
|
+
cross.set(cx=..., cy=...)
|
|
314
|
+
"""
|
|
315
|
+
if self._batching: # already batching — nest transparently
|
|
316
|
+
yield
|
|
317
|
+
return
|
|
318
|
+
self._batching = True
|
|
319
|
+
self._batch_dirty = set()
|
|
320
|
+
try:
|
|
321
|
+
yield
|
|
322
|
+
finally:
|
|
323
|
+
self._batching = False
|
|
324
|
+
dirty, self._batch_dirty = self._batch_dirty, set()
|
|
325
|
+
# One push per dirty panel — no matter how many mutations touched
|
|
326
|
+
# it during the block. hold_trait_notifications coalesces the
|
|
327
|
+
# underlying comm traffic into a single sync.
|
|
328
|
+
with self.hold_trait_notifications():
|
|
329
|
+
for pid in dirty:
|
|
330
|
+
self._push(pid)
|
|
331
|
+
|
|
332
|
+
# ── layout ────────────────────────────────────────────────────────────────
|
|
333
|
+
def _compute_cell_sizes(self) -> dict:
|
|
334
|
+
fw, fh = self.fig_width, self.fig_height
|
|
335
|
+
wr, hr = self._width_ratios, self._height_ratios
|
|
336
|
+
wsum, hsum = sum(wr), sum(hr)
|
|
337
|
+
|
|
338
|
+
# Grid tracks are pure ratio math — no aspect-locking.
|
|
339
|
+
# Rule: col_px[i] = fw * width_ratios[i] / Σ width_ratios (and analogous
|
|
340
|
+
# for rows). Every panel gets exactly the canvas size its cell specifies;
|
|
341
|
+
# images are rendered "contain" (letterboxed) in JS if needed.
|
|
342
|
+
col_px = [fw * w / wsum for w in wr]
|
|
343
|
+
row_px = [fh * h / hsum for h in hr]
|
|
344
|
+
|
|
345
|
+
sizes: dict = {}
|
|
346
|
+
for pid, ax in self._axes_map.items():
|
|
347
|
+
s = ax._spec
|
|
348
|
+
pw = int(round(sum(col_px[s.col_start:s.col_stop])))
|
|
349
|
+
ph = int(round(sum(row_px[s.row_start:s.row_stop])))
|
|
350
|
+
sizes[pid] = (max(64, pw), max(64, ph))
|
|
351
|
+
return sizes
|
|
352
|
+
|
|
353
|
+
def _push_layout(self) -> None:
|
|
354
|
+
cell_sizes = self._compute_cell_sizes()
|
|
355
|
+
all_ids = list(self._axes_map.keys())
|
|
356
|
+
share_groups: dict = {}
|
|
357
|
+
|
|
358
|
+
def _mg(flag, key):
|
|
359
|
+
if flag is True and len(all_ids) > 1:
|
|
360
|
+
share_groups[key] = [list(all_ids)]
|
|
361
|
+
elif isinstance(flag, list):
|
|
362
|
+
share_groups[key] = flag
|
|
363
|
+
|
|
364
|
+
_mg(self._sharex, "x")
|
|
365
|
+
_mg(self._sharey, "y")
|
|
366
|
+
|
|
367
|
+
panel_specs = []
|
|
368
|
+
for pid, ax in self._axes_map.items():
|
|
369
|
+
s = ax._spec
|
|
370
|
+
pw, ph = cell_sizes.get(pid, (200, 200))
|
|
371
|
+
plot = self._plots_map.get(pid)
|
|
372
|
+
panel_specs.append({
|
|
373
|
+
"id": pid,
|
|
374
|
+
"kind": _plot_kind(plot) if plot else "1d",
|
|
375
|
+
"row_start": s.row_start,
|
|
376
|
+
"row_stop": s.row_stop,
|
|
377
|
+
"col_start": s.col_start,
|
|
378
|
+
"col_stop": s.col_stop,
|
|
379
|
+
"panel_width": pw,
|
|
380
|
+
"panel_height": ph,
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
inset_specs = []
|
|
384
|
+
for pid, inset_ax in self._insets_map.items():
|
|
385
|
+
plot = self._plots_map.get(pid)
|
|
386
|
+
pw = max(64, round(self.fig_width * inset_ax.w_frac))
|
|
387
|
+
ph = max(64, round(self.fig_height * inset_ax.h_frac))
|
|
388
|
+
inset_specs.append({
|
|
389
|
+
"id": pid,
|
|
390
|
+
"kind": _plot_kind(plot) if plot else "1d",
|
|
391
|
+
"w_frac": inset_ax.w_frac,
|
|
392
|
+
"h_frac": inset_ax.h_frac,
|
|
393
|
+
"corner": inset_ax.corner,
|
|
394
|
+
"title": inset_ax.title,
|
|
395
|
+
"panel_width": pw,
|
|
396
|
+
"panel_height": ph,
|
|
397
|
+
"inset_state": inset_ax._inset_state,
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
self.layout_json = json.dumps({
|
|
401
|
+
"nrows": self._nrows,
|
|
402
|
+
"ncols": self._ncols,
|
|
403
|
+
"width_ratios": self._width_ratios,
|
|
404
|
+
"height_ratios": self._height_ratios,
|
|
405
|
+
"fig_width": self.fig_width,
|
|
406
|
+
"fig_height": self.fig_height,
|
|
407
|
+
"panel_specs": panel_specs,
|
|
408
|
+
"share_groups": share_groups,
|
|
409
|
+
"inset_specs": inset_specs,
|
|
410
|
+
"hspace": self._hspace,
|
|
411
|
+
"wspace": self._wspace,
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
# ── inset creation ────────────────────────────────────────────────────────
|
|
415
|
+
def add_inset(self, w_frac: float, h_frac: float, *,
|
|
416
|
+
corner: str = "top-right", title: str = "") -> "InsetAxes":
|
|
417
|
+
"""Create and return a floating inset axes.
|
|
418
|
+
|
|
419
|
+
The inset overlays the figure at the specified corner. Call
|
|
420
|
+
plot-factory methods on the returned :class:`InsetAxes` to attach
|
|
421
|
+
data::
|
|
422
|
+
|
|
423
|
+
inset = fig.add_inset(0.3, 0.25, corner="top-right", title="Zoom")
|
|
424
|
+
inset.imshow(data) # returns Plot2D
|
|
425
|
+
inset.plot(profile) # returns Plot1D
|
|
426
|
+
|
|
427
|
+
Parameters
|
|
428
|
+
----------
|
|
429
|
+
w_frac, h_frac : float
|
|
430
|
+
Width and height as fractions of the figure size (0–1).
|
|
431
|
+
corner : str, optional
|
|
432
|
+
Positioning corner: ``"top-right"`` (default), ``"top-left"``,
|
|
433
|
+
``"bottom-right"``, or ``"bottom-left"``.
|
|
434
|
+
title : str, optional
|
|
435
|
+
Text displayed in the inset title bar.
|
|
436
|
+
|
|
437
|
+
Returns
|
|
438
|
+
-------
|
|
439
|
+
InsetAxes
|
|
440
|
+
"""
|
|
441
|
+
return InsetAxes(self, w_frac, h_frac, corner=corner, title=title)
|
|
442
|
+
|
|
443
|
+
def _register_inset(self, inset_ax: "InsetAxes", plot) -> None:
|
|
444
|
+
"""Register an inset plot, allocating its trait and updating layout."""
|
|
445
|
+
pid = plot._id
|
|
446
|
+
if not self.has_trait(f"panel_{pid}_json"):
|
|
447
|
+
self.add_traits(**{f"panel_{pid}_json": traitlets.Unicode("{}").tag(sync=True)})
|
|
448
|
+
self._plots_map[pid] = plot
|
|
449
|
+
self._insets_map[pid] = inset_ax
|
|
450
|
+
self._push(pid)
|
|
451
|
+
self._push_layout()
|
|
452
|
+
|
|
453
|
+
@traitlets.observe("fig_width", "fig_height")
|
|
454
|
+
def _on_resize(self, change) -> None:
|
|
455
|
+
self._push_layout()
|
|
456
|
+
for pid in self._plots_map:
|
|
457
|
+
self._push(pid)
|
|
458
|
+
|
|
459
|
+
@traitlets.observe("event_json")
|
|
460
|
+
def _on_event(self, change) -> None:
|
|
461
|
+
"""Dispatch a JS interaction event to the relevant plot and widget callbacks."""
|
|
462
|
+
self._dispatch_event(change["new"])
|
|
463
|
+
|
|
464
|
+
def _dispatch_event(self, raw: str) -> None:
|
|
465
|
+
"""Process a raw JSON event string from the JS side.
|
|
466
|
+
|
|
467
|
+
Called by ``_on_event`` (traitlets observer) and also directly by the
|
|
468
|
+
Pyodide bridge (``anywidget_bridge.js``) when forwarding user interaction
|
|
469
|
+
events from the iframe back to Python callbacks.
|
|
470
|
+
|
|
471
|
+
Parameters
|
|
472
|
+
----------
|
|
473
|
+
raw : str
|
|
474
|
+
JSON-encoded event message. Expected keys: ``event_type``,
|
|
475
|
+
``panel_id``, and optionally ``source``, ``widget_id``, plus
|
|
476
|
+
any event-specific payload fields.
|
|
477
|
+
"""
|
|
478
|
+
if not raw or raw == "{}":
|
|
479
|
+
return
|
|
480
|
+
try:
|
|
481
|
+
msg = json.loads(raw)
|
|
482
|
+
except Exception:
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
if msg.get("source") == "python":
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
panel_id = msg.get("panel_id", "")
|
|
489
|
+
event_type = msg.get("event_type", "pointer_move")
|
|
490
|
+
widget_id = msg.get("widget_id")
|
|
491
|
+
|
|
492
|
+
# Inset state changes handled before regular plot dispatch
|
|
493
|
+
if event_type == "inset_state_change":
|
|
494
|
+
inset_ax = self._insets_map.get(panel_id)
|
|
495
|
+
if inset_ax is not None:
|
|
496
|
+
new_state = msg.get("new_state", "normal")
|
|
497
|
+
if new_state in ("normal", "minimized", "maximized"):
|
|
498
|
+
inset_ax._inset_state = new_state
|
|
499
|
+
self._push_layout()
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
plot = self._plots_map.get(panel_id)
|
|
503
|
+
if plot is None:
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
# GPU activation status echo (WebGPU path) — not a user event.
|
|
507
|
+
if event_type == "gpu_status":
|
|
508
|
+
if hasattr(plot, "_set_gpu_active"):
|
|
509
|
+
plot._set_gpu_active(bool(msg.get("gpu_active", False)))
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
source = None
|
|
513
|
+
if widget_id and hasattr(plot, "_widgets"):
|
|
514
|
+
widget = plot._widgets.get(widget_id)
|
|
515
|
+
if widget is not None:
|
|
516
|
+
widget._update_from_js(msg, event_type)
|
|
517
|
+
source = widget
|
|
518
|
+
|
|
519
|
+
if hasattr(plot, "callbacks"):
|
|
520
|
+
event = Event(
|
|
521
|
+
event_type=event_type,
|
|
522
|
+
source=source,
|
|
523
|
+
time_stamp=msg.get("time_stamp", time.perf_counter()),
|
|
524
|
+
modifiers=msg.get("modifiers", []),
|
|
525
|
+
x=msg.get("x"),
|
|
526
|
+
y=msg.get("y"),
|
|
527
|
+
button=msg.get("button"),
|
|
528
|
+
buttons=msg.get("buttons", 0),
|
|
529
|
+
xdata=msg.get("xdata"),
|
|
530
|
+
ydata=msg.get("ydata"),
|
|
531
|
+
ray=msg.get("ray"),
|
|
532
|
+
line_id=msg.get("line_id"),
|
|
533
|
+
dwell_ms=msg.get("dwell_ms"),
|
|
534
|
+
bar_index=msg.get("bar_index"),
|
|
535
|
+
value=msg.get("value"),
|
|
536
|
+
x_label=msg.get("x_label"),
|
|
537
|
+
group_index=msg.get("group_index"),
|
|
538
|
+
dx=msg.get("dx"),
|
|
539
|
+
dy=msg.get("dy"),
|
|
540
|
+
key=msg.get("key"),
|
|
541
|
+
last_widget_id=msg.get("last_widget_id"),
|
|
542
|
+
)
|
|
543
|
+
plot.callbacks.fire(event)
|
|
544
|
+
|
|
545
|
+
def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None:
|
|
546
|
+
"""Send a targeted widget-position update to JS (no image data)."""
|
|
547
|
+
payload = {"source": "python", "panel_id": panel_id,
|
|
548
|
+
"widget_id": widget_id}
|
|
549
|
+
payload.update(fields)
|
|
550
|
+
self.event_json = json.dumps(payload)
|
|
551
|
+
|
|
552
|
+
def _push_panel_fields(self, panel_id: str, fields: dict) -> None:
|
|
553
|
+
"""Apply a small set of changed *fields* to a panel, then push once.
|
|
554
|
+
|
|
555
|
+
The fields are merged into the panel's ``_state`` and the panel is
|
|
556
|
+
pushed via the normal trait channel (authoritative for Jupyter,
|
|
557
|
+
snapshots, and the Pyodide bridge alike). Inside a :meth:`batch`
|
|
558
|
+
block the push is coalesced, so many such field updates across many
|
|
559
|
+
panels collapse to one serialise + transfer per panel per frame —
|
|
560
|
+
the dominant per-frame cost over a comm boundary.
|
|
561
|
+
"""
|
|
562
|
+
plot = self._plots_map.get(panel_id)
|
|
563
|
+
if plot is not None:
|
|
564
|
+
plot._state.update(fields)
|
|
565
|
+
self._push(panel_id)
|
|
566
|
+
|
|
567
|
+
# ── helpers ───────────────────────────────────────────────────────────────
|
|
568
|
+
def get_axes(self) -> list:
|
|
569
|
+
"""Return a list of all Axes, sorted by grid position.
|
|
570
|
+
|
|
571
|
+
Returns
|
|
572
|
+
-------
|
|
573
|
+
list of Axes
|
|
574
|
+
Axes sorted by (row_start, col_start) to match typical left-to-right,
|
|
575
|
+
top-to-bottom iteration order.
|
|
576
|
+
"""
|
|
577
|
+
return sorted(self._axes_map.values(),
|
|
578
|
+
key=lambda a: (a._spec.row_start, a._spec.col_start))
|
|
579
|
+
|
|
580
|
+
def _repr_html_(self) -> str:
|
|
581
|
+
"""Return a self-contained iframe embedding the live widget.
|
|
582
|
+
|
|
583
|
+
Used by Sphinx Gallery (via :class:`~docs._sg_html_scraper.ViewerScraper`)
|
|
584
|
+
and by any HTML-capable notebook frontend that falls back to
|
|
585
|
+
``_repr_html_`` instead of the full ipywidgets protocol.
|
|
586
|
+
|
|
587
|
+
Returns
|
|
588
|
+
-------
|
|
589
|
+
str
|
|
590
|
+
HTML string containing an embedded iframe with srcdoc attribute.
|
|
591
|
+
"""
|
|
592
|
+
return repr_html_iframe(self)
|
|
593
|
+
|
|
594
|
+
def to_html(self, *, resizable: bool = True) -> str:
|
|
595
|
+
"""Return a self-contained HTML page rendering this figure.
|
|
596
|
+
|
|
597
|
+
The page inlines the JS renderer and all data — no Jupyter kernel or
|
|
598
|
+
network needed at view time. Load it in any browser context, e.g.
|
|
599
|
+
an Electron ``BrowserWindow`` or ``<webview>``. See
|
|
600
|
+
:mod:`anyplotlib.embed` for the full embedding guide (including live
|
|
601
|
+
Python sync via ``FigureBridge``).
|
|
602
|
+
"""
|
|
603
|
+
from anyplotlib.embed import to_html
|
|
604
|
+
return to_html(self, resizable=resizable)
|
|
605
|
+
|
|
606
|
+
def save_html(self, path, *, resizable: bool = True):
|
|
607
|
+
"""Write :meth:`to_html` output to *path*; returns the ``Path``."""
|
|
608
|
+
from anyplotlib.embed import save_html
|
|
609
|
+
return save_html(self, path, resizable=resizable)
|
|
610
|
+
|
|
611
|
+
def close(self) -> None:
|
|
612
|
+
"""Close the figure.
|
|
613
|
+
|
|
614
|
+
Fires a ``"close"`` event on every panel's :attr:`callbacks`, then
|
|
615
|
+
hides the widget by setting its CSS ``display`` to ``"none"``.
|
|
616
|
+
Subsequent calls are no-ops.
|
|
617
|
+
"""
|
|
618
|
+
if getattr(self, "_closed", False):
|
|
619
|
+
return
|
|
620
|
+
self._closed = True
|
|
621
|
+
close_event = Event(event_type="close")
|
|
622
|
+
for plot in self._plots_map.values():
|
|
623
|
+
if hasattr(plot, "callbacks"):
|
|
624
|
+
plot.callbacks.fire(close_event)
|
|
625
|
+
try:
|
|
626
|
+
self.layout.display = "none"
|
|
627
|
+
except Exception:
|
|
628
|
+
pass
|
|
629
|
+
|
|
630
|
+
def __repr__(self) -> str:
|
|
631
|
+
return (f"Figure({self._nrows}x{self._ncols}, "
|
|
632
|
+
f"panels={len(self._plots_map)}, "
|
|
633
|
+
f"size={self.fig_width}x{self.fig_height})")
|