@dkirkby/zipline
v0.1.1
Published
Animate a wavepacket pulse traveling along a line, rendered as SVG. Framework-independent; returns a native SVGSVGElement.
Maintainers
Readme
@dkirkby/zipline
Animate a wavepacket pulse traveling along a line, rendered as SVG. The pulse carries an oscillating wave under a smooth envelope, fades in and out at the ends of its run, and can loop seamlessly — useful for illustrating signal propagation, group vs phase velocity, or just decorating a diagram edge.
The package is framework-independent: the core API creates and returns native SVG elements, suitable for Observable notebooks, vanilla JavaScript, or a thin React wrapper. SVG DOM work is done with d3-selection, the only runtime dependency. The math and design are documented in DESIGN_SPEC.md and PLAN.md in the repository.
Install
npm install @dkirkby/ziplineQuick start (vanilla JS)
import { createZiplineSvg } from "@dkirkby/zipline";
const { svg, zipline } = createZiplineSvg({
x1: 20, y1: 60, x2: 380, y2: 60, // line endpoints, SVG user units
f: 0.25, // pulse width as a fraction of the line length
N: 2.5, // wave cycles inside the pulse (see "Supported N" below)
alpha: 0.1, // peak amplitude as a fraction of the line length
r: 1.5, // phase velocity / group velocity
dt: 4, // traversal time, seconds
width: 400, height: 120,
});
document.body.appendChild(svg);
zipline.player().play();Quick start (Observable)
pulse = {
const { createZiplineSvg } = await import(
"https://cdn.jsdelivr.net/npm/@dkirkby/[email protected]/+esm"
);
const { svg, zipline } = createZiplineSvg({
x1: 20, y1: 60, x2: 620, y2: 60,
f: 0.25, N: 2.5, alpha: 0.1, r: 1.5, dt: 4,
width: 640, height: 120,
});
zipline.player().play();
invalidation.then(() => zipline.destroy());
return svg;
}Pinning the version (@0.1) keeps the notebook stable as new releases ship.
React
React is not a dependency; wrap the imperative API in a ref + effect:
import { createZipline } from "@dkirkby/zipline";
function Pulse(props) {
const ref = useRef(null);
useEffect(() => {
const zipline = createZipline(props).mount(ref.current);
zipline.player().play();
return () => zipline.destroy();
// rebuild when any endpoint or pulse parameter changes
}, [props.x1, props.y1, props.x2, props.y2,
props.f, props.N, props.alpha, props.r, props.dt]);
return <svg ref={ref} width={640} height={120} />;
}Ways to mount
createZiplineSvg(options)— one call that returns a new standalone<svg>(plus the zipline controller). Addwidth,height, and optionallyviewBoxto the options.createZipline(options).mount(svgOrGroup)— build the animated<g>and append it to an existing SVG container; use this to put several animated lines on one canvas.ziplineFromLine(lineElement, options)— replace an existing<line>in place. Geometry (x1 y1 x2 y2) and styling (stroke,stroke-widthattributes) are inherited unless overridden;destroy()restores the original line.- Many lines sharing one pulse and clock — use a group instead of independent ziplines; see Synchronized groups.
Parameters
| Option | Spec symbol | Meaning | Constraint |
| --- | --- | --- | --- |
| x1 y1 x2 y2 | (x₁,y₁), (x₂,y₂) | line endpoints, SVG user units | distinct points |
| f | f | pulse width as a fraction of the line length L | 0 < f < 1 |
| N | N | wave cycles inside the pulse | needs a precomputed table |
| alpha | α | peak transverse amplitude as a fraction of L | > 0 |
| r | r | phase velocity / group velocity v_p / v_g | finite |
| dt | Δt | time for the pulse to traverse the line, seconds | > 0 |
| stroke | — | stroke color (default currentColor) | |
| strokeWidth | — | stroke width in user units (default 2) | |
| className | — | class attribute for the group | |
The pulse's group velocity is v_g = L(1−f)/Δt; its center covers the middle L(1−f) of the line so the pulse never sticks out past the endpoints.
Controller and player
createZipline and ziplineFromLine return a Zipline; createZiplineSvg
returns { svg, zipline } with the same controller inside:
| Member | Description |
| --- | --- |
| node | the <g> element (three children: lead <line>, pulse <path>, tail <line>) |
| duration | Δt in seconds |
| update(t) | render the pose at time t (clamped to [0, Δt]); attribute-only writes |
| mount(parent) | append to an <svg> or <g>; returns the zipline |
| player(options?) | create a requestAnimationFrame driver |
| destroy() | remove from the DOM, stop players (and restore a replaced line) |
update(t) is the whole animation contract — drive it from any clock you
like. The built-in player is a convenience:
const player = zipline.player({ loop: true, playbackRate: 1 });
player.play(); // player.pause(), player.seek(t), player.t, player.playingloop defaults to true: the amplitude fades to zero at both ends
(A(0) = A(Δt) = 0), so the loop restart is visually seamless. With
loop: false the player stops exactly at t = Δt; play() then restarts
from 0.
Synchronized groups
For many lines sharing one pulse shape and clock (same f, N, alpha,
r, dt — lengths, angles, and colors free per line), use a group. It
renders the pulse once per frame in unit-length coordinates and reuses it
for every line via SVG <use> and a static per-line transform, so the
JavaScript/DOM cost per frame is independent of the number of lines. (Browser
rasterization still scales with the pixels the lines cover — the demo's
weight-matrix stress page measures where that budget runs out.)
import { createZiplineGroup } from "@dkirkby/zipline";
const group = createZiplineGroup({
f: 0.3, N: 2, alpha: 0.09, r: 2, dt: 1.2,
lines: [
{ id: "ab", x1: 60, y1: 130, x2: 430, y2: 50 },
{ id: "bc", x1: 430, y1: 50, x2: 800, y2: 130, className: "route" },
],
}).mount(svg);
await group.play({ mask: "#ab" }); // pulse A -> B; "bc" stays straight
await group.play({ mask: "#bc" }); // then B -> C
await group.play({ mask: "#bc", direction: "backward" }); // reply C -> Bplay({direction, mask, loop, playbackRate})is the single entry point. It returns a Promise that resolves when the run completes (so multi-hop routes are anawaitchain).direction: "backward"runs time from Δt to 0 — the pulse travels from (x2, y2) to (x1, y1). Unlike the single-line player,loopdefaults to false: aplay()models a discrete transmission event. Callingplay()mid-run restarts with the new options.- Masks select which lines animate; the rest keep the exact appearance of
a straight line (they are a straight line — an idle group costs nothing
per frame). Each member
<use>carries the spec'sid/classNameas DOM attributes, so the primary mask form is a CSS selector ("#ab",".route"); arrays of ids/indices and predicates also work. - A mask naming an unknown id or index throws without disturbing a run in progress; a selector matching nothing runs with every line straight.
- Also available:
pause()/resume()/stop(),addLine(spec)/removeLine(ref), andziplineGroupFromLines(lineElements, shared), which adopts existing<line>elements (geometry, id, class, and stroke inherited) and restores them ondestroy().seek(t)on an idle group activates the most recent mask (or all lines) and leaves the group paused, for scrubbing;stop()returns everything to the straight resting state. - Member strokes use
vector-effect="non-scaling-stroke", sostrokeWidthis in screen units regardless of line length.
Low-level API
The building blocks behind the renderers are also exported, for custom uses such as driving a canvas/WebGL renderer or scripting the math directly:
createPlayer(zipline, options?)— the rAF driver behindzipline.player().validateParams(params)— throws on the first invalid parameter;makeKinematics(params)— the derived constants (L, θ, v_g, t_f, …) used everywhere below.sMinus(kin, t)/sPlus(kin, t)— the pulse endpoints s±(t) along the line;sigmaToS(kin, t, sigma)— co-moving coordinate σ to distance s;tauOf(kin, t)— normalized carrier time wrapped into [0, 1);amplitude(kin, t)— the fade A(t).getPulseTable(N)/validatePulseTable(json)/registerPulseTable(json)/listSupportedN()— the table registry and schema validation.makeControlPointBuffer(table)+controlPointsAt(table, tau, out)— allocation-free interpolation of the cubic-Bezier control points at any τ (including the τ > 1/2 symmetry mapping), in pulse-local (σ, z) coordinates.
All exports are typed; see dist/index.d.ts for exact signatures.
Behavior notes
- r > 1 — the carrier wave advances through the envelope (phase faster than group). r < 1 — it slips backward, as it should when the phase velocity is below the group velocity. r = 1 — the carrier is frozen in the envelope. The carrier slips N(r−1)(1−f)/f cycles per traversal.
- f ≥ 0.5 — the fade-in and fade-out windows overlap (each lasts t_f = fΔt/(2(1−f)) > Δt/2), so the pulse peaks below αL. This is handled gracefully rather than rejected.
- Rendering uses precomputed cubic-Bezier keyframes; the shipped tables keep the worst-case shape error ≤ 1% of the pulse amplitude (sub-pixel at typical sizes) and RMS error ~0.1%.
Supported N
Tables for N = 1, 1.5, 2, 2.5, 3, 4, 5 ship with the package
(listSupportedN() returns the list at runtime). Using any other N throws.
Adding a new N
The tables are produced by a standalone Python script (requires uv; not included in the npm distribution — clone the repo):
uv run tools/precompute.py --N 3.5 --keyframes 33 --splines 25 \
--grid 512x128 --out data/pulse-N3.5.json \
--mse-target 1e-6 --max-error-target 1e-2Sizing guidance (derivations in PLAN.md):
--keyframes 33works for every N — the τ-direction interpolation error is independent of N.--splines: about 7 segments per carrier cycle, i.e.ceil(7 * N)with a floor of 8. The script prints the measured end-to-end MSE and max error and exits nonzero if the targets are missed, so you can simply increase the count until it passes.
Then either register the JSON at runtime:
import { registerPulseTable } from "@dkirkby/zipline";
registerPulseTable(await (await fetch("pulse-N3.5.json")).json());or make it permanent: drop the file in data/, add the value to SHIPPED_N
in tools/build_tables.py, and run npm run codegen && npm run build.
Loading validates the JSON (array lengths against numKeyframes/numSplines,
uniform τ spacing) and throws a descriptive error if it is malformed.
Development
npm install
npm test # Vitest (jsdom): math, tables, renderer, player, mounting, groups
npm run test:py # Python tests for the precompute tool and shipped tables
npm run typecheck # tsc --noEmit
npm run demo # interactive demo at http://localhost:5173
# (plus /weights.html, a group stress test with live metrics)
npm run precompute # regenerate data/*.json (needs uv)
npm run codegen # embed data/*.json into src/generated/tables.ts
npm run build # codegen + tsup -> dist/Layout: src/ TypeScript source (src/generated/tables.ts is generated —
do not edit), data/ canonical precomputed JSON, tools/ Python precompute
scripts and codegen, demo/ Vite demo, test/ Vitest suites. Only dist/
is published to npm.
Releasing
Releases are automated by .github/workflows/release.yml using npm trusted
publishing — no tokens or secrets. From a clean checkout of main:
npm version patch # or minor / major — bumps package.json, commits, tags vX.Y.Z
git push && git push --tagsPushing the v* tag triggers the workflow, which runs typecheck, the JS
tests, and the build, verifies the tag matches the package.json version,
and publishes to npm with a provenance attestation. If that version is
already on npm the workflow skips publishing, so re-pushing a tag is
harmless. Watch the run under the repository's Actions tab.
License
MIT
