Interactive figures

Interactive HTML (fig.save("x.html", interactive=True) / fig.to_html()) and the native window (fig.show()) carry a self-contained, vanilla-JS toolbar. It makes no external requests, so it works offline and under strict CSPs (Jupyter, sandboxed webviews).

Nothing is interactive until a tool is selected.

In a Jupyter notebook

Evaluating a figure directly in a cell (fig as the last expression) renders it inline as static SVG, not this toolbar – there’s deliberately no _repr_html_, since Jupyter prefers text/html over image/svg+xml when a MIME bundle offers both, and a full interactive HTML document dropped into an output cell that way renders messily and its <script> doesn’t run there regardless (see Viewing figures for the full comparison of every surface a figure can render on).

fig.show_in_jupyter() (needs [jupyter]: pip install plotpress[jupyter] – any real Jupyter environment already has IPython) is the one line that gets this toolbar working inline instead: it embeds to_html()’s self-contained output in an <iframe>, which does isolate and run the inlined JS, so every tool below – Pan/Zoom, Point Picking, sliders, all of it – works exactly as it does in a saved .html file opened in a browser:

fig, ax = plotpress.subplots()
ax.plot(x, y)
fig.show_in_jupyter()   # last expression in the cell, or wrap in display(...)

width/height default to the figure’s own pixel size (figsize x style.dpi) and can be overridden – fig.show_in_jupyter(width=900, height=600).

Choosing your tools

Every interactive page gets the same core toolbar – Pan/Zoom, Home, Fit Width, Axes, Point Picking, Annotate, and File. Anything beyond that is opt-in, by name, through options= on to_html, save, show, show_in_jupyter, and Report.save:

fig.save("fig.html", interactive=True)                    # the core toolbar
fig.save("fig.html", interactive=True, options=["slice"]) # + Slice
"slice"

The Slice menu – scrub a row or column of a pcolormesh/imshow as a 1-D profile, coupled across as many axes as share the same grid. It only appears when the figure actually has a pcolormesh or imshow to slice. A radio in the menu picks how the profile is shown:

  • Companion panel (the default): the profile is drawn in a strip carved out of the mesh’s own axes – above the heatmap for an X slice, to its left for a Y slice – so both are visible at once, aligned through every pan and zoom. The heatmap gives up 30% of its axes to the strip (panel_size).

  • Profile replaces heatmap: the profile is drawn in the heatmap’s place.

  • Heatmap with cursor: no profile, just a dashed cursor on the slice.

Point Picking works on the profile: click anywhere along it to pin the nearest sample, which reads the position along the profile and the value there. In the “Profile replaces heatmap” view the whole axes is the profile, so a click picks a profile sample rather than a (hidden) heatmap cell. The pin rides the line through pan, zoom, and slider steps (reporting the value the slice holds now), carries across a switch between the two profile views, and arrow keys step it along the profile. A pin whose value moves past the profile’s range (a fixed "colorbar" or "custom" range, while the slider plays) stays on the profile’s edge with its value shown in red rather than leaving the axes; it’s hidden while its sample has no value or has been zoomed out of view.

Extract leaves profile pins out – the heatmap carries the data. Snap pins to slice (a checkbox under “Slice view”, companion panel only) links heatmap pins and profile pins in both directions: a Point Picking pin on the heatmap gets a mirror on the shown profile, and a pin you place on the profile gets a mirror on the heatmap cell it points at (on the row or column the slider is showing, so it moves as the slider does). The pin you placed stays where it is; each pin and its mirror share a label color so you can see they’re linked, and mirrors are left out of Extract. A pin placed on the profile is extracted as the heatmap cell its mirror sits on, and no cell is ever reported twice; if profile pins were left out because Snap is off, the Extract panel says so and names the setting to turn on.

Axes with fixed ticks (set_xticks/set_yticks), axis("off"), or a twin or secondary axis get a strip too (a twin shrinks together with its parent, and fixed ticks are remapped to the smaller heatmap). An inset axes (inset_axes()) is left out of Slice altogether and stays a plain mesh; the axes it sits on keeps the plain cursor and slider, with no strip. When an axes holds several meshes (overlaid on one another), only the first one is sliced.

Gridlines on the profile. A “Gridlines on profile” checkbox in the Slice view group draws light lines on the strip (or on the profile in the replace view) at the value ticks and at the heatmap’s own ticks, so a value can be read straight across. It is on by default; "grid": False starts with it off.

Choosing which axes to slice. A pair of radios under “Axes to slice” picks between All axes and Selected axes. Choosing “Selected axes” drops into a click-to-choose mode: click a mesh’s axes on the figure to add or remove it (the ones still choosable are dashed while you choose; the others stay plain heatmaps with no slider, cursor, or strip, and only the chosen ones are linked together by “Link all matching axes”). “Choose axes on figure” re-enters that mode later, and adding or removing an axes leaves the slider where it was.

Save and Save As keep the Slice state. The saved file reopens with Slice enabled or not, the view, orientation, chosen axes, link and snap settings, value range, and slider position as you left them (its startup settings are rewritten to match), and pins on the profile come back too.

To have Slice start in a chosen state instead of switched off, pass a dict whose values are its settings:

fig.save("fig.html", interactive=True, options={
    "slice": {"enabled": True, "view": "companion", "orientation": "y",
              "link_all": True, "range": "colorbar"},
})

The settings are enabled, view ("companion"/"replace"/ "cursor"), orientation ("x"/"y"), link_all, snap_pins, grid, range ("auto"/"colorbar"/"custom", the last with range_min/range_max), index (the starting row/column), and panel_size (the strip’s share of the axes, 0.1-0.6). Values are checked when the page is built, so a typo raises ValueError immediately.

Worked examples, each live with Slice on: reading a row of a heatmap (Slice: read a row of a heatmap as a line), slicing columns with the profile in the heatmap’s place and gridlines (Slice: columns, and the profile in the heatmap’s place), several meshes scrubbed by one slider (Slice: several meshes scrubbed together), choosing which axes of a grid to slice (Slice: only the axes you choose), and a thousand meshes on one figure (Slice on a thousand meshes). In the gallery, every live pcolormesh or image figure in the plot-type reference and the real applications opens with Slice already enabled.

An unknown name raises ValueError listing the valid ones. The choice only changes which tools the page builds: the embedded data is identical either way, so load_data() reads back any interactive HTML regardless of which options it was saved with.

The toolbar

A single row, spanning the full width of the window and pinned to its top (position:fixed, immune to scrolling, panning, or Pan/Zoom’s own whole-figure zoom). Pan/Zoom, Home, and Fit Width sit standalone at the far left – whole-figure tools reached for often enough to skip a menu’s extra click. Everything else groups into four menus by what the buttons do: Axes (Axis Span/Axis Zoom, then Reset All Axes, the pair it undoes), Point Picking (the tool, Hide Points, Clear Points, and Extract), Annotate (the tool, Hide Annotations, then Clear Annotations), and File (Save, Save As). A caller’s own custom tools (plotpressAddTool, see Adding a custom tool to the existing toolbar) get a fifth Custom menu, created only once one is actually added. A persistent indicator naming the current tool (or “No tool active”) is pinned to the bar’s own far right.

A menu item that selects a tool is checkable, not one-shot: clicking it selects that tool (deselecting whatever else was active) and leaves the menu open, so picking a different tool from the same menu doesn’t need reopening it first. Double-click the active tool to deselect it – or press Escape, which works from anywhere, not just while that tool’s own button has focus (handy for a keyboard-only user, who has no double-click to deselect with). One-shot actions – Save, Home, Clear Points, Extract, and the like – fire immediately and close their menu, the same as clicking outside any menu does; so does Escape, when a menu happens to be open (otherwise Escape clears every pin/annotation and deselects the active tool in one press, see the Point Picking row below).

Tool

Behavior

Pan/Zoom

A plain wheel zooms the whole figure, centered on the cursor, regardless of which axes (if any) is under it – the useful gesture on a figure with many small axes, where the cursor is only ever over one tiny panel at a time. It grows the SVG’s own rendered size (never any axes’ data range or ticks), so once it no longer fits the window the browser’s own scrollbars reach the rest of it, the same as scrolling any other oversized page content. Drag pans that same whole-figure view (native page scroll under the hood) in any direction. Double-click resets that view (there is no per-axes zoom to reset here, unlike Axis Span/Zoom below). Sits standalone at the toolbar’s far left, not behind a menu – the one whole-figure-level navigation tool, reached for often enough to skip a menu’s extra click. (Text on the figure – tick labels, titles, pin labels – is left unselectable for as long as any mode is active, not just this one: every mode’s own drag can sweep across it the same way this one’s pan always could.)

Home

Restores whole-figure magnification to its natural size; leaves every axes’ own pan/zoom and every pin/annotation untouched. Sits standalone right after Pan/Zoom, the tool it undoes. Neither Reset button (this one, or Reset All Axes below) clears pins or annotations; that’s what Clear Points/Clear Annotations below are for.

Fit Width

A one-shot snap to whatever whole-figure magnification makes the figure exactly as wide as the current browser window. Deliberately separate from Home rather than folded into it: most figures are already narrower than the viewport, and redefining Home as “fit width” would zoom those in past their natural size by default. Matters for standalone HTML/pop-up output with a large figsize, which otherwise opens at its natural, often-wider-than-any-window pixel size – an embedded figure (e.g. inside a Report) already CSS-scales to its container, so there is nothing for this to do there. Like Home, fires once; a later window resize doesn’t trigger another automatic re-fit.

Axis Span

Drag to pan a single plot’s data window (log-aware).

Axis Zoom

Two distinct gestures. Drag a rubber-band box to zoom one axes in data space – its ticks recompute and markers keep a constant size. Ctrl+wheel zooms the whole figure instead, the same gesture Pan/Zoom’s plain wheel does.

Reset All Axes

Restores every axes’ own pan/zoom to its original view; leaves whole-figure magnification and every pin/annotation untouched. Double-click resets just the plot under the cursor.

Point Picking

Click to pin the nearest data value. Arrow keys step along the series (nearest-neighbour for scatter, cell-by-cell for meshes, contours and images). Right-click deletes a marker; Escape clears every pin and annotation at once (and deselects the active tool, back to no tool active). A marker’s own dot scales down with the axes it landed on, so it never dwarfs a tiny panel in a large grid the way a fixed size would – and stays that same on-screen size at any whole-figure zoom level (Pan/Zoom or Axis Zoom’s Ctrl+wheel), rather than growing along with the figure until it covers the very cell it is pointing at. Its label box (offset from the dot by default, connected to it by a thin leader arrow) is itself draggable – grab the box, not the dot, while Point Picking is active – and a dragged position survives pan/zoom/arrow-key steps and a Save/Save As round trip.

Hide Points

A standalone toggle, not a mode – available regardless of which tool is selected. Hides every Point Picking pin without deleting any of them; toggling it back to “Show Points” restores them exactly as they were, text included. Independent of Hide Annotations below – an annotation note stays visible either way.

Clear Points

Removes every Point Picking pin at once, and only those – an annotation note survives untouched.

Extract

Copy/download picked points, or return them to Python – Point Picking pins only, not annotation notes; an annotation note has nothing to “extract” in the same sense a picked data value does.

Annotate

Drop a plain text box anywhere on the figure – a caption, not a callout. No dot, no leader arrow, and always pinned to a fixed figure position, even dropped inside an axes.

Annotate Arrow

Drop a user-written note anywhere on the figure, not locked to any datum, but pointing at wherever it was dropped – a dot at that spot, a draggable label box, and a leader arrow connecting the two. Inside an axes it tracks that axes’ data coordinate; outside one it stays at its fixed figure position, the same as Annotate above.

Annotate Point

Like Point Picking, but prompts for text and locks a user-written note to that datum instead of the auto-generated readout – steppable by arrow key, tracks pan/zoom, the same nearest-datum resolution Point Picking itself uses. Classed as an annotation, not a Point Picking marker: it survives Clear Points, Hide Points leaves it visible, and it never appears in Extract’s output.

Hide Annotations

The mirror of Hide Points: hides every annotation note (all three Annotate tools’ own) without deleting any of them, plus every figure-drawn boxed callout (ax.text()/ax.annotate(bbox=...)) – a static callout reads the same way on screen as a note. Toggling it back to “Show Annotations” restores everything exactly as it was.

Clear Annotations

The mirror of Clear Points: removes every annotation note at once, and only those – a Point Picking pin survives untouched.

Save

Tries to overwrite the file this page was opened from – pan/zoom, every pin/annotation, hidden legend series, Hide Points/Hide Annotations, all included – instead of downloading a new one. That needs the File System Access API (Chromium, a secure context); anywhere it’s unavailable this falls back to the same download Save As does.

Save As

The same, but always downloads a new, equally self-contained HTML file rather than trying to overwrite the original. Reopening it resumes this exact session, not just what was originally plotted.

Any note’s own box – dropped by any of the three Annotate tools above – drags to reposition it while any of the three is the active mode, not just the one that created it: repositioning a note is routine housekeeping while annotating a figure, not something that should require first reselecting its exact original flavor. A Point Picking pin, by contrast, only drags while Point Picking itself is active.

window.plotpressGetMarkers() (see below) returns every pin and annotation, unlike Extract – it’s the general programmatic query a custom tool or an embedding (e.g. plotpress.qt) reads from, not Extract’s own narrower output.

A box-drag zoom and Axis Span’s pan both operate on a single axes’ data limits, recomputing that axes’ ticks live – including on log scales. Ctrl+wheel (or a trackpad pinch) under Axis Zoom, and Pan/Zoom’s own plain wheel, are the one image-style zoom of the whole figure: neither ever changes any axes’ data limits or ticks, only what part of the rendered figure is currently visible. A plain wheel with no tool selected is left to scroll the page instead – it never zooms.

Point picking reports extra dimensions

Picked values carry any extra per-point dimensions. A pcolormesh/imshow/ contour cell reports its z value; a scatter reports its c value; and arbitrary named dimensions attach via values= on the plotting call.

Every picked record also carries axes (the source axes’ index) and axes_title – the axes’ own title, or a generated "axes N" when it has none, so a multi-panel export always identifies its source panel by name, not just a bare index. xlabel/ylabel carry that axes’ own axis labels – including one set with ax.set_xlabel(..., visible=False), which is drawn nowhere on the figure but is exactly the name an export still wants – and zlabel carries the title of any colorbar attached to it (this library’s own convention for labeling what a colorbar’s scale means is fig.colorbar(mesh, ax=ax).set_title("units")) – a colorbar shared across several axes via fig.colorbar(mesh, ax=[a, b]) reports the same zlabel for each of them. When the figure has a supxlabel()/ supylabel()/ suptitle(), each record also carries supxlabel/supylabel/suptitle – the shared-axis fallback for a grid that labels its axes once instead of per panel. group carries the title of any group() box that axes sits in – empty when it belongs to none, joined with ", " on the rare axes added to more than one. Together these mean a value pulled out of context (a CSV row, a JSON dict) still says what it means, not just a bare number.

set_pick_context() attaches further, axes-level key/value context that rides along on every record picked from that axes – useful for identifying a panel by more than its title, e.g. surfacing a per-panel spine color:

ax.set_pick_context(edge_color="red")
# a click on this axes now reports {..., "edge_color": "red", ...}

A context key that collides with a structured field the record already sets (x, y, kind, …) is ignored for that record – the picked data always wins. See A different spine color per axes for a worked example, and the live figure in Usage.

set_pickable() (default True) excludes an axes from Point Picking – a click there behaves as if it missed every axes. Annotate Point resolves to a datum the same way, so it respects this too; Axis Span, Axis Zoom, Pan/Zoom, Annotate, and Annotate Arrow are unaffected, so a figure can restrict picking to a single panel while every other tool still works everywhere:

for ax in other_axes:
    ax.set_pickable(False)   # only the remaining axes stays pickable

See Restricting point picking to one axes for a worked example, and the live figure in Usage.

Extracting markers to Python

The Extract button (in the Point Picking menu) opens a panel to copy/download the current Point Picking markers – not annotation notes, which have nothing to “extract” in the same sense a picked data value does – as JSON (the default) or CSV, switched by a pair of radios in the panel (Copy copies whichever is showing; the two download buttons save each format). Each record is a dict: the picked value itself (x/y, plus z/c/any values= dimension), axes and kind, and then every piece of labelling context that axes and figure carry, so a row lifted out of the file still says what it means:

{"axes": 4, "kind": "points", "index": 42, "x": 0.164, "y": 0.71,
 "axes_title": "Fz",
 "xlabel": "time (s)", "ylabel": "potential (uV)",
 "supxlabel": "time (s)", "supylabel": "potential (uV)",
 "suptitle": "EEG montage",
 "group": "frontal"}

axes_title is that panel’s own title (a generated "axes N" when it has none). xlabel/ylabel are that axes’ own axis labels even when hidden – a label set with ax.set_xlabel(..., visible=False) is drawn nowhere but is exactly the name an export wants. supxlabel/ supylabel/suptitle are the figure’s shared labels (present only when set) – the fallback for a grid that labels its axes once instead of per panel. zlabel is any colorbar’s title; group is any group() box the axes sits in; and any set_pick_context() keys ride along too. See Hidden axis labels (stored, not drawn) and Hidden per-panel labels survive the round trip (and the Extract) for a grid built exactly this way – shared labels drawn once, per-panel labels hidden but still carried into every extracted record.

For a blocking “pick session” that hands the markers straight back to the kernel, use the native window:

markers = fig.show(wait_for_extract=True)
# kernel blocks; user picks points / annotates, clicks Extract; window closes
for m in markers:
    print(m)

Sliders for N-dimensional data

plot_frames() renders 3-D data Y of shape (n_frames, n_points) and adds a slider (play / pause / step) over the extra dimension. slider_values labels it.

wave = np.sin(x[None, :] - t[:, None])       # (n_frames, n_points)
ax.plot_frames(x, wave, slider_values=t, slider_label="t")
  • shared=True (default) – all plot_frames panels share one global slider.

  • shared=False – each axes gets its own slider docked beneath it. Give several the same slider_group to show a link checkbox so they can scrub together on demand.

The same frames export to a self-contained looping GIF, for anywhere an interactive HTML slider does not fit – a README, a slide, a chat message:

fig.save("wave.gif", fps=10)

This animates whichever slider slider_unit names ("main", the shared global slider, by default). A figure with more than one – some plot_frames panels shared, others docked with shared=False – picks one slider per GIF; the others hold their frame 0 for that render. Export each unit separately (slider_unit="ax1", matching the axes it is docked to) for more than one animated GIF from the same figure.

Adding or replacing the interactivity

extra_js (on to_html()/save()) inlines a caller-supplied JS string into the page, run after plotpress’s own – so window.plotpressAddTool/plotpressGetMarkers/plotpressToData already exist by the time it runs. Nothing about supplying it fetches anything external on its own; it is inlined the same as plotpress’s own JS, keeping the “no external requests” guarantee intact regardless of what it contains.

Adding to the existing toolbar (include_default_js left at its default True):

extra_js = """
  window.plotpressAddTool({
    label: 'Log Markers',
    onClick: function () { console.log(window.plotpressGetMarkers()); },
  });
"""
fig.save("figure.html", interactive=True, extra_js=extra_js)

window.plotpressAddTool(opts) registers a real button in its own Custom menu, alongside the four built-in ones (created only once a first custom tool actually exists) – not appended into a built-in menu itself, which would otherwise run longer with every tool added and blur which buttons are plotpress’s own vs the page’s. Two shapes mirroring the built-in tools:

{label, onClick}

An always-available action, firing immediately on click – like the built-in Extract/Save buttons. Never joins the selection group below.

{label, mode, onClick, onEnter, onExit, cursor}

A real mode, joining the same single-selection group as Pan/Zoom, Axis Span/Zoom, Point Picking, and the three Annotate tools – selecting it deselects whatever else was active, and vice versa. A click on the SVG that no built-in mode already claims calls onClick(event, userSpacePoint). window.plotpressToData(userSpacePoint) converts that further into a real data value ({axes, x, y}, or null off any pickable axes) – the same per-axes, log-scale/ inverted-axis-aware conversion Point Picking itself uses, so a custom tool doesn’t have to reimplement it. onEnter/onExit fire when the mode is selected/deselected; cursor sets svg.style.cursor while it’s active.

See Adding a custom tool to the existing toolbar for a worked example (a custom “Measure” tool joining the selection group, plus a plain action button).

Replacing the interactivity entirely (include_default_js=False): drops plotpress’s own toolbar/pan/zoom/pick JS from the page altogether – extra_js becomes the only interactivity this page gets, built from the raw #plotpress-meta/#plotpress-pick/#plotpress-style/ #plotpress-layout JSON payloads (still emitted, since interactive=True) and #plotpress-svg directly, rather than extending what plotpress already provides. #plotpress-layout (grid shape/position, every decoration – title, labels, limits, scale, … – spine colors, tick overrides, ids, twin/secondary/inset overlays, colorbar styling, and this figure’s own Style, keeping its original tag id even though it now carries the full template shape) is read by Python’s own load_data()/ figure_from_template() round trip, not by the bundled client JS – no toolbar tool reads it back out of the page. binary_pick_data=False is worth pairing with this: the default packs long numeric arrays as base64 float16/32 for size, which needs plotpress’s own decoder – exactly what dropping the built-in JS is turning off.

fig.save("figure.html", interactive=True, include_default_js=False,
         binary_pick_data=False, extra_js=my_own_toolbar_js)

See Replacing the toolbar entirely with your own JS for a worked example: a click handler built entirely from #plotpress-meta, with none of plotpress’s own JS involved at all.

Exposing the payload shapes at all is a real commitment – axes_metadata()/ pick_data()’s own field names become something a from-scratch script can depend on, so they’re no longer free to change without notice the way purely-internal serialization would be. What is not exposed alongside them is plotpress’s own internal coordinate-transform/zoom/pan implementation as a reusable library – include_default_js=False hands back the raw data and nothing else; reimplementing pan, zoom, or hit-testing against it is on the caller.