@statelyai/flow
v0.9.1
Published
Framework-agnostic flow visualization engine. Builds on @statelyai/graph.
Maintainers
Readme
@statelyai/flow
Framework-agnostic flow visualization engine. Built on @statelyai/graph VisualGraph + @xstate/store + Immer.
Render adapters (React, Vue, tldraw, etc.) sit on top. @statelyai/flow owns the pure state, layout, routing, and interaction logic. DOM-specific helpers such as ResizeObserver integration live in @statelyai/flow-dom.
Install
npm install @statelyai/flow @statelyai/graph @xstate/store@statelyai/graph is a peer dependency — you bring your own version.
If you want DOM helpers such as attachContainer() and createMeasurementObserver(), install @statelyai/flow-dom; it installs and re-exports the core package.
Quick start
import { createFlow } from '@statelyai/flow';
const flow = createFlow({
graph: {
direction: 'down',
nodes: [
{
type: 'node',
id: 'a',
x: 0,
y: 0,
width: 200,
height: 80,
data: { label: 'Start' },
},
{
type: 'node',
id: 'b',
x: 0,
y: 150,
width: 200,
height: 80,
data: { label: 'End' },
},
],
edges: [
{
type: 'edge',
id: 'e1',
sourceId: 'a',
targetId: 'b',
x: 0,
y: 0,
width: 0,
height: 0,
data: {},
},
],
},
});
// Read state
const { graph, viewport, selection } = flow.getContext();
// Use the public controller namespaces
flow.selection.set(['a']);
flow.viewport.panBy({ x: 100, y: 0 });
flow.nodes.setData('a', { label: 'Selected' });
flow.view.setScope({ parentId: null, depth: 1 });
// Query derived state through the engine-backed controller
const selectedBounds = flow.getSelectionBounds();createFlow() owns the store and engine together. Use createFlowStore() and
createFlowEngine() directly when you need lower-level control.
Layout adapters
Install only the engine you use:
npm install @statelyai/flow elkjsUse @dagrejs/dagre instead of elkjs for dagre. Both are optional peer
dependencies, so installing Flow alone does not install either engine.
import { createFlow } from '@statelyai/flow';
import { elkLayout } from '@statelyai/flow/layout/elk';
const flow = createFlow({ graph });
await flow.layout.run(
elkLayout({
nodeSpacing: 80,
rankSpacing: 120,
})
);elkLayout() supports compound nodes, ports, edge labels, graph direction,
per-group child direction, measured bounds via getBounds, and scoped
incremental layout with scope: { ids }.
Results include computed node sizes and bounds, label bounds, routed interior
waypoints, source metadata, and diagnostics. Hierarchical ELK runs repair
invalid nested label positions by choosing the nearest node-free position, keep
intra-compound labels inside their owning group, reserve 24px of rank clearance
around native labels at both root and child levels, favor straight child edges, and
reserve sibling space for the final label-expanded derived bounds. Cross-level
ancestor/descendant edges force integrated hierarchy handling even when a caller's
general ELK options request separate child passes; their labels reserve a clear
lane between the group's content block and the descendant instead of being
pushed outside the compound. When integrated hierarchy would override a
conflicting per-group direction, a focused layered pass restores that group's
child ranks without changing the root direction. Scoped passes send only
the induced graph to ELK; fixed and pinned entities remain in the input but are
omitted from returned geometry.
await flow.layout.run(elkLayout({ scope: { ids: candidateIds } }), {
scope: { parentId: 'group', depth: 1 },
fixedEntityIds: new Set(['pinned-node']),
source: 'editor',
});dagreLayout() flattens hierarchy and rejects scoped layout. It returns node
sizes, node bounds, and edge-label bounds without fabricating unsupported route
waypoints.
import { dagreLayout } from '@statelyai/flow/layout/dagre';
await flow.layout.run(dagreLayout());Data model
The canonical data model is VisualGraph from @statelyai/graph. Flow extends it with:
FlowNode—VisualNode+{ kind, dx, dy, style, ports, autoSize, autoSizeAnchor, selectionBounds, resize, hidden, selectable, draggable, connectable, deletable, resizable }for application-defined rendering kinds, drag offsets, DOM-owned sizing, anchor policy, optional marquee hit geometry, style overrides, resize constraints, and per-entity behavior. Keep the structuraltypeequal to"node"; usekindfor variants such asstateorannotation.FlowEdge—VisualEdge+{ label, waypoints, svgPath, sourcePort, targetPort, kind, style, labelAnchor }for edge labels, editable router via-points, exact SVG geometry, port binding, and edge styling. CanonicalVisualEdge.pointsplusroutingare an authoritative precomputed route and bypass Flow routers; an optionalsvgPathcontrols exact SVG paint while points remain authoritative for interaction geometry. Reconnects, node geometry updates, committed drags, and layout application clear stale precomputed routes; layout-provided edge routes replace them with router waypoints.label.autoSizemakes rendered DOM own transient label dimensions without overwriting authored fallbackwidth/height; inherited edgex/y/width/heightare reserved, while positioned edge content belongs onlabel.FlowGraph—VisualGraphwithFlowNode[]andFlowEdge[]FlowEntityRef— descriptor for a node, edge, port, or block resolved by id.FlowAddressableis a deprecated alias.
Positions use a two-layer system: x/y from layout, dx/dy from user drag. Visual position = (x + dx, y + dy). Paint order uses hierarchy depth, per-entity zIndex, and selected elevation.
For DOM-owned nodes, autoSizeAnchor is 'top-left' by default. Use
'center' to preserve the visual center, or { x: 0.5, y: 0 } to preserve
only the horizontal center. Fractions must be finite and within 0..1.
Node measurements, including their anchor correction, and label.autoSize
measurements remain derived session state: they emit no graph commit, create no
undo entry, and do not overwrite authored dimensions. Auto-size label
measurements drive routing, graph/selection bounds, hit testing, snapping,
culling, and toolbar anchors; surviving measurements remain stable across
replaceGraph() calls.
Graph projections
A projection is derived data addressed to graph entities; a mapper decorates nodes/edges from that data; Flow composes multiple active projections into final visual attrs.
GraphProjection—{ id, label?, nodes?, edges? }, withnodes/edgeskeyed by existing entity idsGraphProjectionMapper— maps projected node/edge datum to visual attrsActiveGraphProjection—{ projection, mapper, priority? }GraphNodeAttrs/GraphEdgeAttrs— visual attrs such as class, style, color, opacity, stroke width, labels, badges, and tooltips
Composition is deterministic: lower priority first, then array order. Attrs merge shallowly, className concatenates, style merges with later values winning, scalar attrs later-win, and badges/tooltips aggregate.
createFlow({ projections }) can own the active projection list directly. Use flow.projections.set(), flow.projections.add(), flow.projections.remove(id), flow.projections.clear(), flow.projections.subscribe(listener), and flow.projections.getAttrs() when projections are application state rather than a render prop. Projection data for missing graph ids is ignored.
Graph traversal
Use getIncomers(graph, id), getOutgoers(graph, id), getConnectedEdges(graph, ids), and getNeighbors(graph, id) for common adjacency queries. The same helpers are available on flow for the current graph. Node-returning helpers skip dangling endpoints and return unique nodes in graph order. flow.connections.get({ nodeId, port?, direction? }) returns endpoint addresses and edges in stable graph order.
Structural views
flow.view scopes the canonical graph without changing it or mixing structural
visibility with visual projections/highlights. The default scope is
{ parentId: null, depth: null }, preserving the full visible graph.
flow.view.setScope({ parentId: 'compound', depth: 1 }, { selection: 'prune' });
const { nodes, edges, boundaryEndpoints } = flow.view.getEntities();Depth 1 exposes direct children, 2 includes grandchildren, 0 is an empty
view, and null is unlimited. Edges owned by the exposed containers are
included. Endpoints below the depth boundary are lifted to their visible
ancestor; those returned edge objects are frozen clones, while the canonical
edge remains unchanged. getBoundaryEndpoints(edgeId) reports both original
and view endpoint ids.
Changing scope validates the parent/depth atomically and prunes selection to
the next view by default; use { selection: 'clear' } to clear it. selection.all()
and viewport.fit() use the current view. Pass { includeHidden: true } to
view.getEntities() or selection.all() when explicitly needed. Active graph
projections remain controller-owned and independent of the structural view.
Viewport fit frame
Register the unobscured screen-space rectangle when persistent UI overlays cover part of the canvas. Fit, center, and zoom commands with no explicit pivot then target that frame. The default remains the full, current viewport. Derived groups fit by their current family bounds, including visible descendants, rather than by the stored content-block anchor.
flow.viewport.setFitFrame({ x: 320, y: 0, width: 880, height: 800 });
// Keep the same world point centered as a docked panel changes the frame.
flow.viewport.setFitFrame(nextFrame, { preserveCenter: true });
// Floating chrome can register live, measured screen-space exclusions. Fit,
// center, and default zoom pivots use the remaining safe frame.
const unregister = flow.viewport.registerFitExclusion({
side: 'left',
gap: 8,
getRect: () => measuredPaletteRect,
});
flow.viewport.fit();
flow.viewport.fitBounds(bounds);
flow.viewport.centerOn({ x: 100, y: 50 });
flow.viewport.zoomTo(1.5);
flow.viewport.setFitFrame(null); // reset to the full viewport
unregister();Frames require finite x/y and positive finite dimensions. A frame on
fit(), fitBounds(), or centerOn() overrides the registered frame for that
call. preserveCenter shifts the viewport by the fit-frame center delta, so
resizing or docking panels keeps the same world point centered. fit() still composes with the current structural view and its nodes
subset. Animation resolves the same frame-aware target before it begins.
Duration-based viewport animations use focal, log-space zooming. Set lerp to
an exponential smoothing factor in (0, 1] for a camera that continuously
approaches changing active entities. The factor is normalized to a nominal
60 Hz frame, so it feels consistent across display refresh rates:
flow.viewport.fit({
nodes: activeEntityIds.map((id) => ({ id })),
lerp: 0.15,
});Every viewport mutation (set, panBy, zoomTo, zoomBy, fit,
fitBounds, and centerOn) returns ViewportAnimationControls with cancel()
and a done promise. done resolves on completion or cancellation. Immediate
and targetless commands return already-resolved controls; a targetless command
also cancels any older animation, so no viewport writes continue after its
done. A newer viewport command or direct low-level viewport write cancels and
resolves the previous controls.
Ports
Ports are based on the visual node port model from @statelyai/graph.
FlowNodeinheritsportsfromVisualNodeFlowPortisVisualPortplus flow'ssidehintsourcePort/targetPortonFlowEdgereference those port names'*'means "choose the closest available port"nullmeans "use the node shape anchor"
At runtime, flow expects port rectangles to be available for routing and hit geometry: x, y, width, height relative to the node origin. In practice this means the port objects in flow are graph visual ports, with one extra flow-specific field: side.
Connection gestures snap to the nearest valid port inside engine.connection.radius (default 24 canvas pixels). In the default loose mode, node body targets remain valid fallback targets; strict mode requires port targets.
For controlled graphs, reconnectMode({ commit: false, dragThreshold: true, preview: false }) keeps pointer capture, thresholding, auto-pan, target resolution, and lifecycle events in Flow while leaving domain mutation and temporary geometry to the controller. Exact DOM node/port targets take priority, then reconnection falls back to Flow's spatial candidate. Groups are fully targetable by default; group.connectionBoundaryWidth limits spatial body targeting to the content block and an inset child-surface boundary, leaving its interior as canvas. connectionEnded.reconnectedEdgeId identifies connected, invalid, and cancelled observer-owned reconnects.
const graph = {
nodes: [
{
type: 'node',
id: 'a',
x: 0,
y: 0,
width: 160,
height: 60,
ports: [
{
name: 'out',
side: 'right',
direction: 'out',
x: 150,
y: 25,
width: 10,
height: 10,
},
],
data: {},
},
],
edges: [
{
type: 'edge',
id: 'e1',
sourceId: 'a',
targetId: 'b',
sourcePort: 'out',
targetPort: '*',
x: 0,
y: 0,
width: 0,
height: 0,
data: {},
},
],
};Selection commands
flow.selection.duplicate() clones selected nodes, their descendants, and
internal edges through the content/ID remapping pipeline. It retains external
parents, selects all clones, offsets the duplicated subtree by { x: 24, y: 24 }
by default, and creates one undo step. Pass { offset: { x, y } } to override.
flow.selection.align('left' | 'right' | 'top' | 'bottom' | 'center-x' |
'center-y') aligns two or more selected nodes. Use
flow.selection.distribute('horizontal' | 'vertical') to space three or more
nodes evenly while retaining deterministic outer endpoints. Both use exact
current visual bounds and return false without adding history when the
selection is ineligible or the geometry is already satisfied.
Layout contract
flow.layout.run(algorithm, options?) accepts LayoutRunOptions: cancellation
signal, stale-result force, structural scope, previousResult, fixed and
pinned entity ids, source, generic metadata, and optional animation.
Algorithms receive frozen clones of graph/options. The position Map shorthand
remains supported.
LayoutResult can additionally provide node sizes/bounds, edge-label
positions/bounds, edge routes, diagnostics, quality metadata, and source.
flow.layout.apply(result, options?) validates all ids and finite geometry
before mutation, then applies the result atomically as one history step. Fixed
or pinned entities remain unchanged unless listed in
overrideFixedEntityIds. Granular positions/sizes override matching axes from
aggregate bounds.
Pass { animation: { duration, easing? } } to run or apply for a transient
visual transition. The final graph is committed once at the start; animation
frames never create graph commits or history entries. Both APIs provide
animation: { cancel, done } controls (apply returns the controls directly).
Canceling, replacing, or destroying an animation snaps visual geometry to the
durable final layout.
Persistence
flow.toJSON() returns { version: 1, graph, viewport, collapsedNodeIds }. The graph is plain persisted intent: pending node and label drag offsets are folded into x/y, pending edge drag offsets are folded into route waypoints, dx/dy and DOM measured sizes are stripped, and style.customWidth/customHeight are kept. Selection, hover, connection state, highlights, measurements, and undo history are session state and are not serialized.
Use fromJSON(data, options?) to create a new controller from saved data, or flow.load(data) to validate and replace an existing document atomically. load() returns { ok: true, value } or { ok: false, error }. User data is deep-cloned and must be JSON-safe; edge route points must be finite, routing must be polyline, orthogonal, or splines, and SVG paths accept only path commands with finite numeric parameters. Optional Standard Schema-compatible contracts can validate and migrate application data.
Use flow.replaceGraph(graph, options?) when another model is authoritative and
continuously projects its graph into Flow. It validates atomically, returns the
applied FlowChangeSet (or null for an equal graph), preserves state addressed
to surviving entities by default, and clears Flow undo/redo history by default.
Set retention: 'reset' to clear graph-scoped session state, or set history to
'ignore' or 'record' when the external synchronization contract requires it.
Pass origin and transactionId to suppress feedback loops through commit
events.
Store events
Graph mutations
| Event | Payload | Description |
| ---------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| createNode | { node: FlowNode } | Add a node |
| deleteNode | { id } | Remove node + cascade edges |
| updateNode | { id, patch? } or { id, updater? } | Patch/replace a node without changing id/type/parent |
| updateNodeData | { id, data } | Merge into node.data |
| createEdge | { edge: FlowEdge } | Add an edge |
| deleteEdge | { id } | Remove an edge |
| deleteEntities | { nodeIds?, edgeIds? } | Low-level synchronous deletion of a known entity set |
| updateEdge | { id, patch? } or { id, updater? } | Patch/replace an edge without changing id/type/endpoints/ports |
| updateEdgeData | { id, data } | Merge into edge.data |
| deleteSelected | — | Remove all selected nodes + edges |
| setGraph | { graph: FlowGraph } | Replace entire graph, reset interaction state |
| replaceGraph | { graph, retention?, history?, origin?, transactionId? } | Replace through the external synchronization policy used by flow.replaceGraph() |
| reset | — | Reset store to initial state (preserves viewport size) |
| reparentNode | { id, parentId } | Move node to new parent |
| collapseNode | { id } | Hide descendants and derive folded boundary edges |
| expandNode | { id } | Show descendants again |
| reconnectEdge | { edgeId, newSourceId?, newTargetId?, newSourcePort?, newTargetPort? } | Rewire edge endpoints |
Drag lifecycle
| Event | Payload | Description |
| ------------ | ------------------------------- | --------------------------------------------------- |
| dragStart | { id } | Begin dragging the selected entity |
| dragMove | { dx, dy } | Apply in-progress drag delta |
| dragEnd | — | End drag gesture |
| commitDrag | { snapOffset?: Point } | Bake dx/dy and the visual snap into positions |
| moveNodes | { direction: Point, factor? } | Move selected nodes by arrow key |
Selection & focus
| Event | Payload | Description |
| ---------------- | ------------------------------------ | ------------------------------- |
| select | { ids, mode: 'single' \| 'multi' } | Select entities (multi toggles) |
| selectAll | — | Select all nodes and edges |
| clearSelection | — | Deselect everything |
| focus | { entityId } | Set keyboard/a11y focus |
| clearFocus | — | Clear focus |
Box selection
| Event | Payload | Description |
| ------------------- | ------------------ | -------------------------------------- |
| selectionBoxStart | { point, mode? } | Begin box-select gesture |
| selectionBoxMove | { point } | Update box-select corner |
| selectionBoxEnd | — | Finalize: select intersecting entities |
Viewport
| Event | Payload | Description |
| ------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------- |
| setViewport | { x, y, zoom } | Set viewport directly |
| setFitFrame | { frame: { x, y, width, height } \| null, preserveCenter? } | Set or reset the persistent screen-space fit frame |
| panBy | { x, y } | Pan by offset |
| panStart | { x, y } | Begin pan gesture (screen coords) |
| pan | { x, y } | Continue pan gesture (screen coords) |
| panEnd | — | End pan gesture |
| zoomTo | { zoom, center } | Zoom to level around center point |
| setMinZoom | { minZoom } | Set minimum zoom level |
| setMaxZoom | { maxZoom } | Set maximum zoom level |
| updateViewportSize | { width, height, preserveCenter? } | Report container size, optionally preserving its world center |
| fitView | { padding?, minZoom?, maxZoom?, nodes?, frame? } | Fit all (or specific) nodes into a target frame |
| pushViewport / popViewport | — | Viewport stack for drill-in/out |
| setTranslateExtent | { extent: [Point, Point] \| null } | Set pan boundary |
| setNodeExtent | { extent: [Point, Point] \| null } | Set node drag boundary |
Connection lifecycle
| Event | Payload | Description |
| ------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------- |
| connectionStart | { sourceId, sourcePort, position } | Begin connection gesture |
| connectionMove | { position, targetId?, targetPort?, isValid?, invalidReason? } | Update in-progress connection |
| connectionEnd | { target?: ConnectEndTarget } | End connection gesture (emits connectionEnded with status) |
| cancelConnection | — | Cancel connection without emitting |
| setConnectionClickStartHandle | { nodeId, port } \| null | Set/clear click-to-connect start handle |
Resize lifecycle
| Event | Payload | Description |
| -------------- | ------------------------------ | ------------------------------------------------- |
| resizeStart | { id, handle: ResizeHandle } | Begin resize gesture |
| resizeMove | { x, y } | Update resize position |
| resizeEnd | — | Commit resize (bake position + custom dimensions) |
| resizeCancel | — | Cancel resize without committing |
Layout
| Event | Payload | Description |
| -------------------- | ---------------------------------------------------- | -------------------------------------------- |
| requestLayout | { algorithm? } | Request auto-layout (blocked during drag) |
| applyLayoutResult | { positions: Map<id, {x,y}>, edgeLabelPositions? } | Apply layout positions, reset dx/dy |
| updateMeasurements | { measurements: Array<{ id, rect: Rect }> } | Renderer reports measured world-space rects |
| bakeLabelPositions | { positions: Map<edgeId, Point> } | Bake computed label positions into edge data |
Configuration
| Event | Payload | Description |
| ------------------- | ------------------------------------------------------ | --------------------------------------------------------------------- |
| setCanvasState | { state: CanvasState } | Set low-level canvas gesture state |
| setMode | { mode: string } | Set active profile mode |
| setDragThreshold | { dragThreshold } | Set pixel threshold before drag starts |
| setAutoPanOptions | { autoPanOnDrag?, autoPanThreshold?, autoPanSpeed? } | Configure auto-pan behavior |
| updateSettings | Partial<FlowRuntimeSettings> | Update runtime store settings in one event |
| setSnapToGrid | { enabled, grid? } | Enable/disable grid snapping |
| setSnapLines | { enabled, threshold?, spacing? } | Enable/disable Figma-style alignment guides |
| setColorMode | { colorMode: FlowColorMode } | Set the preferred UI color mode ('light' | 'dark' | 'system') |
Navigation
| Event | Payload | Description |
| -------------------- | ------------------------------------------------------ | ---------------------------------------------------------------- |
| updateParentNodeId | { parentNodeId } | Drill into/out of nested node |
| setViewScope | { parentNodeId, depth, selection, visibleEntityIds } | Low-level structural scope update; prefer flow.view.setScope() |
Emitted events
Subscribe with flow.on(type, listener) or, for the low-level escape hatch, flow.internals.store.on(type, listener).
| Event | Payload | Description |
| ------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------- |
| graphChanged | { graph, event } | Graph changed after a mutation |
| viewportChanged | { viewport, event } | Viewport changed |
| selectionChanged | { selection, event } | Selection changed |
| viewChanged | { scope, event } | Structural parent/depth changed |
| entitiesDeleted | { ids, event } | Entities were deleted |
| connectionEnded | ConnectionEndedEvent | Connection gesture ended with status: 'connected' \| 'cancelled' \| 'invalid' |
| dragStarted / dragMoved / dragEnded | FlowDrag*Event | Drag gesture milestones; dragMoved fires per processed input event |
| resizeStarted / resizeEnded | FlowResize*Event | Resize gesture milestones |
| connectStarted / connectMoved | FlowConnect*Event | Connection gesture milestones; connectMoved fires per processed input event |
| paneClicked / paneContextMenu | FlowPaneGestureEvent | Pane click and context menu gestures |
| entityClicked / entityDoubleClicked / entityContextMenu | FlowEntityGestureEvent | Entity click, double-click, and context menu gestures |
| layoutRequested | LayoutRequestedEvent | Layout was requested |
| error | { error, event? } | Flow error emitted |
Undo / redo
@statelyai/flow uses XState Store's undoRedo() extension with a whitelist of committed graph mutation events. Interaction-only events such as selection, panning, live drag updates, and connection hover state are intentionally skipped. Undo/redo restores graph and collapse history while preserving current viewport and valid session state. Cancelled drag/resize gestures create no history entry.
import { createFlowStore, createNode } from '@statelyai/flow';
const store = createFlowStore(undefined, {
historyLimit: 200,
});
store.trigger.createNode({
node: createNode({ id: 'a', x: 0, y: 0 }),
});
store.trigger.undo();
store.trigger.redo();
const transactionId = 'create-node-pair';
store.trigger.createNode({
node: createNode({ id: 'b', x: 0, y: 100 }),
transactionId,
});
store.trigger.createNode({
node: createNode({ id: 'c', x: 180, y: 100 }),
transactionId,
});
// A single undo reverts both node creations.
store.trigger.undo();Tracked events: createNode, deleteNode, updateNode, updateNodeData, createEdge, deleteEdge, updateEdge, updateEdgeData, deleteSelected, reparentNode, commitDrag, resizeEnd, reconnectEdge, applyLayoutResult, bakeLabelPositions.
Viewport utilities
import {
createViewport,
toSVGTransform,
toViewBox,
screenToCanvas,
canvasToScreen,
getVisibleBounds,
viewportForBounds,
viewportCenteredOn,
validateFitFrame,
panBy,
zoomTo,
clampZoom,
animateViewport,
} from '@statelyai/flow';
const vp = createViewport(); // { x: 0, y: 0, zoom: 1 }
// SVG rendering
const transform = toSVGTransform(vp); // "translate(0, 0) scale(1)"
const viewBox = toViewBox(vp, { width: 800, height: 600 });
// Coordinate conversion
const canvasPoint = screenToCanvas(vp, { x: 400, y: 300 });
const screenPoint = canvasToScreen(vp, { x: 100, y: 50 });
// Fit content
const bounds = getVisibleBounds(vp, { width: 800, height: 600 });
const frame = validateFitFrame({ x: 300, y: 0, width: 500, height: 600 });
const fitted = viewportForBounds(contentRect, containerSize, {
padding: 0.1,
frame,
});
const centered = viewportCenteredOn(canvasPoint, containerSize, 1, frame);
// Animation
const animation = animateViewport(store, fitted, {
lerp: 0.15,
});
await animation.done;Edge routing
Four built-in routers. All return PathData (points + source/target direction). A custom router may also return svgPath to preserve rounded or otherwise custom SVG geometry; points remain required for bounds and endpoint handles, and exported pathToSVG safely prefers a finite override containing only SVG path commands and numeric parameters. Canonical graph edge.points are different from waypoints: they are a complete authoritative endpoint-to-endpoint route, interpreted by edge.routing (polyline, orthogonal, or chained splines) and never sent through a Flow router or split around a label. An optional edge svgPath preserves exact SVG paint while the authoritative points drive endpoints, bounds, hit testing, culling, transient DOM updates, and export. Core endpoint-geometry mutations invalidate precomputed route fields; layout-provided edge routes replace them with router waypoints, while consumers can apply a fresh authoritative graph to restore exact geometry. Edge waypoints remain ordered interior router inputs passed through EdgeRouterContext; built-in routers honor them. Custom EdgeRouters receive the full typed edge plus the exact canonical or projection graph currently being routed and a detached, read-only getEntityBounds(id) lookup for live node/edge-label geometry. The lookup never routes another edge. Use configureBezierRouter(options), configureStepRouter(options), and configureAvoidRouter(options) for customized built-in router variants. avoidRouter is opt-in and routes orthogonal polylines around sibling node obstacles. Set engine.edgeRouterDependencies: 'endpoints' only when a custom router does not inspect unrelated graph entities, bounds, or obstacles; Flow can then retain unaffected routes after local geometry changes. The safe default is 'graph'.
Router obstacles exclude the source, target, and their ancestor groups, so an edge crossing a hierarchy boundary can pass through its endpoint container.
Routers must be deterministic from their supplied inputs. Flow reuses paths while routing geometry and router identity are unchanged.
import { configureStepRouter, type EdgeRouter } from '@statelyai/flow';
type StatechartEdgeData = { transitionType: 'external' | 'internal' };
const statechartRouter: EdgeRouter<StatechartEdgeData> = (edge, context) => {
const source = context.graph.nodes.find((node) => node.id === edge.sourceId);
const siblingBounds = source?.parentId
? context.graph.nodes
.filter((node) => node.parentId === source.parentId)
.map((node) => context.getEntityBounds(node.id))
: [];
const offset =
edge.data.transitionType === 'internal'
? 32
: siblingBounds.length
? 64
: 48;
return configureStepRouter({ offset })(edge, context);
};When an edge uses ports, routing prefers the explicit port position. When no port is specified, routing uses the registered shape anchor for the node.
Shapes
Shape handlers own hit testing and edge anchor calculation. Register custom shapes:
import { registerShape, getShapeOrDefault } from '@statelyai/flow';
registerShape('hexagon', {
hitTest: (point, bounds) => {
/* point-in-hexagon */
},
edgeAnchor: (bounds, direction) => {
/* anchor point */
},
});
// Built-in: 'rect', 'rounded', 'ellipse', 'diamond'
const shape = getShapeOrDefault('hexagon');Snap lines
Figma-style alignment guides for drag operations. Exported:
import { getSnapLines } from '@statelyai/flow';
import type {
SnapLine,
SpacingGuide,
SnapResult,
SnapOptions,
} from '@statelyai/flow';
const result: SnapResult = getSnapLines(movingRect, otherRects, {
threshold: 5,
enableSpacing: true,
});
// result.snapLines — alignment lines
// result.snapRect — snapped rectangle position
// result.spacingGuides — equal spacing indicatorsEnable via store: store.trigger.setSnapLines({ enabled: true }).
Config & behavior
Behavior is controlled by predicate functions, not boolean flags:
import {
defaultConfig,
readonlyConfig,
resolveBehavior,
} from '@statelyai/flow';
// Everything interactive
defaultConfig; // { selectable: () => true, draggable: () => true, connectable: () => true, ... }
// View-only (can still select)
readonlyConfig; // { draggable: false, connectable: false, reconnectable: false, deletable: false, resizable: false }
// Custom: nodes draggable, edges not
const config = {
...defaultConfig,
draggable: (entity) => entity.type === 'node',
};
// Resolve for a specific entity
const canDrag = resolveBehavior(config.draggable, someNode, flowState);Store options
const store = createFlowStore(graph, {
minZoom: 0.1,
maxZoom: 4,
translateExtent: null, // [Point, Point] | null — pan boundary
nodeExtent: null, // [Point, Point] | null — node drag boundary
dragThreshold: 3, // px before drag starts
autoPanOnDrag: true,
autoPanThreshold: 40,
autoPanSpeed: 15,
snapToGrid: false,
snapGrid: [10, 10],
enableSnapLines: false,
snapThreshold: 5,
enableSpacingGuides: false,
connectOnClick: false,
labelAnchor: 'fixed',
boxSelectionTest: null, // custom node / edge-label bounds predicate
selectionMode: 'partial', // 'partial' | 'full'
historyLimit: 100,
mode: 'interactive',
profile: defaultFlowProfile,
onDeleteNodes: 'cascade', // 'cascade' | 'retarget' | custom function
});Box selection tests node bounds and rendered edge-label bounds only. Crossing
an edge path does not select it, and edges without labels are not box-selected.
Set a node's selectionBounds when its selectable surface differs from its
visual bounds, such as a compound node whose header selects the parent while
its child area remains available for box selection.
Groups use their configured content block as the default marquee hit rectangle,
leaving their child area available for selecting descendants.
As a growing box captures entities, selection order records when each entity
first enters the box.
Use flow.configure(patch) for partial live updates. Use flow.configure(current => completeNext) for explicit full replacement and flow.getConfiguration() to capture a baseline. Store, engine, profile, mode, edge defaults, connection creation, and layout configuration share this path. Changing historyLimit after creation throws until XState exposes a supported live history-limit API.
FlowProfile.resolvers, FlowProfile.policies, and FlowProfile.rules are exported but experimental until the profile API settles.
Flow owns deterministic graph state and graph operations. Apps own intent, authorization, confirmation, persistence, and product policy. Async is valid where the domain is inherently async. For app-owned delete intent:
if (await confirmDelete()) {
flow.selection.delete();
}Deletion itself is synchronous and returns whether anything was removed.
Engine connection options control connect-target magnet behavior: { radius?: number, mode?: 'loose' | 'strict' }. Loose is the default/current behavior and falls back to node-body targets; strict only completes to ports.
Edge labels
Labels are first-class citizens — part of the edge data, positioned like nodes:
store.trigger.createEdge({
edge: {
type: 'edge',
id: 'e1',
sourceId: 'a',
targetId: 'b',
x: 0,
y: 0,
width: 0,
height: 0,
data: {},
label: {
x: 150,
y: 100,
width: 80,
height: 30,
autoSize: true, // optional: rendered DOM owns live width/height
data: { text: 'yes' },
},
},
});Selecting a label selects its edge (same ID). The renderer draws two path segments: source → label, label → target. With autoSize: true, authored dimensions are only the pre-measurement fallback; DOM measurements remain transient and never rewrite the label model.
Factory functions
import { createNode, createEdge, createGraph } from '@statelyai/flow';
const node = createNode({ id: 'a', x: 0, y: 0 }); // defaults: width=150, height=50
const edge = createEdge({ id: 'e1', sourceId: 'a', targetId: 'b' });
const graph = createGraph({ nodes: [node], edges: [edge] }); // defaults: direction='down'Architecture
@statelyai/graph (VisualGraph) ← canonical data model
↓
FlowStore (@xstate/store) ← all state + transitions (pure, testable)
↓
FlowEngine ← purely derived queries (no mutable state)
↓
renderer adapter (React, Vue, …) ← UI: components, DOM/SVG, event bindingDesign principle: the engine is a pure projection over the store. DOM-specific concerns such as element measurement and container observation are intentionally out of this package. All interaction state (pan, drag, selection, connection) lives in the store and is driven by store.trigger.* events. The engine provides computed queries (edge paths, visual bounds, coordinate conversion, hit testing) derived from the current store snapshot.
This means:
- All state transitions are testable via
store.trigger.*+ snapshot assertions - The store is the single source of truth
- The engine can be reconstructed from any store snapshot
- Input handling (gestures, keyboard) is a separate concern — wire
store.trigger.*to whatever input you want
// All interaction goes through the store:
store.trigger.panStart({ x: e.clientX, y: e.clientY });
store.trigger.pan({ x: e.clientX, y: e.clientY });
store.trigger.panEnd();
store.trigger.select({ ids: ['a'], mode: 'single' });
store.trigger.dragStart({ id: 'a' });
store.trigger.dragMove({ dx: 10, dy: 5 });
store.trigger.dragEnd();
store.trigger.zoomTo({ zoom: 1.5, center: { x: 400, y: 300 } });
// Engine is purely derived:
const bounds = engine.getBounds('a'); // measured or model bounds
const paths = engine.getAllEdgePaths(); // computed from graph state
const canvas = engine.screenToCanvas({ x, y }); // from current viewportLicense
MIT
