Architecture
Layer overview
┌──────────────────────────────────────────────────────────────┐
│ Presentation Layer (DOM mutations + framework reactivity) │
│ React / Svelte / Muffin / vanilla — your choice │
├──────────────────────────────────────────────────────────────┤
│ graph-ui-compute │
│ DOM geometry, interaction bindings, keyed diffing │
│ bindPanZoom · bindNodeDrag · bindCursorCapture │
│ keyedJoin · svgPointer · svg-elements │
├──────────────────────────────────────────────────────────────┤
│ GraphCanvasState (composed state: store + pan/zoom) │
│ Single source of truth for all computed canvas state. │
│ getTransform · getNodeScreenRect · getOverlayAnchor │
│ getPanTargetForNode · isNodeInViewport · screenToSvg │
├─────────────────────────────┬────────────────────────────────┤
│ GraphStore │ PanZoomState │
│ node/edge data + layout │ pan, zoom, coord math │
├──────────┬──────────────────┴──────────┬─────────────────────┤
│ Layout │ Traversal │ Geometry │
├──────────┴─────────────────────────────┴─────────────────────┤
│ Ontology (node-types, link-types — BFO grounded) │
├──────────────────────────────────────────────────────────────┤
│ Actors & Session (layered on top, independent) │
│ ActorRegistry · SessionLog · PresenceState │
├──────────────────────────────────────────────────────────────┤
│ EventBus · BatchProcessor (infrastructure) │
└──────────────────────────────────────────────────────────────┘graph-player sits alongside this stack — it consumes snapshots and emits events that feed into GraphStore.
The critical invariant
The headless layer computes all state. The rendering layer executes only DOM mutations.
No layout math, no coordinate transforms, no geometry — none of this belongs in the renderer. The renderer reads computed state from GraphCanvasState and GraphStore, subscribes to events, and applies them to the DOM. That's its entire job.
This invariant is what makes the library work across multiple presentation layers without rewriting any logic.
Data flow
User interaction → graph-ui-compute event handlers
→ store.moveNode() / store.addNode() / etc.
→ GraphStore emits narrow events
→ Presentation layer re-renders minimal DOMProgrammatic mutation → store.ingest() / store.batch()
→ store.computeLayout()
→ 'layout:computed' event
→ Presentation layer rebuilds node+edge elementsGraphPlayer → 'node:appear' / 'edge:appear' / 'node:update'
→ store.addNode() / store.addEdge() / store.setNodeStatus()
→ normal mutation flow aboveCoordinate systems
Two coordinate spaces exist. Confusing them is the most common source of bugs.
SVG space (graph-absolute)
- The natural coordinate system of the graph
- Node
x,y,w,hare in SVG space - Edge
pathdata is in SVG space - Port positions are in SVG space
- Cursor positions in
PresenceStateare in SVG space - Independent of pan and zoom
Screen space (viewport-relative)
- Pixel coordinates on screen, as seen by the user
- Used for DOM event coordinates (
clientX,clientY) - Used for pan values (pan is in screen pixels)
- Changes when the user pans or zooms
Converting between them:
// Screen → SVG (e.g. placing a dropped node at pointer position)
const [svgX, svgY] = panZoom.screenToSvg(screenX, screenY);
// SVG → Screen (e.g. positioning a DOM overlay above a node)
const [screenX, screenY] = panZoom.svgToScreen(node.x, node.y);GraphCanvasState wraps both and adds viewport-aware helpers (getNodeScreenRect, getOverlayAnchor, isNodeInViewport).
Event system
All classes that emit events extend EventBus. The API is the same everywhere:
// Subscribe — returns an unsubscribe function
const unsub = store.on('nodes:changed', ({ nodes }) => { ... });
// One-time subscription
store.once('layout:computed', ({ nodes, edges }) => { ... });
// Wildcard — receives every event
store.on('*', ({ event, payload }) => console.log(event, payload));
// Unsubscribe
unsub();
// Or remove all listeners for an event
store.clear('nodes:changed');
// Remove all listeners on the bus
store.clear();GraphStore events
| Event | Payload | When |
|---|---|---|
nodes:changed | { nodes: node[] } | After any node mutation (batched) |
edges:changed | { edges: edge[] } | After any edge mutation (batched) |
layout:computed | { nodes, edges } | After computeLayout() |
node:removed | { uid, descendants: node[] } | Before node deletion (for exit animations) |
node:moved | { uid, x, y, node } | After moveNode() |
node:status-changed | { uid, status, node } | After setNodeStatus() |
SelectionManager events
| Event | Payload |
|---|---|
selection:changed | { selected, lastSelected, lastDeselected, isMulti, compositeUid, compositeLabel } |
PlaceholderManager events
| Event | Payload |
|---|---|
draglink:started | { fromNode } |
draglink:updated | { fromNode, placeholderUid, x, y, snapTarget } |
draglink:committed | { fromNode, targetNode, edgeUid } |
draglink:cancelled | { fromNode } |
SessionLog events
| Event | Payload |
|---|---|
session:event | The recorded event object { id, type, actorId, timestamp, payload } |
PresenceState events
| Event | Payload |
|---|---|
presence:updated | { actorId, presence } |
Batching
Wrapping mutations in batch() defers all events until the block completes. This prevents a burst of nodes:changed / edges:changed events during bulk operations.
store.batch(() => {
nodes.forEach(n => store.addNode(n));
// No events fired yet
});
// 'nodes:changed' fires once here, with all nodesNesting works — events flush only when the outermost batch() completes:
store.batch(() => {
store.batch(() => {
store.addNode(a);
store.addNode(b);
});
store.addNode(c);
// Still no events
});
// 'nodes:changed' fires once hereingest() uses batch() internally, so ingesting 100 nodes emits exactly one nodes:changed.
Using layers independently
graph-headless only
Pure state logic — no DOM at all. Useful in server-side rendering, testing, or non-browser environments.
import { GraphStore, getDescendants, computeLayout } from '@wity/graph-headless';graph-headless + graph-ui-compute
Add DOM bindings. The presentation layer is still entirely up to you.
import { GraphStore } from '@wity/graph-headless';
import { bindPanZoom, bindNodeDrag, keyedJoin } from '@wity/graph-ui-compute';graph-headless + graph-player
Temporal replay with no rendering concerns.
import { GraphStore } from '@wity/graph-headless';
import { GraphPlayer } from '@wity/graph-player';All three via @wity/graph
import { GraphStore, bindPanZoom, GraphPlayer } from '@wity/graph';