Engineering notes
How the widget pages on this site are actually built, and the dead end that led there.
How a widget's front-end code gets into a static page
TrajectoryScrubber, MotilityCellPaintingView, and Celldega's
Clustergram are all anywidget widgets: each
one declares _esm = Path(...)/"static"/"foo.js", and anywidget reads that file's
text content into the widget's serialized state at construction time — the
actual JS source (our bundled deck.gl code, ~2.2MB per widget) travels as a plain string trait
alongside the widget's data traits (frame_urls, trajectories, etc.).
Once a notebook cell displaying the widget is executed and saved, that whole state — JS source
included — is embedded in the .ipynb file itself, under
metadata.widgets["application/vnd.jupyter.widget-state+json"]. Nothing needs to be
published anywhere for this part; it's just a big JSON blob sitting in the notebook.
The dead end: classic ipywidgets static embedding doesn't work for anywidget
The obvious path is nbconvert's HTMLExporter or
ipywidgets.embed.embed_minimal_html — both pull in
@jupyter-widgets/html-manager, a requirejs/AMD-based renderer, from a CDN at
page-load time. This works fine for classic ipywidgets controls (sliders, dropdowns), but
fails for anywidget widgets specifically, and no amount of CDN version pinning
fixes it. The reason: anywidget's real npm package
(check its package.json)
declares "type": "module" with "main": "dist/index.js" and no
UMD/AMD build at all. Requirejs's classic define()/require() system
cannot consume a pure ES module — the two module systems are fundamentally different, not a
version-compatibility gap.
strict mode: use allowUnionTypes to allow union type keyword (a red herring —
that's just Ajv being noisy about the widget-state schema, harmless) followed by
anywidget.js ... 404 and Falling back to https://cdn.jsdelivr.net/npm/ for
anywidget@~0.9.* — the widget never actually renders after that fallback, regardless of
network reachability or html-manager version.
The fix: anywidget's own portability spec (AFM)
anywidget defines the Anywidget Front-End Module (AFM)
specification precisely for this situation — "widget reuse... beyond Jupyter, including...
standalone web applications." A widget's _esm is just an ES module with
initialize({model, signal}) and render({model, el, signal, host})
lifecycle hooks; a host platform just needs to implement a small
AnyModel interface (get/set/on/
off/save_changes/send) and call those hooks. This site's
host is site/vendor/afm_host.js — about 60 lines, no dependencies, no CDN calls at
all. site/build_site.py's export_widget_page pulls the widget's
_esm/_css and traits straight out of the executed notebook's saved
state, writes them as {page}_widget.mjs / {page}_widget.css, and
generates a small HTML page that imports the host and calls renderAFM(...).
Gotchas, if you extend this
- One
AnyModelper notebook, assumed.export_widget_pagerequires exactly one anywidget model in the notebook's saved state and errors otherwise.0_viz_3_correlation_explorer.ipynbalso has a classicipywidgets.interactdropdown cell (needs a live kernel to recompute on change) — that part is silently dropped; only the Clustergram itself is exported. set()/save_changes()semantics matter. The host mimics ipywidgets' real staged-write pattern:model.set(key, val)stages a pending value,model.save_changes()commits it and fireschange:keylisteners. A naive "set commits immediately" implementation would still mostly work but doesn't match what a widget's own JS expects if it ever readsmodel.get()between aset()and the matchingsave_changes().- Escape
</script>in embedded JSON. The widget state is inlined as a JSON literal inside a<script type="module">tag;build_site.pyreplaces any literal</scriptsubstring with an escaped form before embedding, since a raw occurrence would prematurely close the tag. - Serve over HTTP, not
file://. ES moduleimport()is blocked under thefile://protocol by browser security policy — alwayspython -m http.server(or similar), never open the HTML file directly. - Can't fully test WebGL-based widgets outside a browser. Node can import and
syntax-check a widget's
.mjsand confirm it exports arenderfunction, but deck.gl-based widgets (scrubber, linked view) referencewindow/DOM at module load time, so a real browser is needed to confirm they actually draw anything. - Resolve relative URLs against the page, not against
afm_host.js. A relative specifier passed intorenderAFM({{esmUrl: "./foo.mjs", ...}})gets resolved by theimport()call insideafm_host.js— relative to its own location (vendor/), not the page that calledrenderAFM. Hit this directly: passing a bare relative path producedGET .../vendor/scrubber_widget.mjs(404) instead of.../scrubber_widget.mjs. Fix: resolve to an absolute URL in the page's own inline script first, whereimport.meta.urlcorrectly refers to the page itself:new URL("./scrubber_widget.mjs", import.meta.url).href.
No publishing needed
None of this requires publishing our widget JS to npm or the Python package to PyPI. anywidget
itself (the generic host-bridge concept) is already a public package, but we don't consume it as
a package at all in this approach — afm_host.js is a from-scratch, from-spec
implementation, and every widget's actual JS ships as a plain file alongside the HTML that uses
it, exactly like the rest of this static site.