anyplotlib 0.1.0b1__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.
Files changed (42) hide show
  1. anyplotlib/__init__.py +53 -0
  2. anyplotlib/_base_plot.py +229 -0
  3. anyplotlib/_electron.py +74 -0
  4. anyplotlib/_repr_utils.py +345 -0
  5. anyplotlib/_utils.py +194 -0
  6. anyplotlib/axes/__init__.py +6 -0
  7. anyplotlib/axes/_axes.py +510 -0
  8. anyplotlib/axes/_inset_axes.py +126 -0
  9. anyplotlib/callbacks.py +328 -0
  10. anyplotlib/embed.py +194 -0
  11. anyplotlib/figure/__init__.py +7 -0
  12. anyplotlib/figure/_figure.py +633 -0
  13. anyplotlib/figure/_gridspec.py +99 -0
  14. anyplotlib/figure/_subplots.py +100 -0
  15. anyplotlib/figure_esm.js +6011 -0
  16. anyplotlib/markers.py +704 -0
  17. anyplotlib/plot1d/__init__.py +6 -0
  18. anyplotlib/plot1d/_plot1d.py +1376 -0
  19. anyplotlib/plot1d/_plotbar.py +436 -0
  20. anyplotlib/plot2d/__init__.py +5 -0
  21. anyplotlib/plot2d/_plot2d.py +726 -0
  22. anyplotlib/plot2d/_plotmesh.py +116 -0
  23. anyplotlib/plot3d/__init__.py +4 -0
  24. anyplotlib/plot3d/_plot3d.py +524 -0
  25. anyplotlib/plotxy/__init__.py +4 -0
  26. anyplotlib/plotxy/_plotxy.py +273 -0
  27. anyplotlib/sphinx_anywidget/__init__.py +177 -0
  28. anyplotlib/sphinx_anywidget/_directive.py +245 -0
  29. anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
  30. anyplotlib/sphinx_anywidget/_scraper.py +390 -0
  31. anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
  32. anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
  33. anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
  34. anyplotlib/widgets/__init__.py +18 -0
  35. anyplotlib/widgets/_base.py +218 -0
  36. anyplotlib/widgets/_widgets1d.py +109 -0
  37. anyplotlib/widgets/_widgets2d.py +141 -0
  38. anyplotlib/widgets/_widgets3d.py +50 -0
  39. anyplotlib-0.1.0b1.dist-info/METADATA +160 -0
  40. anyplotlib-0.1.0b1.dist-info/RECORD +42 -0
  41. anyplotlib-0.1.0b1.dist-info/WHEEL +4 -0
  42. anyplotlib-0.1.0b1.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})")