graphing-library / browser guide

Running graphing-library 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 graphing-library 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 @poietic-tech/graphing-library. 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: ["@poietic-tech/graphing-library"] },
});

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.9 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 (about 4.0 MB raw, 1.6 MB gzipped as of v0.11.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 "@poietic-tech/graphing-library/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, wheel zoom and, in the layered, orbit and planes spaces, orbiting (§12.3). Prefer it:

import { attachPointer } from "@poietic-tech/graphing-library/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.

Four 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.
  • A camera auto-orbit keeps it awake for as long as it can turn the camera: in a 3D view, with reduced_motion off. It is on by default in the planes space and off in the others. A turning camera is a full frame every frame, and during the 2.5 s wait before it resumes the loop stays scheduled so that it can see the wait end. auto_orbit: { speed_rad_s: 0 } in the space spec turns it off, and the loop parks once the halos are off too; reduced_motion does both (§12.6).

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, per-node), breathe (radius, in unison) or shimmer (opacity, in unison at three cycles per period) — 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, and so does an enabled camera auto-orbit (§12.6), which is the ambient motion of a 3D view.

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 "@poietic-tech/graphing-library/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 "@poietic-tech/graphing-library";
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("@poietic-tech/graphing-library/mount");

The root entry (@poietic-tech/graphing-library) 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 about 3.9 MB raw, 1.5 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
Dragging pans where you expected it to orbit The flat space, the Focus view (drawn flat in every space), or attachPointer was given orbit: false Check client.space() and client.mode(); orbiting is a Traditional-view gesture of the layered, orbit and planes spaces (§12.3)
A node vanishes as you orbit or dolly in, and project returns null for it It is behind the camera, where nothing is drawn, hit-tested or labelled Dolly out or turn back (§12.4)
setSpace({ mode: "planes" }) draws no planes, and planes() is [] The current node lists no ontology that another loaded node lists too: the attribute is missing, is not a list of strings, or is not the one planes.attr names Look at nodeDetails(id).attrs; set planes: { attr: "…" } to what your data uses (§12.6). The view is drawn as in orbit meanwhile
In the planes space, pressing a node orbits the camera instead of moving it The node is placed in the composition (the current node, a disk, line or backdrop node), and a placed node holds its place Expected; only unplaced non-members drag (§12.6)
The loop never parks in a 3D space The camera's auto-orbit is on, as it is by default in planes, or the halos are pulsing, as they do by default auto_orbit: { speed_rad_s: 0 } in the space spec and halos off (halo: { enabled: false } in the AnimationSpec), or reduced_motion alone (§6, §12.6)

12. Space: flat, layered, orbit and planes

The Traditional view can be drawn in one of four spaces; the Focus view is always drawn flat. A host that never calls setSpace sees none of this: flat is the default, and it is the engine as it was before spaces existed.

Space What you get
"flat" A two-axis layout seen from straight above. The default.
"layered" The same layout with each node's depth taken from its hop distance to the current node, so the graph stands as a stack of sheets: the current node on the nearest, its neighbours one sheet back, and so on.
"orbit" The force simulation run in three axes, seen through a camera that orbits and dollies around the current node.
"planes" The current node at the centre of one plane for each ontology it belongs to, the other members of each ontology laid out on its plane around it as a disk, seen through a camera that slowly circles the composition. See §12.6.
client.setSpace({ mode: "layered", gap: 200 });   // sheets by hop distance from the current node
client.setSpace({ mode: "orbit" });               // drift into three axes; drag to orbit, wheel to dolly
client.setSpace({ mode: "planes" });              // a plane per ontology of the current node (§12.6)
client.setSpace(null);                            // back to flat

12.1 Choosing a space

setSpace takes a SpaceSpec, or null for flat. Every field is optional:

Field Default Meaning
mode "flat" "flat", "layered", "orbit" or "planes".
depth { by: "hops" } Layered only: where a node's depth comes from. Hop distance from the current node is the only source today, and a node the current one cannot reach sits one sheet beyond the deepest.
gap 180 Layered only: world units between one sheet and the next. A value that is not positive and finite takes the default.
fog 0.6 0..1: how far toward the background a node one camera-distance behind the point the camera looks at is faded. Nothing nearer is fogged, and labels fade with their nodes. Ignored in flat.
size_attenuation 0.8 0..1: how far a node's drawn size shrinks with depth. 0 draws every node at one size, 1 is true perspective, and the depth the camera looks at keeps its true size at any setting. Ignored in flat.
planes {} Planes only: which attribute lists a node's ontologies, the angle between planes, what becomes of nodes on no plane, and the rest. A block of its own with every field optional (§12.6).
auto_orbit {} A slow turn of the camera, for any 3D space; on by default in planes only. A block of its own with every field optional (§12.6).

An omitted field takes its default, not its current value, so push the whole spec each time: a slider that sent only { gap } would switch the space back to flat. The planes and auto_orbit blocks are no exception. Out-of-range numbers clamp. An unknown field or malformed input throws with the engine's message and changes nothing.

client.space() reads the mode, client.getSnapshot().space is the same value kept current for you, and on("space", (mode) => …) fires when the mode changes, not when only gap, fog, size_attenuation, planes or auto_orbit did. In React, <GraphExplorer spaceSpec={…} /> applies setSpace whenever the serialised value changes, as simulationSpec does, and null restores flat. A space set while the Focus view is showing is stored and takes effect on the way back.

12.2 What animates

Switching space moves rather than jumps:

  • Every switch morphs. Nodes tween to their new positions over the transition's duration and easing (AnimationSpec.transition), and the camera glides to the new space's pose, framing the current node and its neighbours (in planes, the whole composition): layered tilts back about 55° from top-down, orbit tilts about 70° and turns about 23°, planes tilts about 46° and turns about 29°, and flat returns to straight down. Switching back to flat lands on a flat layout under the top-down camera with no residual depth, and the flat simulation re-settles it live.
  • Layered re-layers on every navigation, and on edits, reveals and loads: depth is recomputed from the new current node and the sheets tween to it.
  • Orbit drifts into depth. Entering orbit hands the layout to a three-axis simulation. A flat graph is given a small, deterministic push out of its plane and the live settle spreads it through the third axis while the camera glides in; a layered stack is relaxed instead, without flattening first. It is the live settle, so the render loop stays awake until the layout comes to rest, as it does for a flat graph that is settling.
  • Planes re-composes on every navigation, and on edits, reveals, loads and any change to the planes block. The current node stays where it is and the members tween onto their disks around it while the camera follows. Under "faded" and "layout" the rest of the graph relaxes around them in the three-axis simulation, so the render loop stays awake until it settles; under "hidden" and "backdrop" nothing else moves. Leaving planes lets the members go and the layout relaxes out of the composition (§12.6).
  • Reduced motion snaps every transition. With reduced_motion set in the AnimationSpec the layout settles synchronously and the camera lands on its pose.
  • The Focus view stays flat. Entering it from a 3D space glides the camera to the flat pose; leaving glides back to the angle it left, or to the space's preset if the space changed meanwhile.

12.3 Pointer and keyboard

attachPointer needs no extra wiring. In the Traditional view of a layered, orbit or planes space:

Gesture Does
Drag over the background Orbits the camera, 0.005 radians per CSS pixel, so the scene follows the cursor.
Shift-drag, or a middle-button drag Pans.
Wheel Dollies.
Click Selects, as in flat; with camera_follow the camera then glides to the node, keeping its angle.
Drag from a node Moves the node, as in flat: layered keeps it in its sheet, orbit at its depth. In planes a node the composition has placed holds its place, so the press orbits the camera instead (§12.6).

Flat behaves exactly as before: a drag pans and the middle button is ignored. So does the Focus view, which is drawn flat in every space. The secondary button is left to the browser's context menu in every space. To pan on every drag, as flat does, turn orbiting off:

attachPointer(client, canvas, { orbit: false });

In React, <GraphExplorer orbitDrag={false} /> does the same.

Touch and pen. A one-finger drag orbits exactly as a mouse drag does, and a touch screen has no Shift key or middle button, so in layered, orbit and planes a touch or pen user can orbit but not pan, and there is no pinch to dolly: pan and pinch on touch wait on a two-finger gesture. Flat and the Focus view pan as before. A host whose touch users need to pan in the 3D spaces passes orbit: false (or orbitDrag={false}), which turns drag-to-orbit off for the mouse as well.

Keyboard leaps (client.leap(dir), the key layer's h j k l) follow screen direction: in the 3D spaces the engine hands the navigator the screen positions of the current node and its neighbours through the camera as drawn, so "right" means right on screen at whatever angle the camera has. A node behind the camera is never a candidate. No key binding turns the camera; call orbit or setCameraPose from your own handler if you want one.

12.4 The camera

client.orbit(0.2, 0);                                  // dyaw, dpitch, in radians
client.cameraPose();                                   // { target: [x, y, z], distance, yaw, pitch }
client.setCameraPose({ pitch: 0.6, distance: 900 });   // any subset of the pose; glides
client.setCameraPose({ yaw: 0 }, false);               // false snaps instead
client.project(x, y, z);                               // { x, y } in canvas pixels, or null
  • target is the world point the camera looks at and distance how far the eye is from it, in world units. yaw turns about the world z axis. pitch is 0 looking straight down and π/2 edge-on, and it passes edge-on: up to just under π the camera looks up at the graph from below. Both are radians.
  • cameraPose() reports the pose as drawn right now: mid-glide, where the camera is rather than where it is going.
  • setCameraPose keeps any field you leave out. If a glide is in flight, that means the value it is heading for, so a patch made mid-glide keeps the glide's destination in the fields it leaves out. Pitch clamps to its range and distance to the zoom range, and a distance that is not positive is ignored. An unknown field or malformed input throws and changes nothing.
  • Flat and Focus ignore the turn. In the flat space and in the Focus view orbit() does nothing and setCameraPose ignores yaw, pitch and the third component of target (its x and y and distance still apply), so the camera stays above the plane looking straight down. Flat is a two-dimensional space, and tilting it is what switching space is for.
  • The camera may turn by itself. With auto_orbit on, which it is by default in planes, the camera turns once 2.5 s have passed since it was last moved, so cameraPose() can change between your calls; each call that moves the camera restarts that wait (§12.6).
  • yaw accumulates as the camera is orbited, so after a long drag it can read well past a turn. A yaw you set is taken within one turn, (−π, π], and a glide to a yaw takes the shortest way round.
  • project(x, y, z = 0) returns canvas pixels (§4), or null when the point is behind the camera, where nothing is drawn, hit-tested or labelled either. z is the depth axis: flat sits at 0, a layered node at -hops × gap, an orbit node wherever the simulation put it, a planes node on its plane.
  • In layered, orbit and planes, visibleNodes() and graphNodes() come back in draw order, far to near, and that order changes as the camera turns: key by id, not by position. A node behind the camera is absent from visibleNodes() and has x and y of null in graphNodes(), as one off screen does.
  • setPositions takes [x, y] or [x, y, z] per node, and so may a setLayout function. Host z is applied only when the space is orbit or planes at the time of the call, and in planes only to the nodes the composition does not place, since it lays the depth of the ones it does (§12.6): flat puts every node on the plane and layered at its hop depth, so a z given in flat is dropped from the layout. Switching to orbit or planes later does not bring it back, because the engine does not re-read host positions on that switch and a host layout gets no simulated drift into depth: the graph stays on the plane. Send the positions again after switching to apply host depth (or call relayout(), which re-reads the positions last sent and re-runs a setLayout function).
  • fitView() frames the graph's bounding sphere at the camera's current angle in layered, orbit and planes, so a wide, shallow graph is framed more loosely than in flat.

12.5 Drawing order and cost

The engine draws in painter's order in every space: edges, then halos, then nodes, then labels. In layered, orbit and planes the nodes are sorted far to near each frame, so a near node covers the far ones it overlaps and a far node draws over a near edge. Hit-testing follows what is drawn: where two nodes overlap on screen, a click picks the nearer. Solid nodes (§12.7) change none of this order; they cost a little more per covered pixel, measured in the CHANGELOG. Meshes (§13) are the exception: they are drawn after the nodes, in a pass of their own, and sit over the impostors, edges and halos; labels still draw on top of them.

Flat is unchanged, and pays almost nothing for the others existing. It runs the same two-axis simulation as before, projects with the same arithmetic and skips the sort, which the 3D spaces pay every frame and flat pays only while the camera glides down from a tilt. Its positions and framing are identical to the build before spaces. The one price is the third position component carried through each frame, which shows only at 20 000 nodes: about 3% of a frame while the simulation runs, and 0.15 ms of a settled one. Layered adds the per-frame sort. Orbit costs more: its three-axis simulation tick is about 1.7× the two-axis one, so a large graph is noticeably heavier while its layout settles. Once it settles, frames are cheap again, and a parked loop costs nothing. Planes costs orbit's price plus the composition itself, which is laid out again on every navigation (§12.6). The CHANGELOG has the 20 000-node measurements.

12.6 Planes

In the "planes" space the current node sits at the centre of one plane for each ontology it belongs to. The other loaded members of an ontology are laid out on its plane around the current node as a disk, and the planes meet along a line through the current node. The disks stand still and the camera circles them slowly (see Auto-orbit below), so the composition reads in depth. Click a member and the composition is rebuilt around it. Like the other spaces it belongs to the Traditional view: the Focus view is drawn flat, as ever.

client.setSpace({ mode: "planes" });   // a plane per ontology of the current node
client.planes();                       // [{ id: "people", index: 0, members: 212, color: "#4c9be8" }, { id: "authors", index: 1, members: 40, color: "#f28e2b" }]

Where the ontologies come from. An ontology here is a named set of nodes, and the engine knows none: the data names them. A node lists its ontologies in a list attribute, ontology_ids by default (planes.attr names another): ["people", "authors"]. A bare string counts as a one-element list, and anything that is not a string is ignored. An ontology becomes a plane when the current node lists it and at least one other loaded node does too, so the planes grow as neighbourhoods load, and a node that shares no ontology with anything has none (see Non-members).

Where the members go.

  • Planes are ordered by member count in order (most first by default), then by id, and max_planes keeps the first of them. Plane 0 lies in the xy plane, the plane the flat layout is drawn in, and the others are turned about the line they share (the x axis). The angle between neighbouring planes is offset_deg but never more than 180° divided by the number of planes, so by default two planes stand 90° apart, three 60° and four 45°, always within a half turn.
  • Members lie on their plane in a sunflower spiral around the current node, closest first: by hop distance from the current node (a node it cannot reach comes last), then by degree (better connected first), then by id. The nodes nearest in the graph sit nearest in the picture, and the arrangement does not depend on the order the nodes were loaded. The rings are spaced from the simulation's link_distance.
  • stagger shifts each plane's rings by a fraction of a ring, so that where two disks cross their nodes do not sit at the same radius.
  • A node that belongs to several of the current node's ontologies is drawn once. With multi: "smallest" (the default) it goes on the plane with the fewest members, the earlier plane if they tie, so an ontology nested in a larger one (authors within people) keeps a disk of its own. With multi: "line" it goes on the line where its planes meet, alternating sides of the current node, nearest first: the strict reading of "in both", which empties the disk of an ontology that lies wholly inside another.
  • The camera opens above and to one side of plane 0 (about 46° from top-down, turned about 29°) and frames the composition rather than the whole graph: the current node, the farthest member and any preview. fitView() still frames everything. Previews, the ghosts of neighbours a provider has not committed yet, fan out in the gap past the last plane, clear of every disk.
  • When max_labels cuts the labels, the current node's label survives first, then the members', innermost ring first, then everyone else's.

The planes block. SpaceSpec.planes, every field optional:

Field Default Meaning
attr "ontology_ids" The node attribute that lists a node's ontologies. A blank name takes the default.
offset_deg 90 Degrees between neighbouring planes; n planes never get more than 180/n. A value that is not positive and finite takes the default, and one above 180 takes 180.
stagger true Shift each plane's rings so interleaved disks do not coincide.
max_planes 0 The most planes shown for one current node, the first in order kept. 0 is no cap, and nothing shows more than 64: a larger number clamps to 64.
order "desc" The plane order, by member count: "desc" is most members first, "asc" fewest first, ties by id. It decides which planes a cap keeps and which plane comes first, so "asc" with a cap shows a hub's small sets instead of its big ones.
multi "smallest" Where a node in several of those ontologies goes: "smallest" or "line".
cross_links "shown" "shown" or "hidden": whether links between nodes on different planes are drawn.
non_members "faded" What becomes of nodes on no plane: "faded", "layout", "hidden" or "backdrop".
sheet_opacity 0.18 The opacity of the translucent disc drawn for each plane, 0 (no discs) to 1. A look, not a layout: changing it re-lays nothing out.

The block follows the rules of §12.1: an omitted field takes its default, an unknown field throws, and out-of-range numbers clamp. A setSpace that stays in the planes space and changes any planes field re-lays the composition (the nodes tween to their new places and the camera stays where it is); space does not fire, since the mode did not change. One that changes only auto_orbit, fog, size_attenuation or gap re-lays nothing.

Non-members. non_members says what becomes of the nodes that are on no plane:

  • "faded" (the default): they stay in the simulation, where the placed nodes, held fixed, push them clear of the planes, and they are drawn at 0.6 of their radius and 0.35 of their opacity, and the links that touch them at 0.35 of their own, so they are dim but still clickable.
  • "layout": the same, at full size and opacity.
  • "hidden": they are left out of the scene, so not drawn, not hit-testable and not labelled, they are skipped by leaps and sibling steps, and the simulation stops.
  • "backdrop": they are placed on a plane behind the disks (behind, as seen from the default camera pose); their links are not drawn, the camera does not frame them, and the simulation stops.

Hidden is a view, not a deletion: the nodes are still loaded, so graphNodes() lists them with x and y of null and storeNodes() lists them, and only visibleNodes() leaves them out.

A current node with no planes, that is, none of whose ontologies another loaded node lists, suspends all of this: nothing is placed, no treatment applies, and the view is drawn as in orbit (planes() is []), so a node outside every ontology never blanks the screen. Moving on to a node that has planes brings the composition back.

Cross links. A link whose two ends are drawn on different planes crosses between them. With cross_links: "shown" it carries the engine attribute __cross: true, which the default style draws dimmed (colour #8a7350, opacity 0.55, width 1); "hidden" leaves it out. A link at the current node, at a node on the line or at a non-member is never a cross link. Preview links keep their provisional look whatever these settings say: they are not dimmed, never carry __cross and are not dropped by cross_links: "hidden". A preview link to a hidden non-member or a backdrop node is not drawn, like any link to one.

Styling by plane. In the Traditional view of the planes space the engine adds three attributes, in the attrs that rules and stylers (setNodeStyler, setEdgeStyler) see:

Attribute On Value
__plane A node drawn on a disk The id of the ontology whose plane it is drawn on. The current node, line nodes and non-members have none.
__plane_color A node drawn on a disk The plane's palette colour, #rrggbb: the colour its disc is drawn in, so a styler can match the node to its plane.
__line A node drawn on the line where the planes meet true. Only with multi: "line".
__cross A cross link true. Only with cross_links: "shown".

Names that start with __ are reserved for the engine, so none of these comes from your data, and none exists outside the planes space: flat output is untouched. The default style draws __line nodes as diamonds and __cross links dimmed, and has no rule for __plane: colouring by plane is yours to do, and __plane_color hands you the plane's own colour for it. As with __anchored, a spec you pass to setStyle replaces the default wholesale and carries those rules itself. A style whose current-node mark is a diamond too, as the explorer demo's is, draws a line node like the current one; give line nodes another shape there if the two must be told apart.

The contains selector tests a list attribute: it matches when the attribute is an array holding the value, or a scalar equal to it. It is ANDed with role, equals and one_of in the same when, and works in edge rules. Use it to style by membership (a node in several ontologies matches each rule that names one of them), and __plane to style by where the node is drawn (one value). Later rules win, as always:

client.setStyle({
  node_base: { color: "#8fa3bd", radius: 9, shape: "circle", label_visible: true },
  node_rules: [
    // by membership, in any space: the attribute is a list
    { when: { attr: "ontology_ids", contains: "people" }, set: { color: "#4dabf7" } },
    // by the plane a node is drawn on, in planes only
    { when: { attr: "__plane", equals: "authors" }, set: { color: "#51cf66" } },
    // what the default style does for the planes attributes
    { when: { attr: "__line", equals: true }, set: { shape: "diamond" } },
  ],
  edge_base: { color: "#4a5568", width: 2.5 },
  edge_rules: [
    { when: { attr: "__cross", equals: true }, set: { color: "#8a7350", opacity: 0.55, width: 1 } },
  ],
});

Auto-orbit. SpaceSpec.auto_orbit is a camera setting rather than a planes one: a slow turn of the camera about its target, usable in any 3D space.

Field Default Meaning
speed_rad_s null Radians per second about the world z axis; negative turns the other way, and 0 is off. null, or omitted, takes the space's default: 0.1 in planes (a full turn in about a minute) and 0 in every other space, so layered and orbit stay still unless you ask. Clamps to [-1, 1]; a value that is not finite takes the default.
wobble 0.3 How far the pitch rises and falls as the camera turns, in radians, so the path covers a sphere rather than a circle. 0 is a level circle. Clamps to [0, π/2]; a value that is not finite takes the default.
client.setSpace({ mode: "planes", auto_orbit: { speed_rad_s: 0 } });                // planes, camera still
client.setSpace({ mode: "orbit", auto_orbit: { speed_rad_s: 0.1, wobble: 0.2 } });  // orbit, turning
  • The camera waits 2.5 s after the last thing that moved it before it resumes. Camera input restarts the wait: a drag over the background, the wheel, calls such as orbit, pan, setCameraPose and fitView. So does dragging a node, and so does every camera glide: the follow glide of a navigation, the glide a space switch makes, the way back from Focus. It never turns a camera that is gliding.
  • It never turns in the flat space or the Focus view (both are drawn flat), under reduced_motion, or on an empty scene.
  • setSpace configures it on every call, one that stays in the same mode included. A new speed_rad_s or wobble takes effect on the next frame and does not restart the wait.
  • cameraPose() reads wherever the turn has got to, and its yaw stays within one turn, (−π, π].
  • It keeps the render loop running. A turning camera is a full 3D frame every frame, and during the 2.5 s wait the loop stays scheduled so that it can see the wait end. It also suppresses the idle presets (§6), which would otherwise start at 2 s, half a second before the orbit does. speed_rad_s: 0 ends all of that, and the loop then parks once the halos are off too (halo: { enabled: false } in the AnimationSpec; with the default pulsing halo it keeps drawing halo-only frames, §6). reduced_motion alone does both.

The discs. Each plane is drawn as a translucent disc about the current node, in the plane itself, out to its outermost member and a little past, in the plane's palette colour (twelve distinct hues by plane index, wrapping) at planes.sheet_opacity. A plane with nobody drawn on it gets no disc; the Focus view and the other spaces draw none. 0 turns them off.

planes(). The composition, one entry per plane in display order, each with its color:

client.planes();   // [{ id: "people", index: 0, members: 212, color: "#4c9be8" }, …]

It is [] outside the planes space and when the current node has no planes. In the Focus view, where nothing is drawn on planes, it still names the composition last laid out. members counts every loaded node other than the current one that lists the ontology, whichever plane it is drawn on, so a node drawn on a smaller, nested plane still counts toward the larger.

It is a pull accessor, like space(): the composition has no event of its own and no snapshot field, and the call does not wake the loop. Read it once the call that changed the composition has returned: after change (a new current node), after mutate (an edit, an undo, a reveal, a hide), after load, and after a setSpace that edits the planes block, which re-lays the composition without firing space. Never read it inside an event handler. Events fire from inside the engine call that caused them, and the engine throws if a handler calls back into it, so defer the read:

client.on("change", () => queueMicrotask(() => render(client.planes())));

What cannot be dragged. The nodes the composition has placed (the current node, the disk and line nodes, the backdrop nodes) hold their places. dragStart(id) returns false for them, which attachPointer treats like a press on the background, so the gesture orbits the camera (or pans it, with orbit: false). A press that never moves is still a click, which selects the node and rebuilds the composition around it; one that travels is a drag and reports no click. Only unplaced non-members ("layout" and "faded") drag, as they do in orbit. A drag that was already under way when its node became placed stops moving it, and unpinNode and unpinAll leave the composition's hold on a placed node alone.

Host layouts. Under a host layout (setPositions, setLayout(fn)) the composition still places the members around the current node. The host's positions stay in force for every other node, its z included (§12.4), and a node gets its host position back as soon as the composition stops placing it: when you leave the planes space, move to a current node that has no planes, or move to one whose composition leaves it out. A setLayout function is called again to read them. A node the host gives no position keeps the one it has, except that a setLayout function which omits it leaves it unplaced when you leave the planes space or move to a node without planes, as any relayout does.

Cost. In the planes space at 20 000 nodes, a steady auto-orbiting frame costs 2.0 ms of CPU (release build, 2560×1440 canvas at pixel ratio 2), against a 4 ms budget. Entering planes at a centre that places every node costs about 24 ms per frame for the first 4 s, while the simulation settles, and about 2 ms thereafter; under "hidden" or "backdrop", which stop the simulation, it costs about 1.2 ms. Flat is unchanged: the 2 000-node fingerprint is identical to a build from before planes, and the 20 000-node flat tick and the flat hot frame (simulation active) run at 0.9998× and 0.996× of that build's. The release wasm grows from 3 933 280 to 3 989 774 bytes raw and from 1 541 244 to 1 563 201 gzipped (+1.4%). The CHANGELOG has the measurements.

12.7 Solid nodes

In layered, orbit and planes a node is drawn as a lit solid: a circle is a sphere, a square a cube and a diamond an octahedron, each sized to its flat shape: a sphere fills the circle exactly, and a cube or octahedron keeps the square's or diamond's centre and extent and gains the corners of its projection (a cube's silhouette is a square face-on and a hexagon otherwise). The cube and the octahedron sit axis-aligned in the world, so orbiting the camera turns them. The light is a headlight fixed to the camera, so nothing is ever in shadow and the style colour stays readable; fog and size attenuation apply as in §12.1.

view.setSpace({ mode: "orbit" });                 // solids, the default
view.setSpace({ mode: "orbit", solid: false });   // the flat shapes, as before 0.10

solid is ignored in flat and in the Focus view, which is always drawn flat. Toggling it changes shading on the next frame with no transition. A solid is picked, haloed and labelled by its bounding circle: exactly the sphere's outline, and at or outside a cube's corners. The circle is sized at the centre's depth, so at close range a cube's near corners can reach a little past its hit circle, halo and label anchor. Edges end at the centre and draw first, so a solid covers the end of its edges.

A node can also wear an image on the sphere or be drawn as a mesh of your own; see §13.


13. Textures and meshes

A node can wear an image, or be drawn as a 3D model of your own. Both are registered on the client under a key you choose and named from the style, the way a colour is: a fixed key in node_base or in a rule's set, or a key read from a node attribute. A node that names neither draws as it did before; nothing in this section costs anything until a node uses it.

import { mountGraph } from "@poietic-tech/graphing-library/mount";
import { loadGltf } from "@poietic-tech/graphing-library/gltf";

const view = await mountGraph("graph", { textureTileSize: 256 });
await view.registerTexture("ada", "/img/ada.png");            // URL, Blob, ImageBitmap, ImageData, <img>
const { meshes } = await loadGltf("/models/studio.glb");
view.registerMesh("studio", meshes[0]!);                      // or your own typed arrays
view.setStyle({
  node_base: { color: "#8fa3bd", radius: 9, texture_attr: "avatar" },   // node.attrs.avatar is a texture key
  node_rules: [{ when: { attr: "type", equals: "Studio" }, set: { mesh: "studio" } }],
  edge_base: { color: "#4a5568", width: 2 },
});
view.setTextureLoader((key) => `/img/${key}.png`);            // called once for each key nothing registered

13.1 Keys and assignment

A key is a string or an integer. A string of canonical decimal text ("7", not "07" or "+7") and the integer 7 are the same key everywhere: in registerTexture and registerMesh, in a style document (texture: "7" and texture: 7), in a node attribute (avatar: "7" and avatar: 7) and in the texture_missing and mesh_missing events, which report the integer form. "07" and "+7" are not canonical and stay strings. The consequence is that you cannot also have a string key "7". Textures and meshes have separate key spaces, so a model and its base-colour image can both be "studio".

Three places can name a key, and the last one that applies wins:

  1. node_base.texture / node_base.mesh give every node one fixed key.
  2. node_base.texture_attr / node_base.mesh_attr name a node attribute whose value is the key (a string or a non-negative integer; a float, a boolean or a list is no key). This overrides the fixed key.
  3. A rule's set.texture / set.mesh overrides both.

when.attr and texture_attr/mesh_attr read the node's own attributes and nothing else: the engine injects no id attribute. A rule on attr: "id", or texture_attr: "id", works only when your data carries an id attribute (the demo's datasets do).

A key nothing has registered draws as if the node had no texture or mesh. The engine reports it once, so it is never a hole and never a console flood (§13.5). unregisterTexture(key) and unregisterMesh(key) drop a registration: the atlas tile is freed and reused, and the engine id is not reissued within a session.

13.2 Textures

registerTexture decodes the source, letterboxes it into a square tile (aspect preserved, centred, transparent surround) and copies it into the atlas, where the engine builds its full mip chain (each level a 2×2 average of the one above, weighted by alpha so the transparent surround lends no colour to the edges; the main thread resamples an image once). It rejects, and registers nothing, when the image cannot be fetched or decoded or the atlas is full. Registering an existing key replaces its image in place. An ImageBitmap you pass stays yours to close; one decoded from any other source is closed once its tile is drawn.

The image composites over the style colour by its own alpha, so transparent corners and the letterbox bars show the node's colour, and the usual lighting, fog and opacity then apply. Mips are sampled trilinearly, so a small avatar does not shimmer. The image reads upright.

Where Shape What is drawn
flat, or the Focus view circle the image fills the disc
flat, or the Focus view square, diamond the image fills the shape's square, clipped to the shape
layered, orbit, planes, solid sphere the image on the facing side, lit like the sphere: a round portrait, not wrapped round the globe
layered, orbit, planes, solid cube, octahedron ignored; the style colour draws
any mesh sampled through the mesh's own uvs

Every textured node is one draw call, whatever the number of distinct images. The images live in one atlas: pages of 2048 × 2048 px, each a grid of tiles. The first registration allocates two pages, and the page count doubles each time the last page fills, up to the GPU's cap. Growing copies every page, so doubling takes 1 000 images at 256 px through four growths rather than fifteen; the price is that up to half the pages can stand empty until they fill. The tile size is fixed when you mount, and it is the one decision here you cannot change later:

const view = await mountGraph("graph", { textureTileSize: 128 });   // 64, 128, 256 (default), 512 or 1024
// <GraphExplorer mountOptions={{ textureTileSize: 128 }} … /> — read once, at mount
Tile Tiles per page One tile with mips
64 1 024 22 KB
128 256 87 KB
256 64 350 KB
512 16 1.4 MB
1024 4 5.6 MB

A page holds (2048 / tile)² tiles and costs 2048 × 2048 × 4 × 4⁄3, about 22 MB (Rgba8Unorm with its mip chain), whichever tile size fills it. Pick 128 for many small avatars: a node drawn 60 px across gains nothing from 256, which costs four times the memory per image, and a 128 page holds 256 images in 22 MB. Pick 512 for a few large posters or logos that fill a hundred or more pixels of a zoomed node, where 256 would look soft. A size that is not one of the five fails the mount. The page count is capped by the GPU's max_texture_array_layers; when the atlas cannot grow, registerTexture rejects with a message that names the cap and the tile size.

13.3 Meshes

A mesh is a triangle list in its own units, centred on the origin:

view.registerMesh("tetra", {
  positions: new Float32Array([/* x, y, z per vertex */]),
  normals: new Float32Array([/* x, y, z per vertex, same length */]),
  uvs: new Float32Array([/* u, v per vertex; optional; origin top-left, as glTF */]),
  indices: new Uint32Array([/* three per triangle */]),   // or Uint16Array
  radius: 1,                                              // optional: farthest vertex from the origin
});

registerMesh checks the lengths, the indices and that every coordinate is finite, and throws with the reason before the engine is called; an invalid mesh registers nothing. radius is measured from positions when you leave it out.

A node of styled radius r draws the mesh scaled by r / radius about its position. It is axis-aligned in the world, like a cube, so orbiting the camera turns it. It is lit by the headlight on your normals, in the style colour or, with a texture also named, the texture composited over it; opacity, fog and fade apply as for other nodes. In flat a mesh is drawn too, seen from above: set mesh in rules that apply in 3D if you want flat glyphs. Picking, the halo and the label treat a mesh node as a circle of the node's radius.

Winding. Triangles must run counter-clockwise seen from outside (the glTF convention), and back faces are culled. A mesh built clockwise, or with its normals negated by hand, is inside out: you see the far wall through the hole where the near one should be, or nothing at all. If a model vanishes, swap two indices in each triangle. loadGltf gets this right for any glTF that is authored correctly, including one with a mirroring transform (§13.4).

The mesh pass. Meshes are not drawn with the nodes. They go in a pass of their own with a depth buffer, so a mesh occludes other meshes and itself (a torus hides its far side correctly), and the pass is composited over the canvas before labels. Two consequences: a mesh is always over impostors, edges and halos, whatever the depths, and the depth buffer sorts meshes among themselves only. The pass costs one colour and one depth attachment at canvas size, 8 bytes a pixel: 118 MB at 2560 × 1440 at pixel ratio 2. They are created on the first frame that draws a mesh, so a scene without meshes never pays, and they are made again when the canvas is resized. Mesh edges are not antialiased by default. meshMsaa: true at mount gives the pass 4 samples and costs about 4× that memory (about 530 MB at that size), so turn it on for a smaller canvas or a discrete GPU:

const view = await mountGraph("graph", { meshMsaa: true });

A device that cannot multisample the pass's formats (Rgba8Unorm and Depth24Plus) at 4 samples gets the single-sampled pass instead, with a console.warn at mount saying so (no error handler can exist before mount returns). If the device cannot allocate the pass's targets at all (out of memory on a large canvas), the engine disables meshes for the rest of the session: the error event fires once, with a message that starts mesh pass disabled, and from the next frame on every mesh node draws as its shape. The failure is reported by the device asynchronously, so one frame may have been drawn with the unbacked targets before the fallback takes effect.

Budget. The cost is triangles × instances per frame: a 2 000-triangle torus on 500 nodes is 1 million triangles. Treat about a million as the ceiling for 60 frames per second on integrated GPUs, and fewer triangles for the mesh that is drawn most. Register a low-poly version for a big graph and a detailed one for a few nodes by naming different keys in different rules. The CHANGELOG has the measurements.

13.4 Loading a glTF file

loadGltf is a separate entry point, so a page that never loads a model does not bundle it:

import { loadGltf } from "@poietic-tech/graphing-library/gltf";

const { meshes } = await loadGltf(file);   // File, Blob, URL string, ArrayBuffer, or { json, buffers, baseUrl? }
for (const m of meshes) view.registerMesh(`${file.name}/${m.name}`, m);
if (meshes[0]?.texture) await view.registerTexture("studio", meshes[0].texture);

Each triangle primitive becomes one LoadedMesh: a MeshSource plus name, a measured radius and, when its material has a base-colour texture, that image as an ImageBitmap in texture. A mesh with several primitives gives several entries, named <mesh>#<index>. .glb and .gltf are both read, and a .gltf's relative buffer and image URIs resolve against the URL you passed. loadGltf(source, { applyNodeTransforms: false }) leaves the placement alone.

What it does for you:

  • A primitive with no normals gets flat ones, one per face.
  • The world transform (translation, rotation, scale or matrix) of the first node that uses the mesh is applied to the positions and normals, so a model comes out as its tool showed it. A mirroring transform reverses the winding to match.
  • 8-bit and 16-bit indices are widened to Uint32Array; a primitive without indices is indexed in order.

What it refuses, by throwing a message that names the cause, before any buffer is fetched (it checks the JSON first, so a .gltf whose external buffers it would refuse never downloads them):

  • a glTF version other than 2.x;
  • any extensionsRequired entry, named in the message: the loader implements no extension, and glTF 2.0 says a required extension a loader does not implement must fail the load (a required KHR_texture_transform would otherwise load with shifted UVs and no message);
  • KHR_draco_mesh_compression, EXT_meshopt_compression or KHR_mesh_quantization in extensionsUsed, a Draco primitive, or a meshopt buffer view;
  • sparse accessors;
  • POSITION, NORMAL or TEXCOORD_0 that is not float;
  • a primitive mode other than triangles, or one with no POSITION.

Extensions a file lists as used but not required are not read (a KHR_texture_transform is not applied). Skins, morph targets, animations, cameras and lights are ignored, baseColorFactor is not applied (the style colour plays that part), and a primitive's only texture is its base colour. loadGltf has no dependency; only decoding a texture needs the DOM (createImageBitmap). To squeeze a file the loader refuses, re-export it without compression or quantization.

13.5 Missing keys and loaders

Each time the scene is built the engine collects the keys its nodes end up wearing that nothing has registered, and reports each key once, in a texture_missing or mesh_missing event whose payload is an array of keys, sorted (integers first, then strings). Only the winning key per node counts: a key an overriding rule or your styler replaced is not reported, and is not written into the node if it is registered later. Registering a key clears its mark, so a key you unregister and a style that still names it is reported again.

view.on("texture_missing", (keys) => console.log("need", keys));

on delivers keys that went missing before you subscribed, from inside the on call itself, and unregisterTexture / unregisterMesh deliver a key a style still names from inside that call. A handler must not call a synchronous engine method at those moments (the engine is mid-call and throws), so defer with queueMicrotask; the client's own loaders do.

Rather than handle the event, set a loader:

view.setTextureLoader(async (key) => (await fetch(`/img/${key}.png`)).blob());
view.setMeshLoader((key) => meshes.get(key) ?? null);   // null: leave it unset

The loader is called once per missing key, with the key already normalised (an integer for "7"), and may return a source or a promise of one. Whatever it returns is registered under that key, and the nodes wearing it draw it on the next frame, without a restyle. Returning null leaves the key unset. A throw or rejection, from the loader or from registering its result, goes to the error event as "texture loader failed for <key>: …" (or mesh), and the key is not tried again. Loading is not rate-limited, so a style that names a thousand missing keys starts a thousand fetches at once: queue inside your loader if that matters. setTextureLoader(null) removes it. In React, pass textureLoader and meshLoader to <GraphExplorer>.

13.6 Limits

  • Textures are ignored on cubes and octahedra. The style colour draws.
  • Meshes are always drawn over impostors, edges and halos; they are depth-tested against meshes only.
  • A mesh has no orientation or animation of its own: it is axis-aligned in the world and scaled by the node's radius.
  • Mesh silhouettes are not antialiased unless you set meshMsaa (§13.3).
  • The atlas does not defragment, and the tile size cannot change after mount.
  • A mesh node is picked as a circle of the node's radius, not by its triangles.
  • Cost: see the CHANGELOG for what an untextured scene, a fully textured one and a scene of meshes cost per frame.

The design is in docs/superpowers/specs/2026-10-13-meshes-textures-design.md.


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