Source code for anyplotlib.embed

"""
embed.py
========

Use anyplotlib figures **outside Jupyter** — in Electron apps, MDI
sub-windows, kiosk dashboards, or any plain web page.  No kernel, no
ipywidgets, no anywidget runtime in the page.

Three levels of integration
---------------------------

**1. Static / self-contained (no Python at runtime)** — export a fully
self-contained HTML page (renderer + data inlined) and load it anywhere a
browser engine runs, e.g. an Electron ``BrowserWindow`` or ``<webview>``::

    import anyplotlib as apl
    fig, ax = apl.subplots(1, 1)
    ax.imshow(data)
    fig.save_html("plot.html")          # win.loadFile('plot.html')

All client-side interactivity (pan, zoom, widgets, markers) works; Python
callbacks obviously do not.

**2. JS-driven (your app owns the data)** — ship ``figure_esm.js`` with your
app and mount figures from JavaScript using the exported ``mount()``::

    import { mount } from './figure_esm.js';
    const handle = mount(container, state, { onEvent: ev => ... });
    handle.setPanelState(panelId, newPanelState);   // live updates
    handle.resize(w, h);  handle.dispose();

``state`` is the JSON dict produced by :func:`figure_state` — generate it
once from Python (build time, or a one-shot script) or construct it in JS.
Each ``mount()`` is fully self-contained, so one window can host many
figures (MDI-style) by mounting into separate containers.

**3. Live Python backend** — run Python alongside your app (sidecar process,
local WebSocket server, …) and keep figures fully interactive with Python
callbacks via :class:`FigureBridge`, which is transport-agnostic::

    # Python side (e.g. behind a websocket)
    bridge = FigureBridge(fig, send=lambda key, value: ws.send(
        json.dumps({"key": key, "value": value})))
    ws.on_message = lambda m: bridge.receive(**json.loads(m))

    // JS side
    const handle = mount(el, snapshot, {
      onSync: (key, value) => ws.send(JSON.stringify({key, value})),
    });
    ws.onmessage = (m) => { const u = JSON.parse(m.data);
                            handle.applyUpdate(u.key, u.value); };

See ``docs/embedding.rst`` for a complete Electron walkthrough.
"""

from __future__ import annotations

import pathlib

from anyplotlib._repr_utils import build_standalone_html, _widget_state

__all__ = ["figure_state", "to_html", "save_html", "esm_path", "FigureBridge"]


[docs] def figure_state(fig) -> dict: """Return the figure's full serialised state as a plain JSON-safe dict. The dict contains every synced trait — ``layout_json``, ``fig_width``, ``fig_height``, ``event_json``, and one ``panel_<id>_json`` entry per panel — and is exactly what the JS ``mount(el, state)`` entry point expects. Parameters ---------- fig : Figure Returns ------- dict """ # _widget_state also picks up ipywidgets infrastructure traits (layout, # tabbable, …) whose values aren't JSON. The renderer only reads scalar # traits, so keep exactly those. return {k: v for k, v in _widget_state(fig).items() if isinstance(v, (str, int, float, bool)) or v is None}
[docs] def to_html(fig, *, resizable: bool = True) -> str: """Return a fully self-contained HTML page rendering *fig*. The page inlines the renderer and all figure data; it needs no network, kernel, or Python at view time. Client-side interactivity (pan, zoom, overlay widgets) is preserved. Parameters ---------- fig : Figure resizable : bool, optional Keep the figure's drag-to-resize handle. Default ``True``. """ return build_standalone_html(fig, resizable=resizable)
[docs] def save_html(fig, path, *, resizable: bool = True) -> pathlib.Path: """Write :func:`to_html` output to *path* and return it as a ``Path``.""" p = pathlib.Path(path) p.write_text(to_html(fig, resizable=resizable), encoding="utf-8") return p
[docs] def esm_path() -> pathlib.Path: """Return the path to ``figure_esm.js`` for bundling into a JS app. Copy (or import) this file into your Electron / web build; it exports ``mount`` and ``createLocalModel`` alongside the anywidget ``render``. """ return pathlib.Path(__file__).parent / "figure_esm.js"
[docs] class FigureBridge: """Transport-agnostic two-way sync between a live ``Figure`` and a remote JS view mounted with ``mount(el, state, {onSync})``. You supply the pipe (WebSocket, Electron IPC via a sidecar, stdio, …); the bridge supplies the protocol: plain ``(key, value)`` pairs. Parameters ---------- fig : Figure The live figure. All Python-side mutations (``plot.set_data(...)``, marker/widget updates, layout changes) are forwarded automatically. send : callable(key: str, value) -> None Called for every outbound state change. Wire it to your transport. Notes ----- * **Python → JS**: any synced trait change triggers ``send(key, value)``; deliver it to ``handle.applyUpdate(key, value)`` in JS. * **JS → Python**: deliver each JS ``onSync(key, value)`` message to :meth:`receive`. Interaction events (``event_json``) are dispatched to the figure's callback registries exactly as in Jupyter, so ``@plot.add_event_handler(...)`` handlers fire unchanged. * Echo is suppressed in both directions. """ def __init__(self, fig, send) -> None: self._fig = fig self._send = send self._applying = False # names=traitlets.All: also covers panel traits added dynamically # after the bridge is created (Figure.add_traits on new panels). import traitlets fig.observe(self._on_trait_change, names=traitlets.All) # ── outbound (Python → JS) ──────────────────────────────────────────── def _on_trait_change(self, change) -> None: if self._applying: return name = change["name"] trait = self._fig.traits().get(name) if trait is None or not trait.metadata.get("sync") or name.startswith("_"): return self._send(name, change["new"])
[docs] def snapshot(self) -> dict: """Full state dict for the initial ``mount()`` on the JS side.""" return figure_state(self._fig)
# ── inbound (JS → Python) ─────────────────────────────────────────────
[docs] def receive(self, key: str, value) -> None: """Apply one inbound ``(key, value)`` message from the JS view. ``event_json`` messages are dispatched to plot/widget callbacks; other keys (e.g. a panel's view state after a JS-side 3D rotate) are stored on the figure without echoing back. """ if key == "event_json": self._fig._dispatch_event(value) return if not self._fig.has_trait(key): return self._applying = True try: setattr(self._fig, key, value) finally: self._applying = False
[docs] def close(self) -> None: """Stop forwarding (unobserve the figure).""" import traitlets try: self._fig.unobserve(self._on_trait_change, names=traitlets.All) except ValueError: pass