Source code for anyplotlib.figure._figure
"""
figure/_figure.py
=================
Top-level Figure widget (the single anywidget.AnyWidget subclass).
"""
from __future__ import annotations
import contextlib
import json
import pathlib
import time
import anywidget
import traitlets
import uuid as _uuid
from anyplotlib.axes import Axes, InsetAxes
from anyplotlib.axes._inset_axes import _plot_kind
from anyplotlib.figure._gridspec import SubplotSpec
from anyplotlib.callbacks import CallbackRegistry, Event, _EventMixin
from anyplotlib._repr_utils import repr_html_iframe
# Recognised figure-level annotation kinds and the fields each carries (beyond
# ``id`` + ``kind``). Positions/sizes are all in FIGURE FRACTIONS (0..1, origin
# top-left), so a marker keeps its relative place across figure resizes.
_FIGURE_MARKER_KINDS = {"text", "circle", "rect", "arrow"}
_HERE = pathlib.Path(__file__).parent.parent
_ESM_SOURCE = (_HERE / "figure_esm.js").read_text(encoding="utf-8")
def _binary_wire() -> bool:
"""True when the Electron binary pixel transport is live, so a pixel
change-token in the panel state should stay a token (the real bytes ride
PLOTBIN) rather than being resolved to inline base64. Read fresh from the
environment — the same gate ``Plot2D._encode_pixels`` uses — so producer and
serializer never disagree."""
import os
return os.environ.get("APL_BINARY_TRANSPORT") == "1"
[docs]
class Figure(anywidget.AnyWidget, _EventMixin):
"""Multi-panel interactive figure widget.
The top-level container for all plots and the only ``anywidget.AnyWidget``
subclass in anyplotlib. It owns all traitlets and acts as the Python ↔
JavaScript bridge via the ``figure_esm.js`` canvas renderer.
Create via :func:`subplots` (recommended) or directly::
fig = Figure(2, 2, figsize=(800, 600))
ax = fig.add_subplot((0, 0))
v2d = ax.imshow(data)
Parameters
----------
nrows, ncols : int, optional
Grid dimensions. Default 1 row, 1 column.
figsize : (width, height), optional
Figure size in CSS pixels. Default ``(640, 480)``.
width_ratios : list of float, optional
Relative column widths. Length must equal *ncols*.
height_ratios : list of float, optional
Relative row heights. Length must equal *nrows*.
sharex, sharey : bool, optional
Link pan/zoom across all panels on the respective axis.
Default False (independent pan/zoom per panel).
See Also
--------
subplots : Recommended factory for creating Figure and Axes grid.
"""
layout_json = traitlets.Unicode("{}").tag(sync=True)
fig_width = traitlets.Int(640).tag(sync=True)
fig_height = traitlets.Int(480).tag(sync=True)
# Bidirectional JS event bus: JS writes interaction events here, Python reads them.
event_json = traitlets.Unicode("{}").tag(sync=True)
# When True the JS renderer shows a per-panel FPS / frame-time overlay.
display_stats = traitlets.Bool(False).tag(sync=True)
# Figure-level help text shown in a '?' badge overlay in JS.
# Empty string means no badge. Gated by apl.show_help at the Python level.
help_text = traitlets.Unicode("").tag(sync=True)
# ── Edit-mode chrome (Report Builder) ─────────────────────────────────────
# edit_chrome — when True the JS renderer shows per-panel hover outlines,
# makes figure-level markers hit-testable/draggable, and
# emits figure-background clicks. When False all of this
# is inert — normal interaction is untouched.
# selected_panel — a panel id (or "") that gets a persistent solid outline;
# all others clear. Pure JS-local DOM styling, no export.
edit_chrome = traitlets.Bool(False).tag(sync=True)
selected_panel = traitlets.Unicode("").tag(sync=True)
# Figure-level annotation layer: a JSON list of marker dicts positioned in
# FIGURE FRACTIONS, drawn over all panels. See set_figure_markers.
figure_markers_json = traitlets.Unicode("[]").tag(sync=True)
_esm = _ESM_SOURCE
# Static CSS injected by anywidget alongside _esm.
# .apl-scale-wrap — outer container; width:100% means it always fills
# the cell without any JS width updates.
# .apl-outer — the figure root; will-change:transform pre-promotes
# it to a GPU compositing layer so transform:scale()
# changes cost zero layout/paint passes.
_css = """\
.apl-scale-wrap {
display: block;
width: 100%;
overflow: visible;
position: relative;
line-height: 0;
}
.apl-outer {
display: inline-block;
position: relative;
user-select: none;
z-index: 1;
isolation: isolate;
will-change: transform;
transform-origin: top left;
vertical-align: top;
/* min-width: max-content prevents the inline-block from shrinking when
the parent container (scaleWrap, width:100%) narrows because the
Jupyter cell is narrower than the figure's native width. Without
this, outerDiv.offsetWidth collapses to cellW, causing _applyScale()
to compute s = cellW/cellW = 1.0 (no-op) instead of the correct
s = cellW/nativeW < 1. */
min-width: max-content;
}
"""
def __init__(self, nrows=1, ncols=1, figsize=(640, 480),
width_ratios=None, height_ratios=None,
sharex=False, sharey=False,
display_stats=False, help="", **kwargs):
super().__init__(**kwargs)
self._nrows = nrows
self._ncols = ncols
self._width_ratios = list(width_ratios) if width_ratios else [1] * ncols
self._height_ratios = list(height_ratios) if height_ratios else [1] * nrows
self._sharex = sharex
self._sharey = sharey
self._axes_map: dict = {}
self._plots_map: dict = {}
self._insets_map: dict = {}
self._hspace: float | None = None
self._wspace: float | None = None
self._batching: bool = False
self._batch_dirty: set = set()
# Geometry-channel bookkeeping (per panel id): a monotonic revision
# and the last geometry dict sent, so geometry is re-transmitted only
# when its values genuinely change.
self._geom_rev: dict = {}
self._geom_last: dict = {}
# Raw pixel side-table for the Electron BINARY transport: maps
# ``(panel_id, pixel_key)`` → the raw ``bytes`` of the current frame.
# When binary transport is active, ``Plot2D.set_data`` stashes the
# uint8 image bytes here (and puts a tiny change-token in the geom
# field instead of a 5.6 MB base64 string), so ``_electron._route_change``
# ships them straight to a PLOTBIN frame with NO base64 encode/decode
# and NO megabyte JSON. Empty (and ignored) on every non-Electron path.
self._raw_pixels: dict = {}
# Figure-level (not per-panel) callback registry + the _EventMixin API
# (add_event_handler / remove_handler / pause_events / hold_events).
# Fired for figure-background clicks and figure-marker pointer events.
self.callbacks: CallbackRegistry = CallbackRegistry()
# Authoritative Python-side copy of the figure-level annotation list.
self._figure_markers: list = []
with self.hold_trait_notifications():
self.fig_width = figsize[0]
self.fig_height = figsize[1]
self.display_stats = display_stats
self.help_text = self._resolve_help(help)
self._push_layout()
@staticmethod
def _resolve_help(text: str) -> str:
"""Return *text* if ``apl.show_help`` is True (default), else ``""``."""
try:
import anyplotlib as _apl
if not getattr(_apl, "show_help", True):
return ""
except ImportError:
pass
return text or ""
[docs]
def set_help(self, text: str) -> None:
"""Set (or clear) the figure-level help text shown in the **?** badge.
Parameters
----------
text : str
Help string displayed when the user clicks the **?** badge.
Pass an empty string (or ``""`` ) to remove the badge entirely.
Newlines (``\\n``) are respected in the card.
Examples
--------
>>> fig.set_help("Drag peak: move μ/A\\nPress f: least-squares fit")
>>> fig.set_help("") # hide the badge
"""
self.help_text = self._resolve_help(text)
[docs]
def subplots_adjust(self, hspace: float | None = None,
wspace: float | None = None) -> None:
"""Set the spacing between subplot panels.
Only the arguments that are explicitly provided are updated; omitting
an argument leaves the current value unchanged.
Parameters
----------
hspace : float, optional
Fraction of the average row height to use as vertical gap between
panels. ``0.1`` adds a gap of 10 % of the mean row height.
``None`` (default) leaves the current hspace unchanged.
wspace : float, optional
Fraction of the average column width to use as horizontal gap.
``None`` (default) leaves the current wspace unchanged.
"""
if hspace is not None:
self._hspace = float(hspace)
if wspace is not None:
self._wspace = float(wspace)
self._push_layout()
# ── subplot creation ──────────────────────────────────────────────────────
[docs]
def add_subplot(self, spec) -> Axes:
"""Add a subplot cell and return its :class:`Axes`.
Parameters
----------
spec : SubplotSpec or int or tuple of (row, col)
Which grid cell(s) to occupy. A :class:`SubplotSpec` is used
directly (e.g. from ``GridSpec[r, c]``). An :class:`int` is
converted via ``divmod(spec, ncols)``, matching
``matplotlib.Figure.add_subplot`` numbering. A ``(row, col)``
tuple selects a single cell.
Returns
-------
Axes
The subplot axes object. Call plotting methods like ``.imshow()``,
``.plot()``, ``.bar()`` to attach data.
Raises
------
TypeError
If *spec* is not a SubplotSpec, int, or tuple.
Examples
--------
>>> fig = Figure(2, 2)
>>> ax1 = fig.add_subplot(0) # top-left (via numbering)
>>> ax2 = fig.add_subplot((0, 1)) # top-right (via tuple)
"""
if isinstance(spec, SubplotSpec):
# Auto-sync Figure grid to the parent GridSpec when the GridSpec is
# larger than the Figure's current dimensions. This allows the
# common workflow:
# gs = GridSpec(2, 2, height_ratios=[3, 1])
# fig = Figure(figsize=(...)) # defaults to nrows=1, ncols=1
# fig.add_subplot(gs[0, :]) # Figure adopts 2×2 from GridSpec
# without requiring the user to repeat nrows/ncols/ratios on Figure.
gs = spec._gs
if gs is not None:
if gs.nrows > self._nrows:
self._nrows = gs.nrows
self._height_ratios = list(gs.height_ratios)
if gs.ncols > self._ncols:
self._ncols = gs.ncols
self._width_ratios = list(gs.width_ratios)
elif isinstance(spec, int):
row, col = divmod(spec, self._ncols)
spec = SubplotSpec(None, row, row + 1, col, col + 1)
elif isinstance(spec, tuple):
row, col = spec
spec = SubplotSpec(None, row, row + 1, col, col + 1)
else:
raise TypeError(f"add_subplot: unsupported spec type {type(spec)!r}")
return Axes(self, spec)
# ── internal registration (called by Axes._attach) ────────────────────────
def _register_panel(self, ax: Axes, plot) -> None:
pid = plot._id
if not self.has_trait(f"panel_{pid}_json"):
self.add_traits(**{f"panel_{pid}_json": traitlets.Unicode("{}").tag(sync=True)})
# Plots that declare _GEOM_KEYS get a second trait carrying only the
# heavy geometry, re-sent only when that geometry changes. The light
# view trait then references it by revision so JS reuses the cached
# decode across view-only updates (highlight, camera, planes).
if getattr(plot, "_GEOM_KEYS", None) and not self.has_trait(f"panel_{pid}_geom"):
self.add_traits(**{f"panel_{pid}_geom": traitlets.Unicode("{}").tag(sync=True)})
self._geom_rev[pid] = 0
self._geom_last[pid] = None
self._plots_map[pid] = plot
self._axes_map[pid] = ax
self._push(pid)
self._push_layout()
def _push(self, panel_id: str, *, resolve_pixels: bool = False) -> None:
"""Serialise one panel and write to its trait.
Inside a :meth:`batch` block, pushes are coalesced: each panel is
recorded as dirty and serialised + sent exactly once when the block
exits, no matter how many mutations touched it. This collapses the
many per-frame pushes of a linked-view update (set_data + set_title +
widget moves on the same panel) into one serialise/transfer per panel
— the dominant cost over a Pyodide comm boundary.
Parameters
----------
resolve_pixels : bool, optional
Force pixel change-tokens to be materialised to inline base64 even
when the binary transport is live. Set by :meth:`_sync_for_export`
because a snapshot has no PLOTBIN channel to carry the bytes; see
the token discussion below.
"""
plot = self._plots_map.get(panel_id)
if plot is None:
return
if self._batching:
self._batch_dirty.add(panel_id)
return
tname = f"panel_{panel_id}_json"
if not self.has_trait(tname):
return
state = plot.to_state_dict()
geom_keys = getattr(plot, "_GEOM_KEYS", None)
gname = f"panel_{panel_id}_geom"
# ``state`` may carry a ``"\x00bin:…"`` pixel change-token (binary
# transport: the real bytes ride PLOTBIN via ``_route_change``). Keep
# the token when that channel is live; otherwise — a standalone /
# save_html / Jupyter figure with no binary channel — materialise the
# real base64 inline so the pixels actually travel. ``_binary_wire``
# matches the producer's gate (``Plot2D._encode_pixels``).
#
# ``resolve_pixels`` overrides that gate for an EXPORT. A snapshot is by
# definition off-wire: whatever PLOTBIN would have delivered never
# arrives, so a token left in the state is a dangling reference and the
# pixels are simply lost. The gate reads a process-global env var, which
# is on in a host app even while THIS push is serialising a snapshot —
# so the export has to say so explicitly.
if (geom_keys and (resolve_pixels or not _binary_wire())
and hasattr(plot, "resolve_pixel_tokens")):
plot.resolve_pixel_tokens(state)
if geom_keys and self.has_trait(gname):
# Split heavy geometry into its own channel. Detect change by
# comparing the geom values themselves (the b64 strings / LUT
# lists) against the last-sent snapshot — a reference/equality
# check that avoids re-serialising hundreds of KB on every
# view-only frame. Only on a real change do we serialise the
# geom blob, bump the revision, and write the geom trait.
geom = {k: state.pop(k) for k in geom_keys if k in state}
if geom != self._geom_last.get(panel_id):
self._geom_last[panel_id] = geom
self._geom_rev[panel_id] = self._geom_rev.get(panel_id, 0) + 1
setattr(self, gname, json.dumps(geom, sort_keys=True))
state["_geom_rev"] = self._geom_rev.get(panel_id, 0)
setattr(self, tname, json.dumps(state))
else:
setattr(self, tname, json.dumps(state))
[docs]
@contextlib.contextmanager
def batch(self):
"""Coalesce all panel pushes within the block into one push per panel.
Use around multi-panel updates (e.g. a linked-view crosshair handler)
so a single mouse event produces one serialise + transfer per panel
instead of one per mutation — a large win under Pyodide / remote
kernels where every push crosses a comm boundary.
::
with fig.batch():
v_xz.set_data(slice_xz)
v_yz.set_data(slice_yz)
cross.set(cx=..., cy=...)
"""
if self._batching: # already batching — nest transparently
yield
return
self._batching = True
self._batch_dirty = set()
try:
yield
finally:
self._batching = False
dirty, self._batch_dirty = self._batch_dirty, set()
# One push per dirty panel — no matter how many mutations touched
# it during the block. hold_trait_notifications coalesces the
# underlying comm traffic into a single sync.
with self.hold_trait_notifications():
for pid in dirty:
self._push(pid)
# ── layout ────────────────────────────────────────────────────────────────
def _compute_cell_sizes(self) -> dict:
fw, fh = self.fig_width, self.fig_height
wr, hr = self._width_ratios, self._height_ratios
wsum, hsum = sum(wr), sum(hr)
# Grid tracks are pure ratio math — no aspect-locking.
# Rule: col_px[i] = fw * width_ratios[i] / Σ width_ratios (and analogous
# for rows). Every panel gets exactly the canvas size its cell specifies;
# images are rendered "contain" (letterboxed) in JS if needed.
col_px = [fw * w / wsum for w in wr]
row_px = [fh * h / hsum for h in hr]
sizes: dict = {}
for pid, ax in self._axes_map.items():
s = ax._spec
pw = int(round(sum(col_px[s.col_start:s.col_stop])))
ph = int(round(sum(row_px[s.row_start:s.row_stop])))
sizes[pid] = (max(64, pw), max(64, ph))
return sizes
def _push_layout(self) -> None:
cell_sizes = self._compute_cell_sizes()
all_ids = list(self._axes_map.keys())
share_groups: dict = {}
def _mg(flag, key):
if flag is True and len(all_ids) > 1:
share_groups[key] = [list(all_ids)]
elif isinstance(flag, list):
share_groups[key] = flag
_mg(self._sharex, "x")
_mg(self._sharey, "y")
panel_specs = []
for pid, ax in self._axes_map.items():
s = ax._spec
pw, ph = cell_sizes.get(pid, (200, 200))
plot = self._plots_map.get(pid)
panel_specs.append({
"id": pid,
"kind": _plot_kind(plot) if plot else "1d",
"row_start": s.row_start,
"row_stop": s.row_stop,
"col_start": s.col_start,
"col_stop": s.col_stop,
"panel_width": pw,
"panel_height": ph,
})
inset_specs = []
indications = []
for pid, inset_ax in self._insets_map.items():
plot = self._plots_map.get(pid)
pw = max(64, round(self.fig_width * inset_ax.w_frac))
ph = max(64, round(self.fig_height * inset_ax.h_frac))
inset_specs.append({
"id": pid,
"kind": _plot_kind(plot) if plot else "1d",
"w_frac": inset_ax.w_frac,
"h_frac": inset_ax.h_frac,
# corner is None for anchor-placed insets; anchor is None for
# corner-placed ones. JS picks the placement mode by which is set.
"corner": inset_ax.corner,
"anchor": list(inset_ax.anchor)
if getattr(inset_ax, "anchor", None) is not None
else None,
"title": inset_ax.title,
"panel_width": pw,
"panel_height": ph,
"inset_state": inset_ax._inset_state,
})
# Region indication (mark_inset callout) is keyed by the inset id.
ind = getattr(inset_ax, "_indication", None)
if ind is not None:
indications.append({"inset_id": pid, **ind})
self.layout_json = json.dumps({
"nrows": self._nrows,
"ncols": self._ncols,
"width_ratios": self._width_ratios,
"height_ratios": self._height_ratios,
"fig_width": self.fig_width,
"fig_height": self.fig_height,
"panel_specs": panel_specs,
"share_groups": share_groups,
"inset_specs": inset_specs,
"indications": indications,
"hspace": self._hspace,
"wspace": self._wspace,
})
# ── inset creation ────────────────────────────────────────────────────────
[docs]
def add_inset(self, w_frac: float, h_frac: float, *,
corner: str = "top-right", anchor=None,
title: str = "") -> "InsetAxes":
"""Create and return a floating inset axes.
The inset overlays the figure at the specified corner (default) or at
an arbitrary *anchor* position. Call plot-factory methods on the
returned :class:`InsetAxes` to attach data::
inset = fig.add_inset(0.3, 0.25, corner="top-right", title="Zoom")
inset.imshow(data) # returns Plot2D
inset.plot(profile) # returns Plot1D
# arbitrary placement (top-left corner at 55% across, 10% down):
free = fig.add_inset(0.3, 0.25, anchor=(0.55, 0.10))
free.imshow(data)
Parameters
----------
w_frac, h_frac : float
Width and height as fractions of the figure size (0–1).
corner : str, optional
Positioning corner: ``"top-right"`` (default), ``"top-left"``,
``"bottom-right"``, or ``"bottom-left"``. Mutually exclusive with
*anchor*.
anchor : (x_frac, y_frac), optional
Position of the inset's TOP-LEFT corner as fractions of the figure
size (0–1), from the figure's top-left. When given, the inset
floats at that anchor and *corner* is ignored. Use this for
free placement (e.g. a callout panel next to the region it marks).
title : str, optional
Text displayed in the inset title bar.
Returns
-------
InsetAxes
"""
return InsetAxes(self, w_frac, h_frac, corner=corner,
anchor=anchor, title=title)
def _register_inset(self, inset_ax: "InsetAxes", plot) -> None:
"""Register an inset plot, allocating its trait and updating layout."""
pid = plot._id
if not self.has_trait(f"panel_{pid}_json"):
self.add_traits(**{f"panel_{pid}_json": traitlets.Unicode("{}").tag(sync=True)})
self._plots_map[pid] = plot
self._insets_map[pid] = inset_ax
self._push(pid)
self._push_layout()
@traitlets.observe("fig_width", "fig_height")
def _on_resize(self, change) -> None:
self._push_layout()
for pid in self._plots_map:
self._push(pid)
@traitlets.observe("event_json")
def _on_event(self, change) -> None:
"""Dispatch a JS interaction event to the relevant plot and widget callbacks."""
self._dispatch_event(change["new"])
def _dispatch_event(self, raw: str) -> None:
"""Process a raw JSON event string from the JS side.
Called by ``_on_event`` (traitlets observer) and also directly by the
Pyodide bridge (``anywidget_bridge.js``) when forwarding user interaction
events from the iframe back to Python callbacks.
Parameters
----------
raw : str
JSON-encoded event message. Expected keys: ``event_type``,
``panel_id``, and optionally ``source``, ``widget_id``, plus
any event-specific payload fields.
"""
if not raw or raw == "{}":
return
try:
msg = json.loads(raw)
except Exception:
return
if msg.get("source") == "python":
return
panel_id = msg.get("panel_id", "")
event_type = msg.get("event_type", "pointer_move")
widget_id = msg.get("widget_id")
# ── Figure-level events (edit mode) — handled BEFORE per-panel lookup ──
# A click on the bare figure background (no panel underneath).
if msg.get("figure_background"):
self._fire_figure_event(event_type, msg)
return
# A drag/click on a figure-level annotation marker. On pointer_up the
# JS ships the marker's updated FRACTION fields; merge them into the
# stored list so Python state converges, then fire figure callbacks.
if msg.get("figure_marker"):
self._apply_figure_marker_event(msg)
self._fire_figure_event(event_type, msg)
return
# A panel drag-swap: the user dragged one panel's move-grip and released
# over a DIFFERENT panel. anyplotlib performs NO layout change itself —
# it only fires a figure-level event carrying the two panel dispatch ids
# so the host (e.g. SpyDE) can swap + rebuild.
if msg.get("panel_swap"):
self._fire_figure_event(event_type, msg)
return
# Inset state changes handled before regular plot dispatch
if event_type == "inset_state_change":
inset_ax = self._insets_map.get(panel_id)
if inset_ax is not None:
new_state = msg.get("new_state", "normal")
if new_state in ("normal", "minimized", "maximized"):
inset_ax._inset_state = new_state
self._push_layout()
return
# Inset drag / resize: the JS ships the inset's final geometry on
# pointer_up (anchor top-left fraction + size fractions). Apply it to
# the InsetAxes via set_geometry so the authoritative Python state
# converges (the event fires once, on release, so re-pushing layout is
# an idempotent echo), then fire the figure-level callbacks with the
# geometry so a host can persist it. Mirrors inset_state_change above.
if event_type == "inset_geometry_change":
inset_ax = self._insets_map.get(panel_id)
if inset_ax is not None:
anchor = msg.get("anchor")
w_frac = msg.get("w_frac")
h_frac = msg.get("h_frac")
inset_ax.set_geometry(anchor=anchor, w_frac=w_frac,
h_frac=h_frac)
self._fire_figure_event(event_type, msg)
return
plot = self._plots_map.get(panel_id)
if plot is None:
if event_type == "view_changed":
import logging
# WARNING so SpyDE's log stream forwards it (it drops non-spyde.* INFO).
logging.getLogger("anyplotlib.tile").warning(
"[TILE] view_changed for UNKNOWN panel %r (have %r) — dropped",
panel_id, list(self._plots_map.keys()))
return
if event_type == "view_changed":
import logging
logging.getLogger("anyplotlib.tile").warning(
"[TILE] view_changed RECEIVED panel=%s zoom=%s center=(%s,%s) disp=(%s,%s)",
panel_id, msg.get("zoom"), msg.get("center_x"), msg.get("center_y"),
msg.get("display_width"), msg.get("display_height"))
# GPU activation status echo (WebGPU path) — not a user event.
if event_type == "gpu_status":
if hasattr(plot, "_set_gpu_active"):
plot._set_gpu_active(bool(msg.get("gpu_active", False)))
return
source = None
if widget_id and hasattr(plot, "_widgets"):
widget = plot._widgets.get(widget_id)
if widget is not None:
widget._update_from_js(msg, event_type)
source = widget
if hasattr(plot, "callbacks"):
event = Event(
event_type=event_type,
source=source,
time_stamp=msg.get("time_stamp", time.perf_counter()),
modifiers=msg.get("modifiers", []),
x=msg.get("x"),
y=msg.get("y"),
button=msg.get("button"),
buttons=msg.get("buttons", 0),
xdata=msg.get("xdata"),
ydata=msg.get("ydata"),
ray=msg.get("ray"),
line_id=msg.get("line_id"),
dwell_ms=msg.get("dwell_ms"),
bar_index=msg.get("bar_index"),
value=msg.get("value"),
x_label=msg.get("x_label"),
group_index=msg.get("group_index"),
dx=msg.get("dx"),
dy=msg.get("dy"),
zoom=msg.get("zoom"),
center_x=msg.get("center_x"),
center_y=msg.get("center_y"),
image_width=msg.get("image_width"),
image_height=msg.get("image_height"),
display_width=msg.get("display_width"),
display_height=msg.get("display_height"),
azimuth=msg.get("azimuth"),
elevation=msg.get("elevation"),
key=msg.get("key"),
last_widget_id=msg.get("last_widget_id"),
target=msg.get("target"),
)
plot.callbacks.fire(event)
# ── figure-level annotation layer ─────────────────────────────────────────
def _fire_figure_event(self, event_type: str, msg: dict) -> None:
"""Fire the FIGURE-level callback registry with a flat Event.
Used for figure-background clicks, figure-marker pointer events, and
panel drag-swaps — events that belong to the figure as a whole, not to
any one panel. The marker id (if any) rides in ``last_widget_id`` so a
host can tell which annotation moved; a panel_swap carries the two panel
dispatch ids in ``source_panel_id`` / ``target_panel_id``.
"""
event = Event(
event_type=event_type,
source=self,
time_stamp=msg.get("time_stamp", time.perf_counter()),
modifiers=msg.get("modifiers", []),
x=msg.get("x"),
y=msg.get("y"),
button=msg.get("button"),
buttons=msg.get("buttons", 0),
xdata=msg.get("xdata"),
ydata=msg.get("ydata"),
last_widget_id=msg.get("marker_id"),
source_panel_id=msg.get("source_panel_id"),
target_panel_id=msg.get("target_panel_id"),
# Inset drag / resize geometry — populated only for the
# inset_geometry_change event; None for every other figure-level
# event (figure_background click, figure-marker drag, panel_swap).
inset_id=(msg.get("panel_id")
if event_type == "inset_geometry_change" else None),
anchor=(msg.get("anchor")
if event_type == "inset_geometry_change" else None),
w_frac=(msg.get("w_frac")
if event_type == "inset_geometry_change" else None),
h_frac=(msg.get("h_frac")
if event_type == "inset_geometry_change" else None),
)
self.callbacks.fire(event)
def _apply_figure_marker_event(self, msg: dict) -> None:
"""Merge a figure-marker drag's updated FRACTION fields into the stored
marker list (matched by ``marker_id``) and re-sync the trait.
The JS side already wrote ``figure_markers_json`` back on mouseup, but
we converge Python's authoritative ``_figure_markers`` here too so a
host reading ``fig.figure_markers`` inside its callback sees the new
position immediately (and the two never drift)."""
marker_id = msg.get("marker_id")
if marker_id is None:
return
# Fraction fields the JS emits per kind.
_pos_keys = ("x", "y", "u", "v", "r", "w", "h")
for m in self._figure_markers:
if m.get("id") == marker_id:
for k in _pos_keys:
if k in msg:
m[k] = msg[k]
break
# Re-sync the trait from the authoritative list (source:"python" is not
# relevant here — figure_markers_json is a plain state trait, not the
# event bus, so this does not echo back through _dispatch_event).
self.figure_markers_json = json.dumps(self._figure_markers)
[docs]
def set_figure_markers(self, markers: list) -> None:
"""Set the figure-level annotation layer.
Parameters
----------
markers : list of dict
Each dict is ``{"id"?, "kind", ...}`` with positions/sizes in
FIGURE FRACTIONS (0..1, origin top-left). ``kind`` is one of:
- ``"text"`` — ``x, y, text``; optional ``color``, ``fontsize``
- ``"circle"`` — ``x, y, r`` (``r`` as a fraction of
``min(fig_width, fig_height)``); optional ``color``, ``linewidth``
- ``"rect"`` — ``x, y`` (centre), ``w, h``; optional ``color``,
``linewidth``
- ``"arrow"`` — ``x, y`` (tail), ``u, v`` (vector); optional
``color``, ``linewidth``
Any dict missing an ``id`` is assigned a fresh one.
Raises
------
ValueError
If a marker has an unrecognised ``kind``.
"""
out = []
for m in markers:
m = dict(m)
kind = m.get("kind")
if kind not in _FIGURE_MARKER_KINDS:
raise ValueError(
f"figure marker kind must be one of "
f"{sorted(_FIGURE_MARKER_KINDS)}, got {kind!r}")
if not m.get("id"):
m["id"] = str(_uuid.uuid4())[:8]
out.append(m)
self._figure_markers = out
self.figure_markers_json = json.dumps(out)
@property
def figure_markers(self) -> list:
"""The current figure-level annotation list (list of dicts, fractions).
Returns a shallow copy so external mutation doesn't desync the trait;
call :meth:`set_figure_markers` to change it."""
return [dict(m) for m in self._figure_markers]
def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None:
"""Send a targeted widget-position update to JS (no image data).
This writes ``event_json`` only — deliberately, because re-serialising
a whole panel (image bytes included) on every drag frame is exactly
what this path exists to avoid. The consequence is that
``panel_<id>_json`` carries stale widget geometry between plot-level
pushes; :meth:`_sync_for_export` reconciles it before any snapshot.
"""
payload = {"source": "python", "panel_id": panel_id,
"widget_id": widget_id}
payload.update(fields)
self.event_json = json.dumps(payload)
def _sync_for_export(self) -> None:
"""Refresh every panel trait so a snapshot sees current widget state.
Widget moves reach JS as targeted ``event_json`` updates that never
touch the panel traits (see :meth:`_push_widget`), so ``save_html`` /
``to_html`` / ``figure_state`` would otherwise capture every widget at
the position it was *created* at rather than where it now is. Python
holds the authoritative geometry — a JS-side drag writes back through
``_dispatch_event`` — so re-pushing each panel is enough to reconcile.
Called from ``_repr_utils._widget_state``, the one chokepoint every
export path goes through. The live Jupyter path deliberately keeps
using targeted pushes and does not pay this cost.
The re-push passes ``resolve_pixels=True``: this rewrites the panel
traits, and under a live binary transport an ordinary push leaves pixel
change-tokens in place for PLOTBIN to fill in. A snapshot has no
PLOTBIN, so those tokens would be dangling — and because the re-push
rewrites ``panel_<id>_json`` unconditionally it would also undo any
materialisation the caller did beforehand. An export always ships real
pixels.
"""
for panel_id in list(self._plots_map):
self._push(panel_id, resolve_pixels=True)
def _push_panel_fields(self, panel_id: str, fields: dict) -> None:
"""Apply a small set of changed *fields* to a panel, then push once.
The fields are merged into the panel's ``_state`` and the panel is
pushed via the normal trait channel (authoritative for Jupyter,
snapshots, and the Pyodide bridge alike). Inside a :meth:`batch`
block the push is coalesced, so many such field updates across many
panels collapse to one serialise + transfer per panel per frame —
the dominant per-frame cost over a comm boundary.
"""
plot = self._plots_map.get(panel_id)
if plot is not None:
plot._state.update(fields)
self._push(panel_id)
# ── helpers ───────────────────────────────────────────────────────────────
[docs]
def get_axes(self) -> list:
"""Return a list of all Axes, sorted by grid position.
Returns
-------
list of Axes
Axes sorted by (row_start, col_start) to match typical left-to-right,
top-to-bottom iteration order.
"""
return sorted(self._axes_map.values(),
key=lambda a: (a._spec.row_start, a._spec.col_start))
def _repr_html_(self) -> str:
"""Return a self-contained iframe embedding the live widget.
Used by Sphinx Gallery (via :class:`~docs._sg_html_scraper.ViewerScraper`)
and by any HTML-capable notebook frontend that falls back to
``_repr_html_`` instead of the full ipywidgets protocol.
Returns
-------
str
HTML string containing an embedded iframe with srcdoc attribute.
"""
return repr_html_iframe(self)
[docs]
def to_html(self, *, resizable: bool = True) -> str:
"""Return a self-contained HTML page rendering this figure.
The page inlines the JS renderer and all data — no Jupyter kernel or
network needed at view time. Load it in any browser context, e.g.
an Electron ``BrowserWindow`` or ``<webview>``. See
:mod:`anyplotlib.embed` for the full embedding guide (including live
Python sync via ``FigureBridge``).
"""
from anyplotlib.embed import to_html
return to_html(self, resizable=resizable)
[docs]
def save_html(self, path, *, resizable: bool = True):
"""Write :meth:`to_html` output to *path*; returns the ``Path``."""
from anyplotlib.embed import save_html
return save_html(self, path, resizable=resizable)
[docs]
def close(self) -> None:
"""Close the figure.
Fires a ``"close"`` event on every panel's :attr:`callbacks`, then
hides the widget by setting its CSS ``display`` to ``"none"``.
Subsequent calls are no-ops.
"""
if getattr(self, "_closed", False):
return
self._closed = True
close_event = Event(event_type="close")
for plot in self._plots_map.values():
if hasattr(plot, "callbacks"):
plot.callbacks.fire(close_event)
try:
self.layout.display = "none"
except Exception:
pass
def __repr__(self) -> str:
return (f"Figure({self._nrows}x{self._ncols}, "
f"panels={len(self._plots_map)}, "
f"size={self.fig_width}x{self.fig_height})")