Browser graph exploration: a Rust/WebAssembly engine rendering through WebGL2, force-directed layout, and a typed TypeScript client. Styling is declarative — you describe rules, not draw calls.
<GraphExplorer>, with a node-details
pane and a responsive layout. Physics on, drag a node and the
neighbourhood follows.
// npm install @benjamin-small/graph-explorer
import { mountGraph } from "@benjamin-small/graph-explorer/mount";
import { attachPointer } from "@benjamin-small/graph-explorer/pointer";
// The canvas must have a non-zero size before mounting.
const view = await mountGraph("canvas-id");
view.setStyle({
node_base: { color: "#4dd0e1", radius: 10, shape: "circle", label_visible: true },
node_rules: [
{ when: { attr: "kind", equals: "database" }, set: { shape: "square" } },
{ scale: { by: "rps", property: "radius", domain: [0, 9800], range: [7, 24] } },
],
edge_base: { color: "#4b858f", width: 1.3 },
});
view.load(JSON.stringify({ nodes: [...], edges: [...] }));
view.fitView();
attachPointer(view, canvas); // click, drag, wheel-zoom
view.start(); // render loop; parks itself when idle
Six subpaths, so a consumer pays only for what it imports. The wasm binary
is pulled in by /mount, not by the root import.
| Import | Provides |
|---|---|
@benjamin-small/graph-explorer | Types only — StyleSpec, AnimationSpec, SimulationSpec, NodeDetails, GraphSnapshot. |
…/mount | mountGraph(canvasId) → a GraphClient. Loads and initialises the wasm engine. |
…/pointer | attachPointer(client, canvas) — click, drag, double-click, wheel zoom. |
…/vim | attachVim(client) — modal keyboard navigation. |
…/react | <GraphExplorer> and useGraphSnapshot. React is an optional peer dependency. |
…/testing | FakeEngine — drive a GraphClient in tests with no GPU. |
The typed wrapper returned by mountGraph. Every method below is
safe to call after dispose() only in the sense that it throws a
clear error rather than reading freed wasm memory.
| Group | Methods |
|---|---|
| Data | load(json), loadRemote(seedJson), setFetchFn(fn), retryFailed() |
| Appearance | setStyle(spec), setBackground(hex), setAnimation(spec), setSimulation(spec), setNodeStyler(fn), setEdgeStyler(fn), setHaloFn(fn), setIdleFn(fn) |
| Camera | fitView(), pan(dx, dy), zoom(factor), zoomIn(), zoomOut(), resize(w, h), setPixelRatio(r) |
| Navigation | focus(id), selectId(id), select(delta), sibling(delta), leap(dir), descend(), back(), overview(), toggleView(), setViewMode(mode) |
| Interaction | nodeAt(x, y), nodeDetails(id), visibleNodes(), graphNodes(), storeNodes(), dragStart(id), dragTo(x, y), dragEnd(), unpinNode(id), unpinAll() |
| Layout | setPositions(map), setLayout(value), relayout() |
| Loop | start(), stop(), render(now), running, scheduled, dispose() |
| State | on(event, cb), subscribe(cb), getSnapshot(), selection(), preview, mode(), clearError() |
nodeAt, dragTo and the node-list accessors
all speak physical pixels — the canvas backing store, not CSS
pixels. On a HiDPI display those differ by devicePixelRatio.
One convention across the whole pointer surface, so a host applies the
conversion once.
Three plain JSON documents. All fields are optional; anything omitted keeps its current value rather than resetting to a default.
| Spec | Governs | Notable |
|---|---|---|
StyleSpec |
Colour, radius, shape, opacity, labels, background | Rules apply in order, last write wins — put role rules last or navigation stops being legible |
AnimationSpec |
Transitions, halo pulse, reduced motion | camera_follow re-frames on every navigation; fit_on_settle only after a drag |
SimulationSpec |
Force layout — link distance, charge, centring, drag release | enabled: false still runs one synchronous settle on load |
{
node_base: { color: "#7c8a99", radius: 10, shape: "circle" },
node_rules: [
// Data rules first…
{ when: { attr: "kind", one_of: ["database", "queue"] }, set: { shape: "square" } },
{ scale: { by: "rps", property: "radius", domain: [3, 9800], range: [7, 26] } },
{ when: { attr: "status", equals: "down" }, set: { color: "#ff1744" } },
// …role rules LAST, so the current node always reads as current.
{ when: { role: "focus" }, set: { color: "#ffffff" } },
],
}
Supported. The ceiling is the browser's WebGL context cap, not this library
— Chrome allows 16 live contexts and force-loses the oldest beyond that,
measured as exactly N − 16 losses at N = 24. Concurrent
mountGraph calls are serialised internally, so mounting several
in one tick is safe.
The render loop parks when nothing is moving and any mutating call wakes it. A pulsing halo keeps a cheap halo-only frame going; reduced motion or no halos lets it stop entirely. At 2000 nodes on a 2400×1600 backing store an idle frame costs about 1.3ms GPU-synced, a halo-only frame about 0.9ms, and a fully idle graph nothing at all.
The opposite trade, off by default: AnimationSpec.idle plays
ambient motion — drift, breathe, or shimmer — after ~2s of quiet, on a
reduced frame tier that skips styling, fetches and the simulation. Any
real activity suppresses it instantly; reduced_motion disables
it. Drift is draw-time by default (mode: "visual", node lists
keep reporting rest positions) or applied to real positions
("physical"). setIdleFn computes custom motion in
JS — one call per node per frame, so prefer the presets on large graphs.
Try it in the explorer demo's idle motion controls.