@benjamin-small/graph-explorer
v0.7.0
Published
Browser graph explorer — WebGL2 + WebAssembly. Progressive neighbourhood-at-a-time exploration with a force layout. Requires a DOM; not a Node.js library.
Downloads
903
Maintainers
Readme
@benjamin-small/graph-explorer
A typed TypeScript client for a Rust/WebAssembly graph engine. Runs in a browser only — it needs a DOM and WebGL2, and will not work under Node.js.
The engine owns the graph, the layout and the WebGL2 rendering; this package is
the framework-agnostic JavaScript surface on top of it — a GraphClient class with
typed events, an immutable snapshot suitable for React/Svelte binding, an
optional Vim-style key layer, and a built-in requestAnimationFrame loop.
The compiled wasm binary and its glue ship inside this package (pkg/), so
there is nothing extra to fetch or configure — importing the /mount entry
initializes it.
How to run it in a browser → — the canvas contract, DPI and resizing, bundler and MIME-type requirements, CSP, SSR, context loss, and what each of those looks like when it goes wrong. API reference and live demos →
Status: pre-1.0. The API has only grown so far, but minor versions may still change behaviour; the changelog calls out anything that alters existing output.
Install
npm install @benjamin-small/graph-explorerA bundler that understands the exports field and can emit .wasm as an asset
(Vite, Webpack 5, Rollup, esbuild) is required — the wasm URL is resolved with
new URL(..., import.meta.url).
Entry points
| Specifier | Contents |
| --- | --- |
| @benjamin-small/graph-explorer | Core: GraphClient, Emitter, toStore, initialSnapshot, and all types. No wasm import. |
| @benjamin-small/graph-explorer/mount | mountGraph(canvasId) — initializes the wasm module and mounts onto a canvas. This is the entry that pulls in the binary. |
| @benjamin-small/graph-explorer/pointer | attachPointer(client, canvas) — click, drag, double-click, wheel zoom, and the CSS-to-physical pixel conversion. |
| @benjamin-small/graph-explorer/vim | attachVim, defaultBindings, and their types — an optional host-owned key layer. |
| @benjamin-small/graph-explorer/react | <GraphExplorer> and useGraphSnapshot. React is an optional peer dependency. |
| @benjamin-small/graph-explorer/testing | FakeEngine, a test double implementing the raw engine interface. Test-only; keep it out of production bundles. |
The core entry is deliberately wasm-free, so importing types or writing framework adapters does not drag the binary into a bundle.
Usage
import { mountGraph } from "@benjamin-small/graph-explorer/mount";
import { attachVim } from "@benjamin-small/graph-explorer/vim";
const client = await mountGraph("graph"); // id of a <canvas> element
client.load(jsonGraph);
client.on("change", (id) => console.log("current node:", id));
attachVim(client); // optional h/j/k/l navigation
client.start(); // library-owned rAF render loop
// later
client.stop();
client.dispose();Consumers who want to drive their own scheduling can skip start() and call
client.render(now) from their own loop.
React
GraphClient implements the useSyncExternalStore contract directly, and
subscribe/getSnapshot are bound, so they can be passed detached:
const snap = useSyncExternalStore(client.subscribe, client.getSnapshot);The snapshot is reference-stable: it is only replaced when one of currentId,
mode, loading or error actually changes.
Svelte
toStore adapts the same snapshot to Svelte's readable-store contract
(immediate call on subscribe, value passed to the callback):
import { toStore } from "@benjamin-small/graph-explorer";
const graph = toStore(client);
// in markup: $graph.currentId, $graph.mode, $graph.loading, $graph.errorNeither framework is a dependency of this package.
Multiple explorers on one page
Supported. Mounted explorers are independent: they render side by side, and
disposing one does not disturb the others. Mounts are also safe to start
concurrently — mountGraph and the engine's own mount both serialize GPU
initialization internally, which matters because React's StrictMode
double-invokes effects and so issues two overlapping mounts from a single
component.
The ceiling is the browser's WebGL context limit, not this library. Chrome allows 16 live contexts per page and force-loses the oldest beyond that; a canvas whose context was lost goes blank. Measured on Chrome:
| Live explorers | Contexts lost | Errors | | --- | --- | --- | | 8 | 0 | 0 | | 16 | 0 | 0 | | 24 | 8 | 0 |
One explorer holds one context, so budget accordingly, and dispose explorers you are no longer showing rather than leaving them mounted off-screen. A discarded canvas does not release its context until it is garbage collected, so churning through mounts can trip the cap even when few are live at once.
Idle cost
The render loop parks when nothing is in motion, and any mutating call on
GraphClient restarts it. An untouched explorer costs nothing rather than
rebuilding and re-uploading its scene every 16ms forever.
running reports whether you asked for a loop; scheduled reports whether a
frame is pending right now. A parked loop is still running.
Three things stop a graph from ever reaching a full idle:
- Halos pulse by default, and halos mark the current node. A pulsing halo
takes a reduced path — the nodes, edges and labels of the previous frame are
reused and only the halo buffer is re-uploaded — but it still draws. Set
reduced_motionin theAnimationSpecto freeze the pulse and let the loop park completely. - An in-flight fetch always keeps the loop awake, because the fetch pump runs per frame. Parking with a request outstanding would strand its result.
- An idle animation, if you configure one (
AnimationSpec.idle, or asetIdleFncallback), keeps the loop alive by design: ambient motion — drift, breathe, or shimmer — that starts after ~2s of quiet, stops the instant anything real happens, and honoursreduced_motion. Drift can be draw-time only (mode: "visual", layout truth untouched) or applied to the real positions (mode: "physical"). It runs on a reduced frame tier that skips styling, stylers, fetches and the simulation.
If you drive render(now) yourself instead of calling start(), it returns
whether another frame is wanted, so you can idle on the same signal. If you
change something the engine cannot observe — a styler callback that starts
returning different answers without being re-registered is the realistic case —
force a repaint with client.raw.invalidate?.(). That one is on the raw engine
rather than on GraphClient, because every method the client does surface
already wakes the loop by itself.
Build
npm run build # tsc -p tsconfig.build.json -> dist/ (JS + .d.ts + maps)The wasm binary is produced separately with wasm-pack from the repository
root (npm run wasm for a dev build, npm run wasm:release for the optimized
one) and lands in pkg/.
