Interactive rendering¶
This page covers the interactive rendering API: selections, conditional encodings, zoom/pan, linked views, and saving interactive output. The interactive behavior requires a Jupyter notebook with anywidget installed; the code patterns on this page work in any Python environment, but the visual interactivity only activates in a live Jupyter session.
For the design rationale — why interactivity is a renderer, not a rewrite — see the Interactivity concept page.
Setup¶
Install the interactive extras:
This adds anywidget and ipywidgets as dependencies. The WASM GPU renderer is bundled inside the ferrum wheel — no separate download.
Switching to interactive mode¶
Any chart becomes interactive by calling .interactive():
import ferrum as fm
import polars as pl
df = pl.DataFrame({"x": [1, 2, 3, 4, 5], "y": [2, 4, 1, 5, 3]})
chart = fm.Chart(df).mark_point().encode(x="x", y="y")
# In a Jupyter cell, this renders as a GPU-backed canvas with zoom/pan:
chart.interactive()
The chart object is unchanged — .interactive() switches the render target from SVG to a WASM canvas widget. The same chart still works with .show_svg(), .save("out.svg"), and every other static render path. Selections and zoom/pan are silently ignored in static output.
Selections¶
Selections define interactive state: "which marks did the user click?" or "what region did the user brush?" They are declared in the chart spec and resolved by the renderer.
Point selections¶
A point selection activates when the user clicks a mark. Use selection_point:
import ferrum as fm
import polars as pl
df = pl.DataFrame({
"x": [1, 2, 3, 4, 5],
"y": [2, 4, 1, 5, 3],
"group": ["a", "b", "a", "b", "a"],
})
sel = fm.selection_point(fields=["group"])
chart = (
fm.Chart(df)
.mark_point(size=100)
.encode(x="x", y="y", color="group:N")
.add_selection(sel)
.interactive()
)
Clicking a mark selects all marks that share the same group value. Shift-click toggles additional selections (controlled by toggle="event.shiftKey", the default). Use [selection_single][ferrum.selection_single] to disable toggling, or [selection_multi][ferrum.selection_multi] for explicit multi-select.
Key parameters:
| Parameter | Default | Effect |
|---|---|---|
fields |
None |
Capture these field values on click; marks with matching values are selected. |
encodings |
None |
Alternatively, trigger on encoding channel values (e.g. ["x", "color"]). |
nearest |
False |
Snap to the nearest mark instead of requiring an exact click. |
on |
"click" |
Trigger event — "click", "mouseover", "dblclick". |
clear |
"mouseout" |
Event that clears the selection. |
resolve |
"global" |
How multi-panel selections are resolved: "global", "union", "intersect". |
Interval selections¶
An interval selection activates when the user drags a rectangular brush. Use selection_interval:
import ferrum as fm
import polars as pl
df = pl.DataFrame({
"x": [1, 2, 3, 4, 5, 6, 7, 8],
"y": [2, 4, 1, 5, 3, 6, 2, 4],
})
brush = fm.selection_interval()
chart = (
fm.Chart(df)
.mark_point()
.encode(x="x", y="y")
.add_selection(brush)
.interactive()
)
Dragging on the canvas creates a rectangular brush. Marks inside the brush are selected; marks outside are not. The brush can be panned (translate=True) and zoomed with the mousewheel (zoom=True).
To style the brush rectangle, pass a SelectionMark:
import ferrum as fm
brush = fm.selection_interval(
mark=fm.SelectionMark(fill="#3388cc", fill_opacity=0.2, stroke="#3388cc"),
)
Conditional encodings¶
Selections become useful when they drive visual feedback. A conditional encoding changes a channel's value based on whether marks are selected.
The pattern is: sel.when(if_selected).otherwise(if_not).
import ferrum as fm
import polars as pl
df = pl.DataFrame({
"x": [1, 2, 3, 4, 5],
"y": [2, 4, 1, 5, 3],
"species": ["setosa", "versicolor", "setosa", "versicolor", "setosa"],
})
sel = fm.selection_point(fields=["species"])
chart = (
fm.Chart(df)
.mark_point(size=100)
.encode(
x="x",
y="y",
color=sel.when(fm.Color("species")).otherwise(fm.value("#cccccc")),
)
.add_selection(sel)
.interactive()
)
Clicking a point colors all marks of the same species; unselected marks turn grey. The value wrapper marks a literal (a hex color, an opacity float) for use in the conditional.
You can also apply conditionals to opacity and size:
import ferrum as fm
import polars as pl
df = pl.DataFrame({"x": [1, 2, 3], "y": [3, 1, 2], "g": ["a", "b", "a"]})
sel = fm.selection_point(fields=["g"])
chart = (
fm.Chart(df)
.mark_point(size=100)
.encode(
x="x",
y="y",
opacity=sel.when(fm.Opacity("g")).otherwise(fm.value(0.2)),
)
.add_selection(sel)
.interactive()
)
Zoom and pan¶
The interactive renderer supports mousewheel zoom and click-drag pan on the canvas. These are controlled by the chart's coordinate system — no extra declaration is needed beyond calling .interactive().
Zooming recomputes the visible domain and re-renders the scene with updated axis ticks and labels. The chart data is not resampled — the renderer draws the full dataset within the visible window.
Linked views¶
Because selections live in the chart spec and composition operators pass the spec through, linked views fall out of composition with no extra API.
Declare a selection in one chart, reference it in another, and compose them:
import ferrum as fm
import polars as pl
df = pl.DataFrame({
"x": [1, 2, 3, 4, 5, 6, 7, 8],
"y": [2, 4, 1, 5, 3, 6, 2, 4],
"category": ["a", "b", "a", "b", "a", "b", "a", "b"],
})
brush = fm.selection_interval()
scatter = (
fm.Chart(df)
.mark_point()
.encode(
x="x",
y="y",
color=brush.when(fm.Color("category")).otherwise(fm.value("#cccccc")),
)
.add_selection(brush)
)
bars = (
fm.Chart(df)
.mark_bar()
.encode(x="category:N", y="count():Q")
)
linked = (scatter | bars).interactive()
Brushing on the scatter highlights points by category; the bar chart stays synchronized because both charts share the selection through the composition operator. This is the same | operator used for static concatenation — no separate "link API."
Listening for selections in Python¶
For programmatic responses to user interaction, register a callback with [on_selection_change][ferrum._interactive.InteractiveChart.on_selection_change]:
import ferrum as fm
import polars as pl
df = pl.DataFrame({"x": [1, 2, 3], "y": [3, 1, 2], "label": ["a", "b", "c"]})
sel = fm.selection_point(fields=["label"])
interactive = (
fm.Chart(df)
.mark_point(size=100)
.encode(x="x", y="y")
.add_selection(sel)
.interactive()
)
def handle(state):
print(f"Selected: {state}")
interactive.on_selection_change(handle)
interactive # display in Jupyter
The callback receives the current selection state as a dict. In Jupyter, output from the callback appears below the chart widget (via an ipywidgets.Output area that clears on each new selection).
Saving interactive output¶
Save an interactive chart as a self-contained HTML file:
The HTML file inlines the WASM renderer and the scene data — no external dependencies, no server required. Open it in any modern browser.
Performance at scale¶
The interactive renderer uses two optimizations that keep large charts responsive:
- Binary instance bridge — GPU mark data bypasses JSON serialization and is sent as a packed binary buffer. This eliminates the deserialization bottleneck that would otherwise make million-point interactive charts impractical.
- Packed tooltips — field-level tooltip content is transferred via a binary buffer rather than per-mark JSON objects. Tooltip lookups use a spatial hit-test (
hitTestAt) that resolves to the nearest mark's data index.
These are transparent — you don't need to opt in. A 1M-point scatter with tooltips uses the same .interactive() call as a 100-point chart.
Static fallback¶
A chart with selections renders normally in static output — .show_svg(), .save("plot.png"), .save("plot.svg") all work. Selections, conditional encodings, and zoom/pan are silently ignored. This means you can build one chart that serves both a notebook dashboard (interactive) and a report figure (static) without maintaining two specs.
Where to go next¶
- Interactivity is a renderer for the design rationale behind this approach.
- Composition for the operators that enable linked views.
- API Reference — ferrum.selection for the full signatures of selection constructors and
SelectionMark. - Marks & encodings for the
tooltip,href, andkeychannels that interact with the renderer.