=================================
Embedding outside Jupyter
=================================
anyplotlib figures do not require Jupyter, ipywidgets, or the anywidget
runtime. The renderer is a single self-contained ES module
(``figure_esm.js``) that draws from a plain JSON state dict, so a figure can
live anywhere a browser engine runs: an **Electron** app, a Tauri/webview
app, an MDI-style multi-window workspace, a kiosk dashboard, or a static
web page.
There are three levels of integration, from zero-Python-at-runtime to a
fully live Python backend.
Level 1 — self-contained HTML (no Python at view time)
=======================================================
Export the figure as a single HTML file with the renderer and all data
inlined::
import anyplotlib as apl
import numpy as np
fig, ax = apl.subplots(1, 1, figsize=(800, 500))
ax.imshow(np.load("frame.npy"), cmap="viridis")
fig.save_html("plot.html")
Load it in an Electron window — that's the whole integration::
const { BrowserWindow } = require('electron');
const win = new BrowserWindow({ width: 840, height: 560 });
win.loadFile('plot.html');
Pan, zoom, overlay widgets, markers, and keyboard shortcuts all work;
Python callbacks (obviously) do not. ``fig.to_html()`` returns the same
page as a string if you want to serve or template it yourself.
Level 2 — JS-driven: your app owns the data
============================================
Bundle ``figure_esm.js`` into your app (``anyplotlib.embed.esm_path()``
tells you where to copy it from) and mount figures directly from
JavaScript:
.. code-block:: javascript
import { mount } from './figure_esm.js';
const handle = mount(document.getElementById('plot-host'), state, {
onEvent: (ev) => {
// every interaction event: pointer_down/up/move, wheel, key_down …
if (ev.event_type === 'pointer_down')
console.log('clicked data coords', ev.xdata, ev.ydata);
},
// 2-D hover readout (position + pixel value) for your own status bar —
// see "Owning the hover readout" below.
onReadout: (info) => { statusEl.textContent = info ? info.text : ''; },
});
// Live updates — replace one panel's state and it re-renders:
handle.setPanelState(panelId, newPanelState);
handle.resize(900, 600);
handle.dispose(); // remove the figure's DOM
``state`` is the figure-state dict. Generate it from Python once (at build
time or via a one-shot script)::
import json, anyplotlib as apl
from anyplotlib.embed import figure_state
fig, ax = apl.subplots(1, 1)
plot = ax.imshow(template_data)
json.dump(figure_state(fig), open("figure_state.json", "w"))
print("panel id:", plot._id) # key for setPanelState
Each ``mount()`` call is fully independent — mount as many figures as you
like into separate containers in one window. This is the natural fit for
**MDI sub-windows**: give every sub-window its own host ``
`` (or
``
``/iframe for hard isolation) and call ``mount`` per window.
Call ``handle.resize(w, h)`` from your sub-window's resize hook.
Level 3 — live Python backend (full callback support)
======================================================
Run Python next to your app (a sidecar process exposing a local WebSocket
is the common Electron pattern) and keep figures *fully* interactive —
``@plot.add_event_handler(...)`` callbacks fire exactly as in Jupyter.
:class:`anyplotlib.embed.FigureBridge` is transport-agnostic: you supply
the pipe, it supplies the ``(key, value)`` protocol.
**Python sidecar** (here with the ``websockets`` package)::
import asyncio, json
import numpy as np
import websockets
import anyplotlib as apl
from anyplotlib.embed import FigureBridge
fig, ax = apl.subplots(1, 1, figsize=(700, 450))
plot = ax.imshow(np.random.rand(256, 256))
cross = plot.add_widget("crosshair", cx=128, cy=128)
async def serve(ws):
loop = asyncio.get_running_loop()
bridge = FigureBridge(fig, send=lambda key, value:
loop.create_task(ws.send(json.dumps({"key": key, "value": value}))))
await ws.send(json.dumps({"snapshot": bridge.snapshot()}))
@cross.add_event_handler("pointer_move") # fires from Electron!
def follow(event):
print("crosshair at", cross.cx, cross.cy)
async for message in ws:
m = json.loads(message)
bridge.receive(m["key"], m["value"]) # JS → Python
asyncio.run(websockets.serve(serve, "localhost", 8765))
**Electron renderer**:
.. code-block:: javascript
import { mount } from './figure_esm.js';
const ws = new WebSocket('ws://localhost:8765');
let handle = null;
ws.onmessage = (msg) => {
const m = JSON.parse(msg.data);
if (m.snapshot) {
handle = mount(document.getElementById('plot-host'), m.snapshot, {
// forward every JS-side write (events, view changes) to Python
onSync: (key, value) => ws.send(JSON.stringify({ key, value })),
});
} else if (handle) {
handle.applyUpdate(m.key, m.value); // Python → JS, echo-free
}
};
Any Python-side mutation — ``plot.set_data(...)``, markers, titles, layout
changes — streams to the window automatically; drags, clicks, and keys
stream back into your Python callbacks. Echo is suppressed in both
directions by the bridge and ``applyUpdate``.
Navigated pages — one page that owns its data
==============================================
A *navigated* figure is one where a navigator panel drives the others: move the
crosshair over the scan and the signal panel shows that position's frame, its
overlays follow, and a detector drawn on the signal panel reduces the whole
dataset back onto the navigator. :func:`~anyplotlib.embed.navigated_html`
exports that as a single file — the renderer, the figure state, the data and the
bindings all inlined, no network and no Python at view time.
The data travels as *blocks*. A **dense** block is a numpy array whose leading
axes are the navigation axes; a :class:`~anyplotlib.embed.Ragged` block is a
row-pointer array plus one value array per column, for a variable number of rows
per position (diffraction spots, detected particles, peaks).
:func:`~anyplotlib.embed.pack_blocks` concatenates them into one little-endian
byte string, which the page decodes once into a single ``ArrayBuffer`` and reads
through typed-array views — no per-block base64, no copy per frame.
::
import numpy as np
import anyplotlib as apl
from anyplotlib.embed import navigated_html
scan = np.load("scan.npy") # (32, 32, 128, 128) uint8
fig, axes = apl.subplots(1, 2, figsize=(760, 380))
navigator = axes[0].imshow(scan.sum(axis=(2, 3)), cmap="gray")
signal = axes[1].imshow(scan[0, 0], cmap="gray")
navigator.add_widget("crosshair", cx=0, cy=0)
signal.add_widget("rectangle", x=48, y=48, w=32, h=32) # the detector
html = navigated_html(
fig,
{"scan": scan},
[
{"panel_id": navigator._id, "role": "navigator"},
{"panel_id": signal._id, "role": "driven",
"frame": {"block": "scan", "kind": "image"},
"reduce": {"block": "scan", "navigator_panel": navigator._id}},
],
title="Scan", caption="Drag the crosshair; drag the detector to re-map.",
)
open("scan.html", "w", encoding="utf-8").write(html)
Open ``scan.html`` in any browser, or point an Electron window or an ``