Skip to content

@wity/graph-headless ​

Pure state, layout, traversal, geometry, and ontology. No DOM, no framework, zero dependencies.

js
import { GraphStore, SelectionManager, /* ... */ } from '@wity/graph-headless';

GraphAbstract ​

Pure combinatorial graph — vertices and typed edges only. No coordinates, no geometry, no ports, no render styles, no layout. The mathematical foundation: a graph defined entirely by its connectivity.

Use GraphAbstract when only typed relationships matter — knowledge graphs, dependency graphs, AI session memory, environment knowledge. Use GraphStore when nodes have positions in a canvas and edges are drawn as paths.

js
import { GraphAbstract } from '@wity/graph-headless';

const graph = new GraphAbstract();

Node CRUD ​

js
addNode({ uid, type?, label?, data? })   → node        // upserts; merges data shallowly
updateNode(uid, patch)                   → node | null  // merges patch.data into node.data
removeNode(uid)                          → boolean      // removes node + all connected edges
getNode(uid)                             → node | null
hasNode(uid)                             → boolean
getNodes()                               → node[]
nodeCount()                              → number

Edge CRUD ​

Edge uid defaults to ${type}:${srcUid}→${targetUid} — each (type, src, target) triple is naturally unique, so multiple edge types between the same node pair are fully supported.

js
addEdge({ uid?, srcUid, targetUid, type?, data? })  → edge | null  // upserts; merges data
removeEdge(uid)                                      → boolean
getEdge(uid)                                         → edge | null
hasEdge(uid)                                         → boolean
getEdges()                                           → edge[]
edgeCount()                                          → number

Traversal ​

js
getOutgoing(srcUid, type?)     → edge[]   // outgoing edges, optionally filtered by type
getIncoming(targetUid, type?)  → edge[]   // incoming edges, optionally filtered by type
getEdgesOf(nodeUid, type?)     → edge[]   // all edges (in or out), optionally filtered
reachable(startUid, type?)     → node[]   // BFS — all reachable nodes (basic, downstream only)

For richer traversal (direction control, depth limits, edge type filtering, cost functions), use the standalone Algorithms functions.

Query ​

js
getNodesByType(type)   → node[]
getEdgesByType(type)   → edge[]

Serialise / hydrate ​

js
serialise()             → { version, nodes, edges }   // plain object, safe for JSON
hydrate(snapshot)       → void                         // clears and restores; no events emitted
clone()                 → GraphAbstract               // deep copy via round-trip

XML serialization ​

Serialize and parse graphs as XML — the canonical persistence format. Round-trips cleanly.

js
import { serialize, parse, setXmlParser } from '@wity/graph-headless';

// Serialize a graph (accepts GraphAbstract or { nodes, edges } snapshot)
const xml = serialize(graph);

// Parse XML back to a snapshot
const snapshot = parse(xml);

// Hydrate into a GraphAbstract
const restored = new GraphAbstract();
restored.hydrate(snapshot);

In Node.js, inject an XML parser before calling parse():

js
import { DOMParser } from '@xmldom/xmldom';
import { setXmlParser } from '@wity/graph-headless';
setXmlParser(new DOMParser());

XML format ​

xml
<?xml version="1.0" encoding="UTF-8"?>
<wity-knowledge version="1">
  <nodes>
    <node uid="n1" type="continuant" label="Idea">
      <data key="priority" type="number">1</data>
      <data key="status">active</data>
    </node>
  </nodes>
  <edges>
    <edge uid="default:n1→n2" src="n1" target="n2" type="default" />
  </edges>
</wity-knowledge>

Data elements support types: string (default), number, boolean, null, object (JSON-encoded), array (JSON-encoded).

FunctionSignatureDescription
serialize(source) → stringGraphAbstract or snapshot → XML string
parse(xml) → { version, nodes, edges }XML string → snapshot (hydrate-ready)
setXmlParser(parser) → voidInject DOMParser for Node.js environments

Events ​

Extends EventBus. Same subscription API as GraphStore.

'nodes:changed'   { op: 'add'|'update'|'remove', uid }
'edges:changed'   { op: 'add'|'update'|'remove', uid }

GraphStore ​

The central state container. Extends EventBus. Owns all node and edge data, drives layout, and emits events.

Constructor ​

js
const store = new GraphStore(options);
OptionTypeDefaultDescription
viewport{ width, height }{ width: 800, height: 600 }Canvas dimensions — used to centre the root node during layout
defaultActionsobject[][]Graph-level fallback actions when a node has no availableActions
defaultStyleConfigobject[][]Graph-level fallback style config

Node data schema ​

Fields recognised by GraphStore. All are optional except uid (auto-generated if absent).

js
{
  // Identity
  uid:          string,       // unique identifier; auto-generated if absent
  pubKey:       string,       // temp client uid for deduplication (see addNode)
  type:         string,       // node type: 'continuant' | 'occurant' | custom
  variant:      string,       // UI extension point, carried opaquely

  // Content
  title:        string,
  content:      string,       // also accepts msg alias
  message:      string,       // also accepts rawMsg alias

  // Display
  styleClass:   string,       // CSS class on the node wrapper
  styleObj:     object,       // per-node committed visual overrides — write via setNodeStyle()
  availableStyleConfig: object[],  // overrides graph default style config
  showStylePanel: boolean,    // default true; false suppresses style overlay

  // Behaviour
  links:        { targetUid, type? }[],  // edges to build on ingest
  tags:         string[],
  status:       string,       // occurant lifecycle status
  availableActions:   object[],    // per-node toolbar actions
  contextMenuActions: object[],    // per-node context menu actions

  // Temporal (for graph-player)
  createdAt:    number,       // epoch ms
  startedAt:    number,       // epoch ms
  completedAt:  number,       // epoch ms
  erroredAt:    number,       // epoch ms
  cancelledAt:  number,       // epoch ms

  // Actor attribution (carried opaquely)
  createdBy:    string,       // actorId — single, immutable once set
  updatedBy:    string[],     // actorIds — contributors list, append-only by convention
}

Node CRUD ​

addNode(data) → node ​

Add or upsert a node. Handles pubKey deduplication — if a temp node with uid === data.pubKey exists, it is swapped out for the real uid while preserving position state.

js
const node = store.addNode({
  uid:   'concept-1',
  type:  'continuant',
  title: 'My concept',
  createdBy: 'user-1',
});

updateNode(uid, data) → node | null ​

Partial update. Does not reset layout state unless _computedInitialProps is explicitly passed.

js
store.updateNode('concept-1', { title: 'Updated title', updatedBy: ['user-1', 'user-2'] });

removeNode(uid) ​

Removes a node and all its connected edges. Emits 'node:removed' with { uid, descendants: node[] } before deletion — use this for exit animations.

removeNodes(uids) ​

Remove multiple nodes in a single batch. More efficient than calling removeNode() in a loop — emits one 'nodes:removed' event before deletion and coalesces 'nodes:changed' + 'edges:changed' into single emissions.

The caller decides the scope — pass descendants explicitly using getDescendants() if deleting a branch.

js
// Delete a single branch
const descendants = store.getDescendants(uid);
store.removeNodes([uid, ...descendants.map(d => d.uid)]);

// Listen for bulk removal
store.on('nodes:removed', ({ uids, nodes }) => animateExits(nodes));

SelectionManager auto-deselects all removed nodes and emits one 'selection:changed' — no extra wiring needed.

getNode(uid) → node | null ​

Returns a direct reference to the internal node object — not a copy. Read-only use is safe. Any mutation must go through updateNode() or a targeted setter (setNodeStyle, setNodeStatus) to trigger events; direct property assignment silently bypasses listeners.

getNodes() → node[] ​

hasNode(uid) → boolean ​

nodeCount → number ​

Edge CRUD ​

addEdge(data) → edge | null ​

Edge uid is deterministic: ${srcUid}-link-${targetUid}. Returns existing edge if already present (pass forceUpdate: true to override).

js
store.addEdge({
  srcUid:    'concept-1',
  targetUid: 'concept-2',
  type:      'default',      // optional, defaults to 'default'
  createdBy: 'user-1',
});

removeEdge(uid) ​

getEdge(uid) → edge | null ​

getEdges() → edge[] ​

refreshEdgePath(edgeUid) → edge ​

Recompute the SVG path for a single edge. Call after moving one of its endpoint nodes.

refreshEdgePathsOfNode(nodeUid) ​

Recompute paths for all edges connected to a given node. Called internally by moveNode.

Targeted mutations ​

moveNode(uid, x, y) → node | null ​

Moves node to (x, y) in SVG space, refreshes connected edge paths, emits 'node:moved'. Use this instead of updateNode for drag — it emits the narrow event so only the moved node and its edges need to update.

setNodeStatus(uid, status) ​

Update occurant lifecycle status. Emits narrow 'node:status-changed' — status indicators update without a full node re-render.

js
store.setNodeStatus('task-1', 'running');
store.setNodeStatus('task-1', 'completed');

setNodeStyle(uid, styleObj) ​

getNodeStyle(uid) → object | null ​

The single write path for per-node style data. setNodeStyle writes to node.styleObj and emits narrow 'node:style-changed' — style overlays update without a full node re-render.

getNodeStyle reads from the store as the source of truth. Returns null if the node doesn't exist or has no committed styles.

js
store.setNodeStyle('concept-1', { background: '#f5f0e8', fontWeight: 'bold' });
store.on('node:style-changed', ({ uid, styleObj, node }) => applyStyle(uid, styleObj));

const persisted = store.getNodeStyle('concept-1');  // { background: '#f5f0e8', ... }

TIP

Always use setNodeStyle rather than mutating node.styleObj directly. Direct mutation bypasses 'node:style-changed' — presentation layers won't know to update.

setNodeData(uid, data) ​

getNodeData(uid) → node | null ​

The single write path for attaching opaque external data to a node (media, composed forms, AI results, etc.). setNodeData shallow-merges data onto the node via Object.assign and emits narrow 'node:data-changed' — presentation layers can subscribe and update the corresponding live component without a full re-render.

getNodeData returns the full node object (mutable reference — read-only use only).

js
store.setNodeData('concept-1', { composedForms: [...], mediaUrl: '...' });
store.on('node:data-changed', ({ uid, data, node }) => {
    getVectorInterface(uid)?.setComposedForms(data.composedForms);
});

Shallow merge

setNodeData uses Object.assign — top-level keys are merged, nested objects are replaced wholesale. To update a single nested key, spread the existing value: { meta: { ...node.meta, key: val } }.

Layout ​

computeLayout(options?) ​

Compute positions for all nodes and refresh all edge paths. Emits 'layout:computed'.

Already-placed nodes (with _computedInitialProps: true) are skipped — user-dragged positions survive layout runs. To force recompute on a node, set node._computedInitialProps = false first.

js
store.computeLayout({ paginationThreshold: 5 });
OptionTypeDescription
paginationThresholdnumberSiblings beyond this index are given visibility: 'hidden'

Bulk operations ​

ingest(nodesData, options?) ​

Batch-add nodes, build edges from node.links, compute layout, emit one set of events.

Handles out-of-order arrival — if node B arrives before node A but links to A, B's edge to A is built when A arrives.

js
store.ingest([
  { uid: 'a', type: 'continuant', title: 'Root' },
  { uid: 'b', type: 'occurant',   title: 'Process', links: [{ targetUid: 'a' }] },
], { paginationThreshold: 8 });

updateNodesBatch(updates, field) ​

Bulk update a specific field across multiple nodes without triggering layout.

js
store.updateNodesBatch(serverUpdates, 'content');  // 'content' | 'style' | 'tags'

Action resolution ​

resolveActionsForSelection(selectedUids) → object[] ​

Merges per-node availableActions with graph defaults, deduplicates, then filters by each action's applicability schema.

js
const actions = store.resolveActionsForSelection(['node-1', 'node-2']);

Action applicability schema:

js
{
  id:            'my-action',
  label:         'Do something',
  applicability: {
    minNodes:    1,          // minimum selection size
    maxNodes:    Infinity,   // maximum selection size
    nodeTypes:   ['continuant'],    // restrict to these types
    nodeVariants: ['draft'],        // restrict to these variants
  }
}

resolveContextMenuActions(uid) → object[] ​

Returns node.contextMenuActions if set, otherwise defaultContextMenuActions.

Viewport ​

setViewport({ width, height }) ​

getViewport() → { width, height } ​

Batching ​

batch(fn) ​

Defer all events until fn completes. Nested batches supported.

js
store.batch(() => {
  store.addNode(nodeA);
  store.addNode(nodeB);
  store.addEdge({ srcUid: 'a', targetUid: 'b' });
});
// One 'nodes:changed' and one 'edges:changed' emitted here

Sync only

fn must be synchronous. Async callbacks are not supported — #flushEvents() fires before any awaited work completes, so async mutations inside the batch bypass the dedup mechanism. Use sequential await calls outside batch() instead.

Lifecycle ​

destroy() ​

Clears all state and removes all event listeners. Call from component teardown.


EventBus ​

Base class for all state machines. Extend it for custom event sources.

js
import { EventBus } from '@wity/graph-headless';

class MyStore extends EventBus {
  doSomething() {
    this.emit('did:something', { data: 42 });
  }
}
MethodReturnsDescription
on(event, handler)() => void (unsubscribe)Subscribe to an event
once(event, handler)() => void (unsubscribe)Subscribe once
off(event, handler)—Remove specific handler
emit(event, payload)—Emit to all handlers; wildcard '*' receives { event, payload }
clear(event?)—Remove all handlers for an event, or all if omitted

SelectionManager ​

Headless selection state. Extends EventBus.

js
const selection = new SelectionManager(store);

Requires a GraphStore — auto-deselects nodes on 'node:removed'.

Methods ​

MethodDescription
select(uid, { addToSelection? })Select. Clears previous selection unless addToSelection: true
deselect(uid)Deselect a node
toggle(uid, { addToSelection? })Toggle selection
clear(excludeUids?)Clear all; optionally keep specified UIDs selected

Queries ​

Property/MethodReturnsDescription
getSelected()node[]All selected nodes
getSelectedUids()string[]All selected UIDs
isSelected(uid)boolean
countnumber
isMultibooleanMore than one selected
lastSelectednode | nullMost recently selected node
lastDeselectednode | nullMost recently deselected node
compositeUidstringSorted UIDs joined by | — stable cache key
compositeLabelstringHuman-readable label from node titles

Events ​

js
selection.on('selection:changed', ({ selected, lastSelected, lastDeselected, isMulti, compositeUid, compositeLabel }) => {
  updateToolbar(selected);
});

PlaceholderManager ​

Drag-to-link state machine. Extends EventBus. Keeps drag interaction logic out of the rendering layer.

js
import { PlaceholderManager } from '@wity/graph-headless';
const dragLink = new PlaceholderManager(store, {
  snapThreshold:  300,   // uniform snap radius in px (default)
  // snapXThreshold: 400, snapYThreshold: 200,  // or set axes independently
});
OptionTypeDefaultDescription
snapThresholdnumber300Uniform horizontal + vertical snap radius (px)
snapXThresholdnumber300Horizontal snap radius — overrides snapThreshold
snapYThresholdnumber300Vertical snap radius — overrides snapThreshold

State machine ​

idle → start(fromUid)  → active
active → update(x, y)  → active (with snap detection)
active → commit()      → idle  (real edge created)
active → cancel()      → idle  (no edge)

Methods ​

MethodDescription
start(fromUid)Begin drag from a node; creates invisible placeholder node + edge
update(x, y, xThreshold?, yThreshold?)Move placeholder; detects snap targets within threshold (constructor default or per-call override)
commit(explicitTargetUid?)Create real edge; remove placeholder
cancel()Remove placeholder, no edge created

Events ​

js
dragLink.on('draglink:started',   ({ fromNode }) => { ... });
dragLink.on('draglink:updated',   ({ fromNode, placeholderUid, x, y, snapTarget }) => { ... });
dragLink.on('draglink:committed', ({ fromNode, targetNode, edgeUid }) => { ... });
dragLink.on('draglink:cancelled', ({ fromNode }) => { ... });

PanZoomState ​

Headless pan/zoom state machine. No DOM. Use directly when you need pan/zoom without a GraphStore (e.g. a plain SVG canvas). For graph use cases, GraphCanvasState wraps this internally — prefer that.

js
const panZoom = new PanZoomState({ minZoom: 0.1, maxZoom: 5 });
OptionDefaultDescription
minZoom0.05Floor on zoom level
maxZoom10Ceiling on zoom level

Mutations ​

MethodDescription
setPan(x, y)Set pan in screen pixels
panBy(dx, dy)Relative pan
zoomToPoint(zoom, screenX, screenY)Zoom while keeping screen point fixed (wheel zoom)
zoomToCenter(zoom, vpWidth, vpHeight)Zoom around viewport centre (programmatic)
setZoomRaw(zoom)Set zoom without adjusting pan
setMinZoom(v)Override min zoom after construction
setMaxZoom(v)Override max zoom after construction

Queries ​

Property/MethodReturns
pan{ x, y }
zoomnumber
screenToSvg(sx, sy){ x, y } — screen → SVG space
svgToScreen(svgX, svgY){ x, y } — SVG → screen space

GraphCanvasState ​

Single source of truth for all computed canvas state. Composes GraphStore + PanZoomState + viewport. PanZoomState is created internally — no need to instantiate it separately.

js
const canvas = new GraphCanvasState(store, { width: 1200, height: 800, minZoom: 0.1, maxZoom: 5 });
OptionDefaultDescription
width800Viewport width in px
height600Viewport height in px
minZoom0.05Passed through to PanZoomState
maxZoom10Passed through to PanZoomState

Methods ​

MethodReturnsDescription
getTransform()stringSVG matrix(...) string for <g class="kg-viewport">
getNodeScreenRect(uid){ x, y, width, height }Screen-space bounding rect
getOverlayAnchor(uid, gap?){ x, y }Screen position for action overlay (right of node top edge)
getPanTargetForNode(uid, opts?){ x, y }Pan values to centre node at given zoom
isNodeInViewport(uid)booleanWhether any part of the node is visible
screenToSvg(sx, sy)[svgX, svgY]Coordinate transform
svgToScreen(svgX, svgY)[screenX, screenY]Coordinate transform

getPanTargetForNode options:

OptionDefaultDescription
zoomcurrent zoomZoom level to use when computing
xOffset0Shift node left of centre by this many SVG units
yOffset0Shift node above centre (positive = up)

Traversal ​

All functions are pure and stateless. They accept Map<uid, node> and Map<uid, edge> directly, or use the delegate methods on GraphStore.

All functions return node objects (not UIDs). To get UIDs: result.map(n => n.uid).

js
import { getChildren, getDescendants, /* ... */ } from '@wity/graph-headless';

// Direct (stateless, pass maps)
const children = getChildren('root-id', nodesMap);

// Via store delegate (convenience)
const children = store.getChildren('root-id');
FunctionDescription
getChildren(uid, nodes)Direct children (nodes linking TO this node)
getDescendants(uid, nodes)All descendants depth-first
getParents(uid, nodes)Direct parents (nodes this node links TO)
getAncestors(uid, nodes)Full ancestor chain
getRoots(nodes)Nodes with no outgoing links
getEdgesOfNode(uid, edges)All edges where node is source or target
getOutgoingEdges(uid, edges)Edges where node is source
getIncomingEdges(uid, edges)Edges where node is target
findCommonParent(uidA, uidB, nodes)First shared parent, or false
getDepth(uid, nodes)Distance from nearest root (0 = root)

Algorithms ​

Pure functions over a GraphAbstract. Directional BFS propagation, reachability, Dijkstra shortest path, and DFS all-paths enumeration. All support direction control (downstream/upstream/both), edge type filtering, and depth limits.

js
import { propagate, reachable, shortestPath, allPaths } from '@wity/graph-headless';

propagate(graph, fromUid, options?) → { uid, depth, via }[] ​

BFS propagation from a source node. Returns each reached node with its depth and the node it was reached through.

OptionTypeDefaultDescription
direction'downstream' | 'upstream' | 'both''downstream'Edge traversal direction
edgeTypesstring[]allRestrict to these edge types
maxDepthnumberInfinityStop at this depth
filter(node) => boolean—Skip nodes that fail the predicate
js
propagate(graph, 'S1', { direction: 'downstream', maxDepth: 2 });
// → [{ uid: 'V1', depth: 1, via: 'S1' }, { uid: 'P1', depth: 2, via: 'V1' }]

reachable(graph, fromUid, options?) → string[] ​

All node UIDs reachable from a source via BFS. Same options as propagate except filter.

js
reachable(graph, 'S1', { edgeTypes: ['feeds'], maxDepth: 3 });
// → ['V1', 'P1', 'T1']

shortestPath(graph, fromUid, toUid, options?) → { path, cost } | null ​

Dijkstra's shortest path with a pluggable cost function.

OptionTypeDefaultDescription
direction'downstream' | 'upstream' | 'both''downstream'Edge traversal direction
edgeTypesstring[]allRestrict to these edge types
cost(edge) => number() => 1Edge cost function
js
shortestPath(graph, 'S1', 'H1', { cost: e => e.data.length || 1 });
// → { path: ['S1', 'V1', 'P1', 'H1'], cost: 1650 }

allPaths(graph, fromUid, toUid, options?) → { path, cost }[] ​

All simple paths via depth-limited DFS. Same options as shortestPath plus maxDepth (default: 20).

js
allPaths(graph, 'S1', 'H1', { maxDepth: 10 });
// → [{ path: ['S1', 'V1', 'P1', 'H1'], cost: 3 }, ...]

Layout ​

computeLayout(nodes, viewBox, options) → node[] ​

Horizontal tree layout. Modifies nodes in-place, returns the array.

Called internally by store.computeLayout() — you rarely need this directly.

computeNodePosition(node, nodeIdx, nodes, viewBox, options) → node ​

Position a single node. Idempotent — skips if node._computedInitialProps is true.

getNodesAroundPoint(x, y, nodes, xThreshold, yThreshold, excludeUids) → node[] ​

Proximity query. Returns nodes within (xThreshold, yThreshold) of point. Used by PlaceholderManager for snap detection.

rectsOverlap(a, b) → boolean ​

getOverlappingNodes(nodes) → [node, node][] ​

resolveOverlaps(nodes, padding?) → node[] ​

Overlap detection and resolution utilities. resolveOverlaps iteratively shifts nodes apart (up to 10 passes). Useful for custom layout post-processing.


Geometry ​

Point / rect queries ​

js
import { getNodeAtPoint, getNodesInRect } from '@wity/graph-headless';
FunctionReturnsDescription
getNodeAtPoint(x, y, nodes, opts?)node | nullTopmost node whose bounding box contains (x, y). Options: exclude: string[], padding: number
getNodesInRect(rx, ry, rw, rh, nodes)node[]All nodes whose bounding box overlaps the given rectangle
js
// Hit-test a click in SVG space
const hit = getNodeAtPoint(svgX, svgY, store.getNodes(), { exclude: ['placeholder-123'] });

// Rubber-band selection
const inRect = getNodesInRect(selX, selY, selW, selH, store.getNodes());

Port geometry ​

js
import { getPortSvgPos, getPortDots, getActiveInputPorts,
         getDefaultOutputPortId, getDefaultInputPortId } from '@wity/graph-headless';
FunctionReturnsDescription
getPortSvgPos(node, portId, getConfig){ x, y }Absolute SVG position of a named port
getPortDots(node, getConfig)object[]All port positions for a node
getActiveInputPorts(node, edges, getConfig)object[]Declared input ports + overflow port if all connected
getDefaultOutputPortId(type, getConfig)stringFirst output port id, or 'out'
getDefaultInputPortId(type, getConfig)stringFirst input port id, or 'in'

Path geometry ​

js
import { horizontalLinkPath, computeNodeLinkPath,
         polylinePath, smoothPolylinePath, computePolylinePath } from '@wity/graph-headless';
FunctionReturnsDescription
horizontalLinkPath(source, target)stringSVG d attribute for cubic bezier between two [x, y] points
computeNodeLinkPath(srcNode, tgtNode, getConfig, srcPortId?, tgtPortId?)stringFull path resolving port positions
polylinePath(points)stringStraight-segment SVG path through [x, y][] waypoints
smoothPolylinePath(points, tension?)stringCatmull-Rom smooth curve through waypoints (passes through every point)
computePolylinePath(edge, srcNode, tgtNode, options?)stringSVG path for edges with edge.data.waypoints. Options: { smooth?: false, tension?: 0.5 }

computePolylinePath reads waypoints from edge.data.waypoints — an array of [x, y] or { x, y } points. Falls back to a straight line if no waypoints exist. Use for edges that follow real-world routes (pipe networks, cable runs, transmission lines) rather than abstract bezier curves between ports.

Pan target ​

js
import { getPanTargetForNode, getFitToContent } from '@wity/graph-headless';

const pan = getPanTargetForNode(node, layout, viewport, { zoom: 1.2, yOffset: 100 });
// → { x, y }  — set panZoom.setPan(pan.x, pan.y)

getFitToContent(nodes, viewport, options?) → { pan, zoom } | null ​

Pure math — compute the pan and zoom that fit all placed nodes into the viewport. Returns null if no nodes have been placed yet.

Accepts any array of { x, y, w, h } objects — not just store.getNodes(). Unplaced nodes (x == null) are skipped automatically.

js
// From store
const result = getFitToContent(store.getNodes(), canvas.getViewport());

// From a filtered subset
const result = getFitToContent(store.getNodes().filter(n => n.type === 'entity'), viewport);

if (result) {
    // Animated (simultaneous pan+zoom — use animateToFit, not animateTo)
    animateToFit(result);

    // Or instant
    canvas.setPan(result.pan.x, result.pan.y);
    canvas.zoomToCenter(result.zoom);
}

Use animateToFit, not animateTo

getFitToContent computes pan at the target zoom. animateTo sequences pan→zoom, which shifts the final pan. Use animateToFit from bindPanZoom for correct animated fit-all.

OptionTypeDefaultDescription
paddingnumber0.9Fraction of viewport to fill — 0.9 leaves ~10% margin on each side
minZoomnumber0.1Floor on computed zoom
maxZoomnumber1Ceiling on computed zoom — default prevents magnifying a small graph beyond 1:1

Objects API ​

A graph object is a third entity type — anchored to a node, positioned by offset, and carrying opaque data. Objects pan/zoom with the canvas and reposition automatically when their anchor node is dragged. Use them for progress indicators, selection halos, annotation bubbles, connection hints, or any decorator that must track a node.

js
// Add a progress ring anchored to a node
store.addObject('progress-abc', {
    anchoredTo: 'node-abc',
    offset:     { x: 60, y: -20 },   // top-right of node
    movable:    false,
    type:       'progress-ring',
    progress:   0.6,
});

// React in presentation layer
store.on('objects:changed', ({ objects }) => renderObjects(objects));
store.on('object:moved',    ({ uid, x, y }) => repositionObject(uid, x, y));

Object shape ​

FieldTypeDescription
uidstringUnique identifier
anchoredTostringUID of the anchor node
offset{ x, y }Offset from anchor origin in SVG space. Default: { x: 0, y: 0 }
movablebooleanWhether the object can be dragged. Default: false
typestringOpaque — presentation layer defines meaning
...dataanyAny additional fields — stored and emitted as-is

Methods ​

addObject(uid, config) → object | null ​

Add an object anchored to a node. Returns null with a console warning if the anchor node doesn't exist.

removeObject(uid) ​

Remove an object. When an anchor node is deleted, all its anchored objects are automatically removed.

moveObject(uid, offsetX, offsetY) ​

Update a movable object's offset. Emits 'object:moved' with the new absolute SVG position.

js
// Wire drag via bindNodeDrag
bindNodeDrag(objectEl, {
    getData: () => store.getObject(uid),
    onDrag:  ({ dx, dy }) => {
        const obj = store.getObject(uid);
        store.moveObject(uid, obj.offset.x + dx, obj.offset.y + dy);
    },
});

getObject(uid) → object | null ​

getObjects() → object[] ​

getObjectsForNode(nodeUid) → object[] ​

Events ​

EventPayloadWhen
objects:changed{ objects }After addObject() / removeObject()
object:moved{ uid, x, y, offset, object }After moveObject() or when the anchor node is dragged

x, y in object:moved is the absolute SVG position — ready for setAttribute('transform', ...) without further math.


Ontology API ​

js
import {
  NODE_TYPES, DEFAULT_NODE_TYPE, getNodeTypeConfig, getDataSchema, registerNodeType,
  LINK_TYPES, DEFAULT_LINK_TYPE, getLinkTypeConfig, registerLinkType,
} from '@wity/graph-headless';
ExportDescription
NODE_TYPES{ continuant: { label, layout, ports, style }, occurant: {...}, placeholder: {...} }
DEFAULT_NODE_TYPE'continuant'
getNodeTypeConfig(type)Returns config for type, falls back to 'continuant' if unknown
getDataSchema(type)Returns the dataSchema for a node type, or null if none is defined
registerNodeType(name, config)Register a new custom node type (full config required)
patchNodeType(name, patch)Partially update a built-in or registered type — deep-merges per structural key
LINK_TYPES{ default: { label, description, style }, placeholder: {...}, semantic: {...} }
DEFAULT_LINK_TYPE'default'
getLinkTypeConfig(type)Returns config for type
registerLinkType(name, config)Register a custom link type at runtime

patchNodeType(name, patch) ​

Partially update a built-in or registered node type. Deep-merges one level per structural key — only the fields you provide change; everything else (ports, spacing, classes) stays untouched.

js
import { patchNodeType } from '@wity/graph-headless';

// Change occurant height — only express what changes
patchNodeType('occurant', {
  layout: { height: 100 },    // other layout keys (width, xSpacing, ySpacing) unchanged
  style:  { heightCss: '6.25em' },  // other style keys unchanged
});

Call at app initialisation, before any store.addNode() for that type. node.w and node.h are stamped at addNode() time — patching after nodes exist leaves their cached dimensions stale until recreated.

Port positions (getPortSvgPos) always read the live config, so edge paths and port dots reflect the patch immediately.

layout.height and style.heightCss must be kept in sync manually — they are in different units (px vs em) and the library has no root font-size knowledge to convert between them.

vs registerNodeType: use registerNodeType for brand-new types (requires a full config); use patchNodeType to adjust an existing type.

dataSchema — typed properties for domain ontologies ​

Node type registrations accept an optional dataSchema field — a plain object describing the typed properties that node.data should carry for nodes of this type.

js
registerNodeType('valve', {
  label:  'Valve',
  layout: { xSpacing: 200, ySpacing: 150, width: 40, height: 40 },
  ports:  { inputs: [{ id: 'in', side: 'input', yFraction: 0.5, xOffset: 0 }],
            outputs: [{ id: 'out', side: 'output', yFraction: 0.5, xOffset: 0 }] },
  dataSchema: {
    pressure:  { type: 'number', unit: 'kPa' },
    status:    { type: 'enum', values: ['open', 'closed', 'partial'] },
    diameter:  { type: 'number', unit: 'mm' },
  },
});

// Query the schema for any registered type
const schema = getDataSchema('valve');
// → { pressure: { type: 'number', unit: 'kPa' }, ... }

dataSchema is carried opaquely by the headless layer — no runtime validation is performed. It serves as a contract for domain-specific consumers: UI layers can generate property panels from it, serialisation layers can enforce it, and domain ontologies (SysML2, industry-specific schemas) can declare their property sets through it.

patchNodeType preserves dataSchema through merges. Passing dataSchema in a patch replaces it; omitting it keeps the existing schema.


Actors & Session ​

See Actors & Sessions guide for full usage.

js
import { ActorRegistry, SessionLog, PresenceState } from '@wity/graph-headless';
ClassDescription
ActorRegistryLookup store mapping actorId strings to display metadata
SessionLogAppend-only event log; auto-captures GraphStore events; record(), getEvents()
PresenceStateLive per-actor snapshot; cursor positions + selections; 'presence:updated' event

BatchProcessor ​

Sequential async task queue.

js
import { BatchProcessor } from '@wity/graph-headless';

const queue = new BatchProcessor({ intervalMs: 50 });
queue.enqueue(async () => await fetchNode('a'));
queue.enqueue(async () => await fetchNode('b'));
await queue.drain();
Method/PropertyDescription
enqueue(fn)Add async task; returns this (chainable)
drain()Returns Promise that resolves when queue is empty and idle
sizePending task count
busyboolean — currently processing
clear()Remove pending tasks (not the currently running one)

Errors from individual tasks are logged but do not stop the queue.