@statelyai/flow
v0.1.0
Published
Framework-agnostic flow visualization engine. Builds on @statelyai/graph.
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(), also install @statelyai/flow-dom.
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();
// Trigger events
flow.trigger.select({ ids: ['a'], mode: 'single' });
flow.trigger.panBy({ x: 100, y: 0 });
flow.trigger.dragStart({ id: 'a' });
flow.trigger.dragMove({ dx: 10, dy: 20 });
flow.trigger.dragEnd();
flow.trigger.commitDrag();
// 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.
Data model
The canonical data model is VisualGraph from @statelyai/graph. Flow extends it with:
FlowNode—VisualNode+{ dx, dy, style, ports, hidden, selectable, draggable, connectable, deletable, resizable }for drag offsets, style overrides, and per-entity behaviorFlowEdge—VisualEdge+{ label, sourcePort, targetPort, kind, style, labelAnchor }for edge labels, port binding, and edge stylingFlowGraph—VisualGraphwithFlowNode[]andFlowEdge[]
Positions use a two-layer system: x/y from layout, dx/dy from user drag. Visual position = (x + dx, y + dy).
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.
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: {},
},
],
};Store events
Graph mutations
| Event | Payload | Description |
|---|---|---|
| createNode | { node: FlowNode } | Add a node |
| deleteNode | { id } | Remove node + cascade edges |
| updateNodeData | { id, data } | Merge into node.data |
| createEdge | { edge: FlowEdge } | Add an edge |
| deleteEdge | { id } | Remove an edge |
| updateEdgeData | { id, data } | Merge into edge.data |
| deleteSelected | — | Remove all selected nodes + edges |
| setGraph | { graph: FlowGraph } | Replace entire graph, reset interaction state |
| reset | — | Reset store to initial state (preserves viewport size) |
| reparentNode | { id, parentId } | Move node to new parent |
| 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 | — | Bake dx/dy into committed 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 |
| 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 } | Report container size |
| fitView | { padding?, minZoom?, maxZoom?, nodes? } | Fit all (or specific) nodes in view |
| 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? } | Update in-progress connection |
| connectionEnd | { target?: ConnectEndTarget } | End connection gesture (emits connectionEnded) |
| 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 |
| reportMeasurements | { measurements: Map<id, {w,h}> } | Renderer reports measured sizes |
| reportPortMeasurements | { measurements: Array<{nodeId, portName, x, y, width, height}> } | Renderer reports port positions |
| bakeLabelPositions | { positions: Map<edgeId, Point> } | Bake computed label positions into edge data |
Configuration
| Event | Payload | Description |
|---|---|---|
| setCanvasState | { state: CanvasState } | Set interaction mode |
| setInteractionMode | { mode: InteractionMode } | Set pointer gesture mode ('default' | 'pan' | 'connect' | 'select') |
| setDragThreshold | { dragThreshold } | Set pixel threshold before drag starts |
| setAutoPanOptions | { autoPanOnDrag?, autoPanThreshold?, autoPanSpeed? } | Configure auto-pan behavior |
| setSnapToGrid | { enabled, grid? } | Enable/disable grid snapping |
| setSnapLines | { enabled, threshold?, spacing? } | Enable/disable Figma-style alignment guides |
Navigation
| Event | Payload | Description |
|---|---|---|
| updateParentNodeId | { parentNodeId } | Drill into/out of nested node |
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.
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, updateNodeData, createEdge, deleteEdge, updateEdgeData, deleteSelected, reparentNode, commitDrag, resizeEnd, reconnectEdge, applyLayoutResult, bakeLabelPositions.
Viewport utilities
import {
createViewport,
toSVGTransform,
toViewBox,
screenToCanvas,
canvasToScreen,
getVisibleBounds,
viewportForBounds,
panBy,
zoomTo,
clampZoom,
} 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 fitted = viewportForBounds(contentRect, containerSize, { padding: 0.1 });Edge routing
Three built-in routers. All return PathData (points + source/target direction).
import { straightRouter, stepRouter, bezierRouter, pathToSVG } from '@statelyai/flow';
const path = stepRouter({
sourceRect: { x: 0, y: 0, width: 200, height: 80 },
targetRect: { x: 300, y: 200, width: 200, height: 80 },
sourceDirection: 'bottom',
targetDirection: 'top',
});
const d = pathToSVG(path); // SVG path d attributeWhen 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,
selectionMode: 'partial', // 'partial' | 'full'
historyLimit: 100,
beforeDelete: ({ graph, reason, nodes, edges }) => { /* return false to cancel */ },
onDeleteNodes: 'cascade', // 'cascade' | 'retarget' | custom function
});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,
data: { text: 'yes' },
},
},
});Selecting a label selects its edge (same ID). The renderer draws two path segments: source → label, label → target.
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, resolved 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.resolvedBounds('a'); // from store snapshot
const paths = engine.getAllEdgePaths(); // computed from graph state
const canvas = engine.screenToCanvas({ x, y }); // from current viewportLicense
MIT
