graph-explorer / browser guide

Running graph-explorer in a browser

What the browser demands of you — the canvas contract, DPI, serving the wasm, CSP, SSR, teardown — and the exact failure each one produces when it is wrong.

Running graph-explorer in a browser

The site documents what the API does. This documents what the browser demands of you — the canvas contract, how the wasm binary gets served, what a real page has to do about resizing, DPI, idling and teardown, and the failures you get when one of those is wrong.

Everything here is specific to @benjamin-small/graph-explorer. None of it is optional folklore: each rule below exists because breaking it produces a particular, reproducible failure, and the failure is named.


1. What the browser must provide

Requirement Why Floor
WebGL2 The renderer's only backend. wgpu is the abstraction; the backend is pinned to GL. Chrome/Edge 56, Firefox 51, Safari 15
WebAssembly The engine is Rust compiled to wasm. Same era as WebGL2 — anything with WebGL2 has it
A DOM mountGraph looks the canvas up by id and hands it to wgpu.

There is no software fallback and no 2D-canvas path. A browser without WebGL2 cannot run this library; feature-detect and show something else:

const ok = !!document.createElement("canvas").getContext("webgl2");

This is not a Node.js library. It has no server-side rendering path — the module reaches for document and a GPU adapter during mount. See §9 Frameworks and SSR for how to keep it out of a server bundle.


2. Getting the wasm to the browser

The compiled binary ships inside the npm package (pkg/). There is nothing to host separately and nothing to configure — but it has to survive your build.

Your bundler must emit .wasm as an asset

The URL is resolved with new URL("../pkg/…_bg.wasm", import.meta.url), so the bundler fingerprints and copies it. Vite, Webpack 5, Rollup and esbuild all handle this. A bundler that inlines or drops unknown asset types will produce a module that builds cleanly and fails at init().

The repository's own containerized consumer check asserts the asset survives — ls dist/assets/*.wasm after a vite build from a clean npm install. See docker/README.md.

Vite dev: exclude it from dependency pre-bundling if the wasm 404s

Vite's dep optimizer rewrites modules into .vite/deps, which relocates them relative to the .wasm and breaks new URL(…, import.meta.url). The URL then points somewhere the file is not, the dev server answers the 404 with index.html, and the loader reports a WebAssembly magic-word error against <!do… — which is HTML, not a corrupt binary.

// vite.config.ts
export default defineConfig({
  optimizeDeps: { exclude: ["@benjamin-small/graph-explorer"] },
});

This repository carries exactly that exclusion for a sibling wasm package (see vite.config.ts) after hitting the failure above. Reach for it when you see that symptom; it is not needed for production builds, where pre-bundling does not run.

Serve .wasm as application/wasm

The loader tries WebAssembly.instantiateStreaming first. Streaming requires the correct Content-Type; with anything else the glue falls back to arrayBuffer() + instantiate() and logs:

WebAssembly.instantiateStreaming failed because your server does not serve Wasm with application/wasm MIME type. Falling back to WebAssembly.instantiate which is slower.

So a wrong MIME type works — it just buffers the whole ~3.7 MB before compiling instead of compiling as it arrives, and leaves a warning in every user's console. Most static hosts get this right; hand-rolled Express and some CDN rewrite rules do not.

Content-Security-Policy

If your page sets a CSP, script-src needs 'wasm-unsafe-eval' — compiling a WebAssembly module is gated by that source expression, and without it the browser refuses the module outright. It does not imply 'unsafe-eval'; it permits wasm compilation and nothing else.

Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'

Nothing else about the library is unusual for CSP: no inline scripts, no workers, no eval. No cross-origin isolation (COOP/COEP) is needed either — the engine is single-threaded and uses no SharedArrayBuffer.

Caching

The bundler fingerprints the .wasm filename, so it is safe to serve with a long-lived immutable cache. It is by far the largest thing your page fetches (~3.7 MB raw, ~1.4 MB gzipped as of v0.6.0 — see CHANGELOG), and it never changes for a given version. Enabling Brotli on it is worth more than any other transfer optimization you can make here.


3. The canvas contract

Five rules. The first is fatal if broken; the rest are visual.

3.1 Size the canvas before mounting

GraphView.mount reads canvas.width / canvas.height at GPU-init time, and rejects a zero-sized canvas:

canvas has zero size; size it before mount()

That check is deliberate. A zero-sized wgpu surface is a validation error, and on wasm a validation error is a panic that takes the whole instance with it — a dead engine for the rest of the session, not a degraded one. A clear rejection is the better failure.

clientWidth/clientHeight can read 0 if you measure before the browser's first layout pass, and in a hidden or backgrounded tab both the element and the viewport measure 0. Fall back to a nominal size so startup completes, and let your resize handler correct it when the tab is actually shown.

3.2 The backing store is in physical pixels

canvas.width is the drawing buffer; CSS width is the displayed box. On a HiDPI display they differ by devicePixelRatio. Size the buffer to cssSize × ratio and let CSS keep the box at the CSS size — the browser downsamples, which is the point: lines and glyphs rasterize at twice the detail instead of being drawn at half resolution and stretched.

Cap the ratio at 2. Uncapped, a 3× phone display shades nine fragments per CSS pixel for a sharpness gain nobody can see.

3.3 Tell the engine the ratio separately

setPixelRatio(r) resizes nothing. It supplies the interpretation: the engine states a handful of sizes in pixels itself — label type size, the node-to-label gap, hit-test slop, the label LOD cutoffs — all chosen by eye at 1×. Without the ratio it renders them at half their intended apparent size on a 2× canvas: 7pt text, and labels at twice the density they should be.

Geometry needs no such adjustment and gets none. Node radii and link distances are world units, and a doubled viewport doubles the fitted zoom, so framed content keeps its proportions and only gains detail.

3.4 Re-size on layout changes and on DPI changes

They are different events and neither implies the other:

  • A sidebar opening resizes the element with no window resize → needs a ResizeObserver.
  • Dragging a window to a monitor with a different density changes the ratio with no element resize → needs the window resize listener.

resize() does not re-fit the camera, so the user's pan and zoom survive it.

3.5 Very large canvases are clamped, not fatal

wgpu treats a surface larger than max_texture_dimension_2d as a validation error — again, instance death. Every path that hands a size to the surface goes through fit_within_max_dimension first, which shrinks both axes by the same factor and writes the result back to the canvas element.

Both axes on purpose: clamping only the offending one would change the drawing buffer's aspect ratio while the CSS box kept its shape, and every circle would render as an ellipse.

Practically this only bites past ~4096px per axis on desktop GL. You lose sharpness on an unusually large canvas; you do not lose the engine. (Before v0.3.0 the cap was the WebGL2 downlevel default of 2048, so a 2560px window was enough to kill it — that is the bug the clamp exists to prevent recurring.)

Reference implementation

import { mountGraph } from "@benjamin-small/graph-explorer/mount";

const canvas = document.getElementById("graph") as HTMLCanvasElement;

// Read fresh every time — this is what makes moving the window between
// monitors work.
const pixelRatio = () => Math.min(window.devicePixelRatio || 1, 2);

function sizeCanvas(): boolean {
  const ratio = pixelRatio();
  // The fallbacks cover measuring before first layout, and a hidden tab where
  // every measurement is 0.
  const w = Math.round((canvas.clientWidth || window.innerWidth || 1280) * ratio);
  const h = Math.round((canvas.clientHeight || window.innerHeight || 720) * ratio);
  if (canvas.width === w && canvas.height === h) return false;
  canvas.width = w;
  canvas.height = h;
  return true;
}

sizeCanvas();                             // BEFORE mount — mount reads these
const client = await mountGraph("graph"); // by element id, not the element
client.setPixelRatio(pixelRatio());

const apply = () => {
  if (sizeCanvas()) client.resize(canvas.width, canvas.height);
  client.setPixelRatio(pixelRatio());
};
const ro = new ResizeObserver(apply);     // layout changes
ro.observe(canvas);
window.addEventListener("resize", apply); // DPI changes

// teardown
ro.disconnect();
window.removeEventListener("resize", apply);
client.dispose();

The /react entry point does all of this for you, including the ResizeObserver and the maxPixelRatio cap (default 2).


4. Coordinates are physical pixels

nodeAt, dragTo, and the node-list accessors all speak the canvas backing store, not CSS pixels. One convention across the whole surface, so a host applies the conversion once.

getBoundingClientRect() gives CSS pixels, so converting a mouse event means scaling by the backing-store ratio:

const rect = canvas.getBoundingClientRect();
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const hit = client.nodeAt((e.clientX - rect.left) * sx, (e.clientY - rect.top) * sy);

attachPointer already does this, along with click-vs-drag, pointer capture and wheel zoom. Prefer it:

import { attachPointer } from "@benjamin-small/graph-explorer/pointer";

const pointer = attachPointer(client, canvas, {   // (client, canvas) — in that order
  onNodeClick: (hit) => console.log(hit.id),
});
// later
pointer.detach();

detach() matters beyond removing listeners: detaching mid-drag would otherwise leave the pointer captured by the canvas and a node pinned by a drag that can no longer be ended.


5. Mount and teardown

const client = await mountGraph("graph");   // canvas element id
client.setStyle({ /* StyleSpec */ });
client.load(JSON.stringify({ nodes, edges }));
client.fitView();
client.start();

Concurrent mounts are safe. Two GraphView.mount() calls overlapping in time panic the wasm instance outright, so both mountGraph and the engine's own mount serialize GPU initialization internally and calls simply take their turn. This is not a limit on how many engines exist — only the overlap of the init itself is unsafe. It matters in practice because React's StrictMode double-invokes effects, so a single component issues two overlapping mounts by itself.

Queuing delays a mount; it does not cancel one. If you abandon a mount before it resolves, you must still dispose() the client you eventually receive, or you leak a WebGL context.

Always dispose(). It aborts in-flight fetches and frees the wasm object. After disposal every method throws a clear error rather than reading freed wasm memory:

GraphClient has been disposed; create a new client via mountGraph()

6. The render loop in a real page

start() runs a requestAnimationFrame loop that parks itself when nothing is in motion, and any mutating call wakes it. An untouched explorer costs nothing rather than rebuilding and re-uploading its scene every 16ms forever.

  • running — whether you asked for a loop. A parked loop is still running.
  • scheduled — whether a frame is pending right now.

Three things prevent a full idle:

  • Halos pulse by default, and halos mark the current node. A pulsing halo takes a reduced path — the previous frame's nodes, edges and labels are reused and only the halo buffer is re-uploaded — but it still draws. Set reduced_motion in the AnimationSpec to 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.
  • A configured idle animation (AnimationSpec.idle with a preset other than "none", or a registered setIdleFn callback) keeps the loop from parking by design — that is the trade it offers. See below.

Measured at 2000 nodes on a 2400×1600 backing store: a full frame ~1.3ms GPU-synced, a halo-only frame ~0.9ms, a parked graph nothing at all.

Idle animation

Off by default. AnimationSpec.idle enables ambient motion — drift (position), breathe (radius), or shimmer (opacity) — that plays only after dwell_ms (default 2000) of complete quiet, eases in over ease_in_ms, and is suppressed the instant anything real happens: any interaction, simulation activity, camera glide, or fetch stops it that frame and restarts the dwell. reduced_motion disables it entirely.

Two cheaper frame tiers make it affordable:

  • Dwelling (quiet, but the countdown hasn't elapsed): the loop stays scheduled but draws nothing — per frame, roughly the cost of a classification check. The loop cannot park here, because a parked engine can never observe the timer that would wake it.
  • Ambient (motion playing): frame assembly re-runs — nodes, edges, labels and halos follow the motion, and hit-testing follows what you see — but styling, styler callbacks, the fetch pump and the simulation are all skipped. Cheaper than a full frame, more than halo-only.

drift has a mode: "visual" (default) displaces draw-time geometry only, so graphNodes()/storeNodes() keep reporting rest positions and layout truth never moves; "physical" nudges the real world positions (bounded around captured rest anchors, restored before any mutation acts), so hosts diffing node lists see the motion too. Physical requires the force layout to own positions (Traditional view); elsewhere it falls back to visual.

setIdleFn((id, tMs, rest) => ({dx?, dy?, scale?, opacity?} | null)) computes custom motion in JS, replacing the preset per node where it returns non-null. Cost, stated plainly: it runs once per node per frame while ambient — 2000 nodes at 60fps is 120k wasm→JS crossings a second. The presets never cross the boundary; prefer them beyond a few hundred nodes. The callback never runs while suppressed or dwelling.

Driving your own loop

Skip start() and call render(now) yourself. It returns whether another frame is wanted, so you can idle on the same signal:

function frame(now: number) {
  if (client.render(now)) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

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. This is on the raw engine, not on GraphClient:

client.raw.invalidate?.();

Hidden tabs

requestAnimationFrame does not fire in a hidden or backgrounded tab. A client started while the tab is hidden renders nothing until the tab is shown — which is correct behaviour, but worth knowing before you debug it. Combined with §3.1, a tab that is hidden at mount time measures 0 everywhere; use the nominal fallback and let the resize handler correct it.


7. Several explorers on one page

Supported, and they are independent: they render side by side, and disposing one does not disturb the others.

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

Exactly N − 16. 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.


8. Context loss

The library does not handle webglcontextlost. Nothing listens for it and there is no automatic recovery: a lost context leaves a blank canvas and a client whose calls no longer draw anything.

A context can be lost for reasons that have nothing to do with your page — a GPU driver reset, the OS reclaiming resources, another tab exhausting the context cap. If your page must survive that, own it:

canvas.addEventListener("webglcontextlost", (e) => {
  e.preventDefault();       // otherwise restore can never happen
  client.dispose();
  // re-mount when you're ready; the canvas element can be reused
});

This is a genuine gap rather than a design decision, and it is called out here so it is not discovered in production.


9. Frameworks and SSR

React

Use the component — it owns mounting, sizing, DPI, pointer wiring and teardown:

import { GraphExplorer } from "@benjamin-small/graph-explorer/react";

<GraphExplorer
  graph={graphJson}
  styleSpec={styleSpec}
  maxPixelRatio={2}
  onReady={(client) => { /* GraphClient | null — null on unmount */ }}
/>

style and className on this component apply to the wrapper element, not to the graph — the graph's appearance is styleSpec.

Driving a client manually instead: 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 — replaced only when currentId, mode, loading or error actually changes.

Svelte

import { toStore } from "@benjamin-small/graph-explorer";
const graph = toStore(client);   // $graph.currentId, $graph.mode, …

Neither framework is a dependency.

SSR — Next.js, Remix, SvelteKit, Astro

The /mount entry pulls in the wasm binary and touches document. It must never be evaluated on the server. Import it dynamically, inside a browser-only lifecycle:

// inside useEffect / onMount — never at module scope
const { mountGraph } = await import("@benjamin-small/graph-explorer/mount");

The root entry (@benjamin-small/graph-explorer) is deliberately wasm-free and DOM-free — types, Emitter, toStore, initialSnapshot. It is safe to import anywhere, including in server code and shared type modules. That split is the reason the entry points exist.

For Next.js App Router, mark the containing component "use client", or reach for next/dynamic with { ssr: false }.


10. What to expect on first load

The wasm is ~3.7 MB raw, ~1.4 MB gzipped, so first paint waits on that transfer. It is a one-time, cacheable cost, not a per-interaction one, but plan for it:

  • Serve it with Brotli and an immutable cache header.
  • Show something during await mountGraph(...) — the promise does not resolve until the binary has downloaded, compiled and initialized the GPU.
  • Preload it if the graph is above the fold: <link rel="preload" as="fetch" crossorigin href="/assets/….wasm">.

Most of that mass is dependencies rather than engine code — wgpu, naga's WGSL frontend and GLSL backend, and the glyph stack. The CHANGELOG carries the per-release breakdown.


11. When something goes wrong

Symptom Cause Fix
canvas has zero size; size it before mount() Mounted before layout, or in a hidden tab Set canvas.width/height first; use nominal fallbacks (§3.1)
WebAssembly magic word error mentioning <!do… The .wasm request 404'd and the dev server answered with index.html optimizeDeps.exclude in Vite dev (§2)
Console warning about instantiateStreaming and MIME type Server does not send application/wasm Fix the MIME type; it works either way, just slower
CSP blocks the module Missing 'wasm-unsafe-eval' Add it to script-src (§2)
Circles render as ellipses Backing store aspect ratio ≠ CSS box aspect ratio Size the buffer from the element's own CSS size × ratio (§3.2)
Everything is blurry on a retina display Backing store sized in CSS pixels Multiply by devicePixelRatio (§3.2)
Labels are tiny or absurdly dense Buffer scaled but setPixelRatio never called Call it after every ratio change (§3.3)
Clicks land on the wrong node Passing CSS pixels to nodeAt/dragTo Scale by the backing-store ratio, or use attachPointer (§4)
A canvas goes blank with others on the page WebGL context cap; the oldest was force-lost Dispose off-screen explorers (§7)
Canvas goes blank after a GPU hiccup Context loss, unhandled by the library Listen for webglcontextlost and re-mount (§8)
Nothing renders, no errors, running === true Loop is parked (correct) or the tab is hidden Check scheduled; see §6
Stale picture after changing a styler callback Engine cannot observe the change client.raw.invalidate?.() (§6)
GraphClient has been disposed… Use after dispose() Create a new client via mountGraph()
Camera lurches unexpectedly after a drag fit_on_settle in the AnimationSpec It re-frames only after a drag settles; unset it if unwanted

See also

Site API reference and live demos
Package README Install, entry points, framework integration
CHANGELOG Behaviour changes between versions
docker/README.md How packaged-consumer verification works