Visualization
Backends & export¶
A PlotSpec is a semantic description of a figure
— a kind, some layers of array data, typed axes, a theme. It holds no drawing
state and imports no plotting library. Rendering is the separate step that
turns that description into a concrete artifact: a matplotlib figure, an
interactive plotly page, a three.js web payload, or a serialized JSON document.
The same spec drives all four. Because the spec is the single source of truth,
every fluent tweak — rescale(x="log"), theme("dark"), .recolor(...) —
renders identically on whichever backend draws it. You pick the backend by name,
or let the file extension pick it for you.
import tsdynamics as ts
spec = ts.Lorenz().to_plot_spec(
components=["x", "y", "z"], final_time=50.0, dt=0.01, ic=[1.0, 1.0, 1.0]
)
spec.plot() # draw with the default backend (matplotlib)
spec.save("lorenz.png") # → matplotlib (raster / vector image)
spec.save("lorenz.html") # → plotly (a self-contained interactive page)
spec.save("lorenz.json") # → json (a lossless, re-loadable payload)
spec.render("threejs") # → a three.js BufferGeometry-ready dict
Everything on a spec is inherited by results, trajectories and systems: a
Trajectory or an analysis result carries the same .plot() / .save() /
.render() methods (they build a spec, then render it), and so does the
ts.viz.plot(...) composition result.
The four backends¶
| Backend | Draws | 3-D | Interactive | Web export | Data export | Needs |
|---|---|---|---|---|---|---|
| matplotlib | every kind | ✓ | matplotlib |
|||
| plotly | 2-D + 3-D + composite | ✓ | ✓ | ✓ | plotly |
|
| three.js | line / points / surface geometry | ✓ | (in the browser) | ✓ | ✓ | — |
| json | serializes any kind | ✓ | ✓ | — |
The two data-export backends (json, threejs) return a serializable
payload rather than a live figure — they draw nothing themselves, they hand
a browser (or a cache) the data to draw. Both are pure standard library over the
spec IR, so they need no third-party dependency and always register.
matplotlib — the universal reference renderer¶
matplotlib is the default and the fallback. It declares kinds=None —
meaning it can draw every PlotKind, in 2-D and 3-D — so it is the backend a
no-backend= render resolves to, and the one dispatch falls back to when a
partial backend declines a spec. If you never think about backends, this is the
one you get. It renders to any raster / vector format matplotlib supports
(.png / .pdf / .svg / .jpg) and writes .mp4 / .gif
animations via ffmpeg / pillow.
fig = spec.render("matplotlib") # a matplotlib.figure.Figure
spec.save("lorenz.pdf") # a vector figure for a paper
plotly — interactive, web-native¶
plotly produces an interactive figure — pan, zoom, hover, and orbit a 3-D
attractor with the mouse. It draws the 2-D and 3-D kinds and tiles composites
natively (a make_subplots grid, each panel on its own xy / scene cell), and
it serializes to a self-contained HTML page with .save("*.html") — the
zero-extra-dependency way to ship a live figure into docs, a dashboard, or a
paper-as-web-page.
fig = spec.render("plotly") # a plotly.graph_objs.Figure
spec.save("lorenz.html") # one self-contained interactive page
plotly declines only the deferred animation kinds and — for animation — an animated composite and the animated spatial-field movie (both are matplotlib's job); those fall back automatically. Its animated-HTML export is a real-time comet you can rotate while it plays — see Animation.
json — lossless serialization¶
The json backend serializes the whole spec to the versioned JSON envelope
(tsdynamics.viz.export.to_json) — every layer, axis, annotation, color range,
theme, and meta field, with NumPy arrays as nested lists. It round-trips
exactly, so a spec computed on one machine can be cached, shipped to a web
frontend, or replayed without re-running the analysis and without a plotting
library installed.
spec.render("json", path="lorenz-spec.json") # write the payload to a file
text = spec.render("json", raw=True) # …or get the JSON string back
>>> from tsdynamics.viz.export import to_json, from_json
>>> import numpy as np
>>> spec2 = from_json(to_json(spec))
>>> spec2.kind == spec.kind and len(spec2.layers) == len(spec.layers)
True
>>> np.allclose(spec2.layers[0].data["x"], spec.layers[0].data["x"])
True
By default the renderer returns a RenderResult carrying the JSON string as its
.payload and mimetype="application/json"; pass raw=True for the bare string,
indent=N to pretty-print, or path= to write it straight to a file (it returns
the Path). The envelope is versioned (schema_version) so the schema can evolve
without breaking old payloads — from_json reads current, legacy-versioned, and
bare-to_dict() documents alike.
three.js — a BufferGeometry web payload¶
The threejs backend lowers a spec to a three.js BufferGeometry-ready JSON
payload — a plain dict of flat float / int lists a browser front-end turns into
an orbitable WebGL scene with no Python kernel in the loop. It is the natural
format for 3-D attractors on the web. It is covered in full below.
How a backend is chosen¶
PlotSpec.render(backend=...) delegates to the dispatcher, which:
- Registers the installed in-tree backends lazily, on the first render —
never at import, so
import tsdynamicsstays plot-free. A backend whose library is absent simply does not register. - Selects a backend — the one you named, or a default.
- Negotiates capabilities — if the chosen backend cannot draw this spec's kind (or its 3-D-ness), it falls back to a capable one, warning you.
- Reports honoring gaps — one consolidated warning names any style key, animation knob, or theme field the chosen backend will silently ignore.
The default is deterministic¶
With no backend=, matplotlib is preferred whenever it is installed — it is
the universal reference renderer, selected by name, so the choice never depends
on which backends happen to be registered or in what order. If matplotlib is
absent, dispatch picks the first registered drawing backend that can handle
the spec (the data-export backends are skipped — a caller with no backend=
wants a figure, not a payload).
Named selection, with aliases¶
Naming a backend reaches it directly. The friendly aliases are accepted
("mpl" → matplotlib), and an unknown name raises a clear KeyError:
>>> spec.render(backend="mpl") # the alias resolves
>>> spec.render(backend="gnuplot")
Traceback (most recent call last):
...
KeyError: "No renderer registered as 'gnuplot'."
Capability negotiation & VisualizationDegraded¶
Not every backend can draw every kind, and not every backend honors every style
knob. Rather than fail or silently misrender, the dispatcher degrades loudly
with a VisualizationDegraded warning. There are two triggers.
1. Backend fallback. You named a backend that declines the spec, and dispatch routed to a capable one (matplotlib) instead:
>>> px = ts.Lorenz().to_plot_spec(components="x", final_time=20, dt=0.02, ic=[1.0, 1.0, 1.0])
>>> py = ts.Lorenz().to_plot_spec(components="y", final_time=20, dt=0.02, ic=[1.0, 1.0, 1.0])
>>> comp = ts.viz.plot(px, py, layout="stack", animate=True) # an animated composite
>>> comp.render("plotly")
VisualizationDegraded: backend 'plotly' cannot draw a 'composite' spec;
falling back to 'matplotlib'.
2. Knob degradation. The chosen backend draws the spec, but it does not honor one or more of the style keys / animation directives / theme fields the spec carries. The dispatcher collects all of them and emits one consolidated warning before drawing:
>>> spec = ts.Lorenz().to_plot_spec(components=["x", "y", "z"], final_time=20, dt=0.02, ic=[1.0, 1.0, 1.0])
>>> spec.style(linestyle="dashed") # three.js has no line dashing
>>> spec.render("threejs")
VisualizationDegraded: threejs: ignoring linestyle
Each backend's honored vocabulary is an enforced contract — see
Styling & themes for the per-key honored_by table and the
caps.style_honoring_gaps helper that drives these warnings. json is exempt
(it serializes every field faithfully, so it drops nothing).
VisualizationDegraded is a UserWarning, so the call still returns a figure — a
hard failure (no capable backend registered at all) raises
VisualizationNotInstalled instead. Under filterwarnings=["error"] (a strict
test suite) a degradation becomes an error; scope an
ignore::tsdynamics.viz.render.caps.VisualizationDegraded — or a pytest.warns
around the one call — narrowly, so a genuinely unintended degradation still
surfaces everywhere else.
Saving by extension¶
PlotSpec.save(path) picks the backend from the file extension when you do not
name one — the fast path for "just write me the figure".
| Extension | Backend | Produces |
|---|---|---|
.png .pdf .svg .jpg |
matplotlib | a raster / vector image |
.html |
plotly | a self-contained interactive page |
.json |
json | the lossless, re-loadable payload |
.mp4 .gif (animated spec) |
matplotlib | a movie (ffmpeg / pillow) |
.html (animated spec) |
plotly | a real-time, rotatable-while-playing page |
spec.save("fig.png") # matplotlib image
spec.save("fig.html") # interactive plotly page
spec.save("fig.png", size=(1600, 1200), dpi=200) # pixel size + resolution
spec.save("fig.svg", backend="matplotlib") # force a backend explicitly
.save returns the path it wrote. A still save of an animated spec renders
its final, fully-revealed frame; a movie save (.mp4 / .gif) drives the
matplotlib FuncAnimation. size is in pixels (converted to a matplotlib
figure size via dpi), and fps / dpi override the spec's animation settings
for that one write. See Animation for the movie details.
three.js export¶
Besides the matplotlib and plotly renderers, a spec can be exported to a three.js BufferGeometry-ready payload — a plain JSON object a browser front-end turns into an orbitable WebGL scene with no Python kernel in the loop. It is the natural format for 3-D attractors on the web (docs, dashboards, papers-as-web-pages).
Exporting a payload¶
The threejs renderer ships in-tree and needs no extra dependency (it is
pure Python over the spec IR):
import tsdynamics as ts
# A 3-D attractor, coloured by elapsed time.
spec = ts.Lorenz().to_plot_spec(
components=["x", "y", "z"], color_by="time",
final_time=50.0, dt=0.01, ic=[1.0, 1.0, 1.0],
)
payload = spec.render("threejs") # a RenderResult carrying the dict
spec.render("threejs", path="lorenz.json") # …or write it straight to a file
>>> result = spec.render("threejs")
>>> tj = result.payload
>>> list(tj.keys())
['schema_version', 'kind', 'title', 'geometries', 'metadata']
>>> tj["geometries"][0]["type"] # a 3-D line → a "line" geometry
'line'
>>> "colors" in tj["geometries"][0] # color_by="time" adds a per-vertex "c" channel
True
A 2-D phase portrait exports the same way (its LINE / SCATTER layers are
lifted to z = 0).
The payload schema¶
{
"schema_version": 2,
"kind": "phase_portrait_3d",
"title": "...",
"geometries": [
{
"type": "line | points | surface",
"label": "...",
"positions": [x0, y0, z0, x1, y1, z1, ...], // FLAT, Float32-ready
"indices": [0, 1, 1, 2, 2, 3, ...], // line segments / surface triangles
"material": { // the three.js-honored style keys
"color": "#1f77b4", "linewidth": null,
"markersize": null, "alpha": null, "zorder": null
},
"colors": [r0, g0, b0, r1, g1, b1, ...] // OPTIONAL, per-vertex 0-1 (a "c" gradient)
}
],
"metadata": {
"schema_version": 2,
"labels": {"x": "x", "y": "y", "z": "z"},
"units": {"x": "", "y": "", "z": ""},
"bounds": {"x": [min, max], "y": [min, max], "z": [min, max]},
"camera": {"position": [x, y, z], "target": [x, y, z], "up": [x, y, z]},
"theme": {"background": "...", "palette": ["#...", "..."]},
"animation": { // ONLY when the spec is animated
"fps": 30.0, "duration": null, "n_frames": null,
"loop": true, "pingpong": false,
"trail_length_samples": 200, // comet tail length; null = persistent
"head": true, "head_size": 6.0, "head_color": null,
"n_samples": 5001 // vertices on the longest animated line
}
}
}
A web frontend reads each geometry's flat positions into a Float32Array, the
optional colors into a second Float32Array (vertex colours), and indices
into the draw-index buffer — exactly the three inputs a THREE.BufferGeometry
wants. metadata.camera seeds the initial view, and metadata.theme gives the
scene background + the palette for auto-coloured layers.
A few honest exclusions the payload makes deliberately:
materialcarries only the style keys three.js genuinely honors —color/linewidth/markersize/alpha/zorder(→renderOrder).linestyle, marker shape, andcmapare not emitted (the loader has no way to honor them — the per-vertexcolorsuse a fixed built-in colormap ramp), so they are dropped rather than serialized as dead fields.capswarns about any of them.colorsappears only for a genuine per-vertex"c"gradient (color_by="time", a speed field, …). A solid colour rides once onmaterial.color— repeating it per vertex would be waste.metadata.themecarries onlybackgroundandpalette— the other theme fields (foreground, font, grid) have no analogue in a bare three.js scene.
metadata.animation is present only for an animated spec (e.g.
spec.to_plot_spec(..., animate=True)); a static export omits it and the geometry
buffers are byte-for-byte the non-animated payload. When it is present, the loader
plays a reveal comet — a faint full-curve backdrop with a bright windowed
trail and a head marker sweeping the line — by advancing
geometry.setDrawRange(start, count) per frame (no buffer re-upload). Because
OrbitControls is independent of that draw-range update, you can orbit the
attractor with the mouse while it plays.
The reveal comet sweeps a line index buffer (LINE / LINE3D), so the
animation block is emitted only when the spec has a line layer. An animated
points-only (SCATTER / MARKERS) or surface-only spec has nothing to
reveal: the exporter drops the animation to a valid static payload (which the
loader still renders, auto-rotating) and emits a VisualizationDegraded warning
— the animation is never silently dropped.
Composite payloads¶
A composite spec has no top-level geometry — its content is in the panels. The
threejs exporter detects a composite, lowers each panel recursively, and emits a
"panels" list (each with its own geometries + metadata, a grid cell, and
a layout offset for dropping every panel into one shared scene) plus a
top-level layout block. geometries is then always empty at the top level.
The reference loader¶
tsdyn-threejs-loader.js is a tiny
reference ES module that does the whole conversion — BufferGeometry for line
(LineSegments), points (Points) and surface (a normals-computed Mesh),
per-vertex colours, a camera from metadata.camera, OrbitControls, and (when
metadata.animation is set) a setDrawRange-driven reveal comet with a
play/pause overlay:
import { renderThreejsPayload } from "./tsdyn-threejs-loader.js";
const payload = await (await fetch("lorenz.json")).json();
renderThreejsPayload(document.querySelector("#viewer"), payload);
In the system catalogue¶
Every 3-D ODE system page in the catalogue embeds
exactly this pipeline: at docs-build time an animated PHASE_PORTRAIT_3D payload
of the attractor is exported, inlined into a small self-contained HTML document
(the import map + the inlined payload + this reference loader), and dropped onto
the page in an <iframe> — so the headline figure on a chaotic-attractor page is
the live, orbitable comet rather than a static PNG. Maps, DDEs, spatial-field
and stiff / discontinuous systems keep their static figure, and any page degrades
to the PNG when WebGL or the CDN is unavailable. The emitter is
docs/_tooling/threejs_viewer.py.
Live demo¶
The exact payload the 3-D catalogue pages ship — the Lorenz attractor as an animated reveal comet (a thin teal trail fading into the dark stage behind an indigo state head, sweeping the full faint attractor) rendered in your browser by the reference loader. The camera is yours: drag to orbit, scroll to zoom — the comet keeps playing while you move it. The play/pause + restart controls sit bottom-left.
Pitfalls¶
| Situation | What happens / what to do |
|---|---|
import tsdynamics and expecting a plot library |
None is imported — ts.viz is lazy, and backends register on first render. |
| No plotting library installed | matplotlib / plotly renders raise VisualizationNotInstalled; json / threejs still work (they need no dependency). |
| Naming a backend that declines the spec | Dispatch warns (VisualizationDegraded) and falls back to matplotlib. |
| A style / theme knob a backend ignores | One consolidated VisualizationDegraded names every dropped knob (json is exempt). |
Strict filterwarnings=["error"] test suite |
A degradation becomes an error — scope a narrow pytest.warns / ignore around the one call. |
Saving .mp4 with no ffmpeg |
matplotlib raises — install ffmpeg, or save .gif (pillow) / .html (plotly). |
.render("json") returns a RenderResult, not a string |
Pass raw=True for the bare string, or read .payload. |
See also¶
- Plotting — the front door — the
to_plot_specfront door every render starts from. - Styling & themes — the per-key
honored_bycontract these degradation warnings enforce. - Composition — how composites tile natively in matplotlib and plotly (and lower to panelled payloads in three.js / json).
- Animation — the movie export formats (
.mp4/.gif/.html) and the three.js reveal comet.