npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dkirkby/zipline

v0.1.1

Published

Animate a wavepacket pulse traveling along a line, rendered as SVG. Framework-independent; returns a native SVGSVGElement.

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/zipline

Quick 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). Add width, height, and optionally viewBox to 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-width attributes) 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.playing

loop 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 -> B
  • play({direction, mask, loop, playbackRate}) is the single entry point. It returns a Promise that resolves when the run completes (so multi-hop routes are an await chain). direction: "backward" runs time from Δt to 0 — the pulse travels from (x2, y2) to (x1, y1). Unlike the single-line player, loop defaults to false: a play() models a discrete transmission event. Calling play() 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's id/className as 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), and ziplineGroupFromLines(lineElements, shared), which adopts existing <line> elements (geometry, id, class, and stroke inherited) and restores them on destroy(). 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", so strokeWidth is 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 behind zipline.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-2

Sizing guidance (derivations in PLAN.md):

  • --keyframes 33 works 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 --tags

Pushing 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