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:

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 <div> (or <webview>/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.

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:

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.

API reference#

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 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 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.

anyplotlib.embed.figure_state(fig)[source]#

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)

Return type:

dict

anyplotlib.embed.to_html(fig, *, resizable=True)[source]#

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 type:

str

anyplotlib.embed.save_html(fig, path, *, resizable=True)[source]#

Write to_html() output to path and return it as a Path.

Parameters:

resizable (bool)

Return type:

Path

anyplotlib.embed.esm_path()[source]#

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 type:

Path

class anyplotlib.embed.FigureBridge(fig, send)[source]#

Bases: object

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 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.

snapshot()[source]#

Full state dict for the initial mount() on the JS side.

Return type:

dict

receive(key, value)[source]#

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.

Parameters:

key (str)

Return type:

None

close()[source]#

Stop forwarding (unobserve the figure).

Return type:

None

class anyplotlib.embed.Ragged(offsets, columns, nav_shape=())[source]#

Bases: object

A block with a variable number of rows per navigation position.

offsets is the row-pointer array: position i owns rows offsets[i] up to offsets[i + 1], so it has n_positions + 1 entries. columns maps a name to one value per row. nav_shape gives the navigation grid when it has more than one axis, so a two-dimensional index resolves to the right row span.

Parameters:
offsets: ndarray#
columns: dict#
nav_shape: tuple = ()#
anyplotlib.embed.pack_blocks(blocks)[source]#

Pack arrays into one little-endian byte string plus a manifest.

blocks maps a name to a numpy array (a dense block whose leading axes are the navigation axes) or to a Ragged. The return is (payload, manifest): the page base64-decodes payload once into a single ArrayBuffer and takes a typed-array view per manifest entry, so no block is encoded or copied on its own.

Parameters:

blocks (dict)

Return type:

tuple[bytes, dict]

anyplotlib.embed.navigated_html(fig_or_state, blocks, bindings, *, chrome=None, title='', caption='')[source]#

Return a self-contained page whose navigator drives its other panels.

fig_or_state is a live Figure or the dict figure_state() returns. blocks is the data the page navigates, in the form pack_blocks() takes. bindings says what each panel does:

{panel_id, role: "navigator" | "driven" | "static",
 widgets: [...],
 frame: {block, kind: "image" | "disks" | "points3d", radius?,
         combine?, levels?, width?, height?, colors?},
 views: [{label, block, colors?}],
 overlays: [{block, kind, style, columns?, face_camera?}],
 reduce: {block, navigator_panel, x?, y?, value?},
 readout: {block, names, units}}

views are a committed result’s alternative frames (a strain map’s epsilon_xx, epsilon_yy, epsilon_xy, omega): the page renders a segmented control that swaps which block the panel’s frame is read from, at whatever position the navigator is already on. With views, frame.block may be omitted and the first entry is the one shown first. A navigator binding may carry initial_index to open somewhere other than the origin.

A 3-D panel takes kind: "points3d" (a dense (M, 3) float32 cloud plus a colors block) and a "highlight" overlay marking one point per navigation position; face_camera on that overlay turns the panel to face the marked point.

The renderer, the figure state, the packed data and the bindings are all inlined, so the page needs no network and no Python at view time.

Raises:

ValueError – When a binding names a panel or a block the page does not carry.

Parameters:
Return type:

str

JS handle reference#

mount(el, state, opts) handle

handle.setPanelState(id, st)

Replace one panel’s state (dict or JSON string) and re-render it.

handle.patchPanel(id, partial)

Merge partial into one panel’s state and re-render. Values are stored verbatim — markers, extra_lines, display_min/max, overlay_widgets.

handle.setImage(id, b, w, h, o)

Replace a 2-D panel’s image with raw pixel bytes (w*h codes, or w*h*4 with o.rgb). Repaints on the next animation frame.

handle.flushImages()

Paint pending setImage frames now.

handle.panelIds()

The panel ids in layout order.

handle.set(key, value)

Raw model write + sync flush.

handle.get(key)

Read any model key.

handle.applyUpdate(key, v)

Apply a Python-originated update without echoing it back through onSync.

handle.resize(w, h)

Resize the figure (CSS pixels).

handle.exportPNG(opts)

Composite to a PNG data URL. Resolves to {dataUrl, width, height}. See Exporting images.

handle.exportCanvas(opts)

The same, synchronously, returning {canvas, width, height} so a host can encode it itself (TIFF, JPEG, a PDF page).

handle.registerExportAction(a)

Add an entry to the right-click export menu; returns an unregister function.

handle.unregisterExportAction(id)

Remove one by id.

handle.dispose()

Remove the figure’s DOM and listeners.

handle.model

The underlying local model (advanced).

Export options (all optional), shared by exportPNG and exportCanvas:

scale

Extra multiplier over devicePixelRatio (default 1).

includeWidgets

Draw overlay widgets, without their drag handles.

panelId

Export just this panel instead of the whole figure.

source

'view' (as displayed), 'full' (whole data extent) or 'native' (one output pixel per data pixel).

theme

'current', 'light' or 'dark'.

opts.onEvent(ev) receives parsed interaction events (the same payloads Python’s Event carries); opts.onSync(key, value) receives every outbound model write for bridging to Python.

Owning the hover readout#

2-D panels show the cursor’s position and pixel value in a small pill drawn on the image (see Hover readout (2-D)). In a desktop app you usually want that in your own chrome instead — a status line pinned to the bottom-right of the window, where it never covers data. Hide the pill from Python and take the payload from opts.onReadout:

plot.set_readout_visible(False)      # before figure_state(fig)
const statusEl = document.getElementById('status-bar');   // your own chrome

mount(host, state, {
  onReadout: (info) => {
    // info === null when the cursor leaves the image
    statusEl.textContent = info ? info.text : '';
  },
});

The same payload is also dispatched as an apl:readout CustomEvent that bubbles off the mount container, which is handy when the listener lives somewhere other than the mount() call site:

host.addEventListener('apl:readout', (e) => render(e.detail));

info fields:

panel_id

Which panel the cursor is over.

img_x, img_y

Fractional position in image pixels; integer i is the centre of pixel i.

col, row

Integer pixel index (img_x/img_y rounded).

xdata, ydata

Physical position in units.

units

Axis units string ("px" when unset).

value

Pixel value, or null for a true-colour image.

exact

true when value is the true datum rather than a quantised estimate (see Hover readout (2-D)).

rgba

[r, g, b, a] for a true-colour image, else null.

text

The formatted one-line string the built-in pill uses.

Updates are deduplicated by text, so a cursor moving inside one pixel does not call back repeatedly. exact flips to true for the same pixel when a value probe resolves, which fires one more callback — render it, don’t ignore it.

Notes and caveats#

  • The state dict is the wire format, not a stable public schema — treat panel-state internals as opaque where you can, and prefer regenerating states from Python when upgrading anyplotlib versions.

  • dispose() removes the figure’s DOM; for hard teardown of all window-level listeners, host each figure in its own iframe/webview and drop the frame (this is also the most robust MDI isolation).

  • One renderer file, no build step: figure_esm.js has no imports, so it works with any bundler or directly as a <script type="module">.

  • An exported page inlines its state and data into a <script> block, and an HTML parser ends that block at the first </ in its text whether or not it sits inside a JavaScript string. Every JSON literal the page embeds therefore goes through anyplotlib._repr_utils.script_json, which escapes </ and <!-- through the <, so a title or an axis label read from file metadata cannot close the block and run what follows. Titles and captions are HTML-escaped on their way into markup. If you template your own page around figure_state, do the same.