import matplotlib
if not hasattr(matplotlib.RcParams, "_get"):
matplotlib.RcParams._get = dict.get
Template - Interactive#
This page is a living template for interactive widget sections — pages where the reader drags sliders and a plotly figure answers, live, in their browser. It is also a tutorial: by the end you should be able to write a fun widget from scratch that looks native to the book.
Use it three ways:
As a tutorial — Section 2 is the quick start: the anatomy of a widget notebook and the steps from blank file to built page.
As a starting point — copy
notebooks/starter.ipynb(Section 3) and grow it.As a worked example — every cell on this page runs: Section 6 shows the same cell under all three visibility modes and Section 7 is a finished widget. Compare the page against its sources in
content/templates/template-interactive/(main.mdplus thenotebooks/folder).
See also
For prose-only pages see the Markdown template; for ordinary executable pages (code discussed as code, no widget) see the notebook template; for baked narrative animations (a video the page plays, no reader input) see the Animation template.
1. How an interactive section works#
A chapter’s interactive section is authored as two pieces, side by side:
the chapter’s Markdown — the prose, with a one-line directive where the widget belongs:
:::{interactive}[notebooks/my-widget.ipynb] :::the companion notebook in the chapter’s
notebooks/folder — Markdown cells and code cells, written and tested like any notebook (VS Code’s “Run All” is the fastest loop).
The split pipeline (tools/split_chapters.py) then emits the section as a
Jupyter notebook page: the surrounding prose becomes Markdown cells and the
companion notebook’s cells are spliced in where the directive was. From
there, what the reader experiences:
At build time the cells execute (
execute_notebooks: auto). Ordinary outputs — an audio card, a print — are baked into the HTML, and so is the widget’s figure:icm_plotly.showruns only thefigure()half and bakes the result as inert JSON. The sliders and their Python callback are not baked — a static page has no Python to run them on (and any ipywidgets model instantiated at build bakes megabytes of inert widget-state JSON instead).At page load the baked figure renders immediately (the book’s vendored plotly.js draws it — no kernel, no wait). Meanwhile, because the widget cell carries the
# autorunmarker, the live-code layer (_static/live-cells.js) starts the in-browser Python kernel (Pyodide + the book’s packages, ~45 MB on a first visit — all served by the book itself, cached after, and pre-warmed in the background while the reader is on other pages); when it’s up — roughly ten seconds on a fast connection — the sliders appear and the figure goes live. The reader never presses anything.On ▶ Run any cell can be edited and re-run: the clicked cell’s not-yet-run setup — the cells above it from the same companion notebook only (each is self-contained, so the rest of the page never runs on its behalf) — then the cell itself. Reloading the page restores the built page — that is the reset button. One caution: the page’s notebooks all execute in one shared Python kernel, so their top-level names can collide — the capture rule in Section 2 exists because of this.
Because the same cells run at build, in the reader’s browser, and in VS Code, write every cell against the browser kernel (Section 4); the build environment is a superset of it.
Note
This page itself is built by that pipeline. Its sources live in
content/templates/template-interactive/: main.md (the prose you are reading, plus
its {interactive} directives) and the notebooks/ folder beside it.
make template-interactive expands them into the index.ipynb that
_toc.yml points at — the same expansion make split performs for chapter
sections. Edit the sources, regenerate, rebuild.
2. Quick start: your first widget#
Every widget notebook is the same two cells — learn this shape once and every example on this page reads instantly:
Dependencies (
# hide+# no-output) — the%pip installline (inside awith capture_output():block) and every import. Copy it verbatim from Section 4.The widget (
# autorun; add# hidewhen the code is beside the point) — afigure()that builds the visual and acontrols(fig)that wires the sliders, handed to the book’s plumbing:def figure(): fig = go.Figure() fig.add_scatter(x=t, y=y0, mode="lines") return fig def controls(fig): amp = widgets.FloatSlider(description="Amplitude", min=0, max=2, value=1) def update(a, y0=y0): fig.data[0].y = a * y0 widgets.interactive_output(update, {"a": amp}) out = widgets.Output() # the audio card, always current gate = icm_plotly.release_gate() # pointer state: mid-drag? pending = [] dirty = [] def render(): audio = Audio(amp.value * y0, rate=sr, normalize=False) data, metadata = get_ipython().display_formatter.format(audio) out.outputs = ({"output_type": "display_data", "data": data, "metadata": metadata},) async def settle(): await asyncio.sleep(0.25) pending.clear() if dirty and not gate.dragging: dirty.clear() render() def on_change(_): dirty.append(True) if pending: pending.pop().cancel() pending.append(asyncio.ensure_future(settle())) def on_release(change): if not change["new"] and dirty: if pending: pending.pop().cancel() dirty.clear() render() amp.observe(on_change, names="value") gate.observe(on_release, names="dragging") if not os.environ.get("ICM_BOOK_BUILD"): render() return widgets.VBox([amp, out, gate]) icm_plotly.show(figure, controls)
The mental model. figure() is the part that can exist without Python —
at build it runs once and the resulting figure is baked into the page, so
the reader sees it the moment the page opens. controls(fig) is the
part that can’t: it receives the figure as a live plotly FigureWidget,
and its update(...) callback runs in Python on every slider move —
on the book page, that is the in-browser kernel that # autorun boots at
page load. When the kernel is up, the sliders appear and the baked figure
is swapped for the live one. Sound lives in controls(fig) too: an
audio card in a widgets.Output under the sliders. The previous clip
stays in place while a drag is underway (so the layout never jumps) and is
swapped for the freshly rendered one when the pointer releases (keyboard
nudges settle on a short timer), so whatever the reader plays is always
the settings on screen. Importing icm_plotly also applies
the house figure style, so neither function contains styling code
(Section 5).
The rules of the pattern — each one earned by a real failure:
figure()returns a plaingo.Figureand touches no ipywidgets — it runs at build time, and any ipywidgets model instantiated there (aFigureWidgetcounts) bakes megabytes of dead widget-state JSON into the page. Everything slider-shaped lives incontrols(fig), which never runs at build.Precompute outside, mutate inside — arrays the callback needs are computed once at the top of the cell;
update(...)only assigns tofig.data[i].x/y(andmarker.coloretc.), never rebuilds the figure. Wrap multi-trace updates inwith fig.batch_update():so they repaint as one frame.Capture what the callback reads — snapshot every top-level name
update(...)uses as a default argument:def update(a, y0=y0):. The callback fires long after its cell ran, and all of a page’s notebooks share one Python kernel — self-contained means each runs on its own, not that its names are private. Without the capture, the moment another notebook on the page rebindstory0, the sliders silently compute from the wrong array (this page’s starter once drew a flat line on the first drag because Section 7’s widget had reboundtbehind its back).Fix the axes —
fixedrange=Trueand explicit ranges, so the view doesn’t jump while data moves (the page hides plotly’s toolbar; sliders are the interface).Sound is a card that follows the sliders, not a cell and not a button — put a
widgets.Output()incontrols(fig)and arender()that synthesizes from the sliders’.values and writes the card through the Output’s syncedoutputstrait:out.outputs = ()thenout.append_display_data(Audio(x, rate=sr, normalize=False))(IPython.display.Audio, the elementpq.playshows; the book’s chip wraps it).observeevery slider: the first change of a drag clears the card dirty, and the release of the pointer re-renders it: include anicm_plotly.release_gate()in the VBox (a hidden widget whosedraggingtrait mirrors the pointer) and render on its falling edge, keeping the shortasynciosettle timer as the keyboard fallback. Never clear the card in between, and write the replacement as ONEout.outputs = (...)assignment (format theAudioyourself withget_ipython().display_formatter.format): the old clip stays visible during the drag and swaps in place, so the figure below never moves. Callrender()once at the end, guarded byif not os.environ.get("ICM_BOOK_BUILD")so the build bakes no card (the ghost reserves the card’s height). The result: there is never a stale clip to press. A separate “hear it” cell makes the reader retype parameters, and a render button lets them play a clip that no longer matches the sliders. Two details earned by failures: write through theoutputstrait, neverwith out:(that block only routes output while a kernel message is being handled, and the timer fires outside one); and scale the signal yourself, since the card is built withnormalize=False(x *= 0.125 / abs(x).max()is about -18 dBFS,pq.play’s own safe ceiling;0.125 * awhen loudness itself is the lesson). Needsimport asyncio,import os, andfrom IPython.display import Audioin the dependencies cell.
From blank file to built page:
Copy the starter —
notebooks/starter.ipynb(rendered in Section 3) — into your chapter’snotebooks/folder and rename it (notebooks/my-widget.ipynb).Get the figure right first — layout, ranges, axis labels with units, palette colors — before wiring any slider.
Add the sliders and the
update— precompute what you can outsideupdate; the callback should be cheap enough to run on every drag tick.Add sound where it earns its place — the self-refreshing audio card in
controls(fig)(the rule above; Section 7’s demo has one) always plays the current settings.Mark the cells (Section 6) —
# hide+# no-outputon the setup cell;# autorunon the widget cell (its baked output is the figure — no# no-outputthere), plus# hideif readers shouldn’t see the code.Test the notebook on its own — “Run All” in VS Code; drag every slider.
Drop the directive into the prose where the widget belongs, with a lead-in paragraph above and a “try changing…” prompt below:
:::{interactive}[notebooks/my-widget.ipynb] :::Regenerate and build —
make splitemits the section notebook (for this template page it ismake template-interactive), thenmake book; open the built page and watch the widget arrive on its own.
If something looks wrong on the page, check Section 8 — the common mistakes all have one-line fixes.
3. A minimal starter#
The whole pattern at its smallest — one hidden setup cell, one widget cell (code left visible here on purpose). The figure below is on screen from the moment the page opened; the sliders arrive when the kernel does. This is the file the quick start copies. Drag the sliders, or edit the cell and ▶ Run it:
# autorun
t = np.linspace(0, 1, 400)
def figure():
fig = go.Figure()
fig.add_scatter(x=t, y=np.sin(2 * np.pi * 3 * t), mode="lines")
fig.update_xaxes(title_text="Time (s)", fixedrange=True)
fig.update_yaxes(range=[-2, 2], title_text="Amplitude", fixedrange=True)
return fig
def controls(fig):
amp = widgets.FloatSlider(description="Amplitude", min=0, max=2, value=1, step=0.05)
freq = widgets.FloatSlider(description="Frequency (Hz)", min=1, max=8, value=3, step=0.5)
# runs in Python on every slider move; t=t snapshots the array — the
# page's notebooks share one kernel, and a later cell may rebind t
def update(a, f, t=t):
fig.data[0].y = a * np.sin(2 * np.pi * f * t)
widgets.interactive_output(update, {"a": amp, "f": freq})
return widgets.VBox([amp, freq])
icm_plotly.show(figure, controls)
4. The libraries a widget may use#
Library |
What it powers |
At build |
In the browser |
|---|---|---|---|
|
arrays, signal math |
✓ |
✓ preloaded (Pyodide build) |
|
the |
✓ (conda) |
✓ auto-installed when the page’s code uses them |
|
the house figure style, the palette, and |
✓ |
✓ self-hosted wheel, installed at kernel start |
|
|
✓ |
✓ self-hosted wheel, installed at kernel start |
|
|
– |
✓ auto-installed when the page’s code uses it |
|
pyquist’s dependencies |
✓ |
✓ preloaded |
|
WAV I/O, playback plumbing |
real |
stub wheels (WAV only; playback renders as an audio card) |
Two conventions keep the same cell runnable in both environments:
Install with
%pip install -q plotly anywidgetinside awith capture_output():block, so pip’s chatter stays off the page while a genuine install failure still raises loudly. Do not wrap the whole cell in a bare%%capture— it swallows the traceback too, and a silently-failed setup resurfaces cells later as a bafflingNameError. The install is a no-op wherever the packages already exist (both environments) and documents what the notebook needs. Two packages are deliberately not in the install line:icm_plotlyandpyquistship with the book (the build uses the repo’s editable installs and the browser pre-installs the book’s own wheels, so a PyPI fetch would fight both).Import everything in that one cell,
# hide+# no-outputit (Section 6), and let the teaching cells stay free of plumbing.
The standard first cell, in full:
# hide
# no-output
from IPython.utils.capture import capture_output
with capture_output():
%pip install -q plotly anywidget
import numpy as np
import plotly.graph_objects as go
import ipywidgets as widgets
import pyquist as pq
import icm_plotly
from icm_plotly import RED, GOLD, STEEL
Every companion notebook on this page opens with a cell like it — that is what keeps each one runnable on its own (“Run All” in VS Code) and lets the notebooks run in any order on the page.
5. The house style#
Widgets should look like the book drew them, and the styling is not the
author’s job: importing icm_plotly registers the book’s “icm” plotly
template and makes it the default — open spines in iron gray, outside
ticks, no grid, white figure surfaces, the palette as the colorway, and the
zoom/select tools trimmed (the page hides the toolbar entirely; sliders are
the interface). A widget cell contains no styling code at all.
Color is meaning. The palette imports from icm_plotly (re-exported from
its single source of truth, shared with the book’s manim animations) —
Carnegie red is both the university’s color and the book’s accent, so a
widget colored this way reads as part of the course:
Swatch |
Hex |
Role |
|---|---|---|
■ Carnegie red |
|
the primary object — the thing being built or measured |
■ Highlands blue |
|
a contrasting second series |
■ gold thread |
|
the moving part — a probe, the newest addition |
■ iron gray |
|
a composite or total signal |
■ teal thread |
|
a second component when red and blue are taken |
■ steel gray |
|
static guides: reference curves, unit circles |
Layout conventions, all visible in the demo widget below:
Two panels via
make_subplotswhen the widget shows a thing and its recipe side by side; weight the panels withcolumn_widths.Label everything — axes carry units (
"Time (ms)","Amplitude"); the slider’sdescriptionis the moving quantity’s name.Fixed ranges on every axis (Section 2’s rules) — the data moves, the frame doesn’t.
Sliders above the figure —
controls(fig)returns awidgets.VBox([…sliders…, out]), which the page displays above the figure, where the reader’s eye lands first; the panel’s card, equal slider tracks, and audio-card styling come fromlive-cells.cssandicm_plotly, never from the notebook.
6. Hide, collapse, show — and autorun#
Every code cell in a companion notebook chooses how much of itself the page shows, with a whole-line marker:
Marker |
On the page |
Use it for |
|---|---|---|
|
source removed entirely; the output stands alone as native page content (no |
boilerplate — or a widget whose code is beside the point |
|
source behind a collapsed Show setup bar |
setup a curious reader might expand: constants, helpers |
|
source visible and editable |
the code you are teaching |
|
the cell’s baked output is dropped from the page (it still runs, at build and in the browser) |
setup cells — and any kernel-only cell whose output is dead page weight. NOT the widget cell: its baked output is the figure |
|
the live layer runs the cell (and its setup chain) on page load — the baked figure holds the spot while the kernel boots |
every widget cell: the sliders arrive without the reader pressing ▶ Run |
The marker must be a line of its own (conventionally the first line of the
cell), it is case-insensitive, and the first one wins. It is an ordinary
comment: the split pipeline reads it to tag the cell (# hide →
icm-hide-input, # collapse → hide-input) and leaves the line in
place — expand any Show setup bar on this page and the # collapse
that put it there is the first thing you read, and a cell copied off the
page keeps its behavior in your own notebook. By convention visible cells
carry no marker: # show exists, but visible is the default. Hidden and
collapsed cells still run, both at build time and in the browser —
when a student runs a visible cell, the setup cells above it execute
first, in order.
To see the modes side by side, the rest of this section embeds
notebooks/three-modes.ipynb: the same cell three times — it renders
half a second of the 220 Hz sawtooth the widget in Section 7 builds, as the
book’s audio card — and nothing differs between the copies but the marker
line. (The notebook also opens with a hidden setup cell, which outputs
nothing.) The mode labels below are Markdown cells inside that notebook,
rendered as prose.
# hide — the source is gone; the audio card below is all the page shows:
# collapse — the same cell behind a Show setup bar:
visible (no marker) — the default; the code is the teaching material:
sr = 44100
t = np.arange(int(0.6 * sr)) / sr
k = np.arange(1, 13)
# a 220 Hz sawtooth from its first 12 harmonics, faded in and out
saw = (np.sin(2 * np.pi * 220 * k[:, None] * t) / k[:, None]).sum(axis=0)
saw *= 0.5 / np.abs(saw).max()
saw[:220] *= np.linspace(0, 1, 220)
saw[-220:] *= np.linspace(1, 0, 220)
pq.play(pq.Audio(saw, sample_rate=sr))
7. The demo widget#
Now the payoff: a full widget in the house style, from
notebooks/harmonics-builder.ipynb. Its dependencies cell is hidden; the
widget cell’s code is visible on purpose — in a chapter you would usually
add # hide (Section 5.2 of the book, “The phasor”, is this pattern in the
wild with the code hidden, so only the widget shows).
Drag the slider: each step adds the next harmonic of the sawtooth recipe — harmonic \(k\) at amplitude \(1/k\) — to a running sum. On the left, the partial sum (Carnegie red) bends closer to the ideal sawtooth (steel gray), with the newest harmonic drawn in gold; on the right, each bar of the recipe lights up as its harmonic joins. The audio card under the slider always holds the sum you see, re-rendered a moment after each drag.
# autorun
f0 = 220.0 # fundamental (A3)
n_max = 12
T = 2 / f0 # two cycles on screen
t = np.linspace(0.0, T, 900, endpoint=False)
sr = 44100
seg = np.arange(sr) / sr # one second, for the ear
# The sawtooth recipe: harmonic k at amplitude 1/k (2/pi normalizes the
# full series to peak +-1). Row n of `sums` is the wave after 1..n+1.
k = np.arange(1, n_max + 1)
partials = (2 / np.pi) * np.sin(2 * np.pi * f0 * k[:, None] * t[None, :]) / k[:, None]
sums = np.cumsum(partials, axis=0)
ideal = 1.0 - 2.0 * ((f0 * t) % 1.0) # what the infinite series converges to
def figure():
fig = make_subplots(rows=1, cols=2, column_widths=[0.62, 0.38],
horizontal_spacing=0.12)
# left: the ideal sawtooth as a fixed reference, the sum drawn on top
fig.add_scatter(x=t * 1000, y=ideal, mode="lines",
line=dict(color=STEEL, width=1.6), row=1, col=1)
fig.add_scatter(x=t * 1000, y=sums[0], mode="lines",
line=dict(color=RED, width=2), row=1, col=1)
fig.add_scatter(x=t * 1000, y=partials[0], mode="lines",
line=dict(color=GOLD, width=1.4), row=1, col=1)
fig.update_xaxes(title_text="Time (ms)", fixedrange=True, row=1, col=1)
fig.update_yaxes(range=[-1.4, 1.4], title_text="Amplitude",
fixedrange=True, row=1, col=1)
# right: the recipe, 1/k per harmonic; joined bars light up
fig.add_bar(x=k, y=1 / k, marker_color=[GOLD] + [STEEL] * (n_max - 1),
row=1, col=2)
fig.update_xaxes(title_text="Harmonic k", fixedrange=True, row=1, col=2)
fig.update_yaxes(range=[0, 1.05], title_text="Amplitude 1/k",
fixedrange=True, row=1, col=2)
return fig
def controls(fig):
n = widgets.IntSlider(description="Harmonics", min=1, max=n_max, value=1)
# the defaults snapshot the arrays — the page's notebooks share one kernel
def update(n, sums=sums, partials=partials, n_max=n_max):
with fig.batch_update():
fig.data[1].y = sums[n - 1]
fig.data[2].y = partials[n - 1]
# joined harmonics in red, the newest in gold, the rest waiting
fig.data[3].marker.color = (
[RED] * (n - 1) + [GOLD] + [STEEL] * (n_max - n))
widgets.interactive_output(update, {"n": n})
# sound is a card that follows the slider: the previous clip stays in
# place while you drag (so the layout never jumps) and is swapped on
# mouse release (keyboard nudges settle on a short timer). The card is
# written through the Output's synced `outputs` trait, which works
# outside a kernel message, where display() output has no destination.
out = widgets.Output()
gate = icm_plotly.release_gate() # pointer state: is a slider mid-drag?
pending = []
dirty = []
def render(k=k, seg=seg, f0=f0, sr=sr):
kk = k[:n.value, None]
x = (np.sin(2 * np.pi * f0 * kk * seg) / kk).sum(axis=0)
x *= 0.125 / np.abs(x).max() # about -18 dBFS, a safe level
x[:441] *= np.linspace(0, 1, 441) # 10 ms fades: no clicks
x[-441:] *= np.linspace(1, 0, 441)
audio = Audio(x.astype(np.float32), rate=sr, normalize=False)
data, metadata = get_ipython().display_formatter.format(audio)
# one assignment swaps the old card for the new one in place, so
# the page never shows an empty card and nothing shifts
out.outputs = ({"output_type": "display_data",
"data": data, "metadata": metadata},)
async def settle():
await asyncio.sleep(0.25)
pending.clear()
if dirty and not gate.dragging:
dirty.clear()
render()
def on_change(_):
dirty.append(True)
if pending:
pending.pop().cancel()
pending.append(asyncio.ensure_future(settle()))
def on_release(change):
if not change["new"] and dirty:
if pending:
pending.pop().cancel()
dirty.clear()
render()
n.observe(on_change, names="value")
gate.observe(on_release, names="dragging")
if not os.environ.get("ICM_BOOK_BUILD"): # the build bakes no card
render()
return widgets.VBox([n, out, gate])
icm_plotly.show(figure, controls)
Try it live: change f0 to 110 in the widget cell and press ▶ Run, or
flip the recipe to odd harmonics only (a square wave) with
k = np.arange(1, 2 * n_max, 2) — then press play on the card under the
slider to hear the difference.
8. When something looks wrong#
Symptom |
Cause and fix |
|---|---|
pip chatter printed on the page |
The |
Code shows on the page that shouldn’t |
The marker isn’t on a whole line of its own, or isn’t the cell’s first line |
The widget is missing from the built page |
The directive path doesn’t match the notebook, or you edited the generated |
No figure at page load |
The widget cell carries |
The figure shows but the sliders never arrive |
The widget cell is missing its |
The page bakes megabytes of inert widget-state JSON |
ipywidgets (a |
The sliders take a while to arrive |
Expected only on a first visit — the kernel + packages are ~45 MB, served by the book itself, downloaded once, cached, and pre-warmed while the reader is on other pages; the figure is already on screen and the status pill bottom-right shows progress |
A slider draws garbage after another widget on the page has run, and re-running its own cell cures it |
The callback reads a top-level name ( |
The audio card plays at the same loudness whatever the amplitude slider says |
The card is built with |
The card clears on a drag but never comes back |
|
The whole widget vanishes once |
The empty-output rule in |
Slider drags feel laggy |
The |
The figure repaints in visible stages |
Multiple trace assignments per tick — wrap them in |
|
Outside this repo it isn’t installed — |
Before publishing
Remove this Interactive Template page from _toc.yml — it is an author
reference, not course content.