npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@statelyai/flow

v0.9.1

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(), 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 elkjs

Use @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:

  • FlowNodeVisualNode + { 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 structural type equal to "node"; use kind for variants such as state or annotation.
  • FlowEdgeVisualEdge + { label, waypoints, svgPath, sourcePort, targetPort, kind, style, labelAnchor } for edge labels, editable router via-points, exact SVG geometry, port binding, and edge styling. Canonical VisualEdge.points plus routing are an authoritative precomputed route and bypass Flow routers; an optional svgPath controls 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.autoSize makes rendered DOM own transient label dimensions without overwriting authored fallback width/height; inherited edge x/y/width/height are reserved, while positioned edge content belongs on label.
  • FlowGraphVisualGraph with FlowNode[] and FlowEdge[]
  • FlowEntityRef — descriptor for a node, edge, port, or block resolved by id. FlowAddressable is 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? }, with nodes/edges keyed by existing entity ids
  • GraphProjectionMapper — maps projected node/edge datum to visual attrs
  • ActiveGraphProjection{ 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.

  • FlowNode inherits ports from VisualNode
  • FlowPort is VisualPort plus flow's side hint
  • sourcePort / targetPort on FlowEdge reference those port names
  • '*' means "choose the closest available port"
  • null means "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 indicators

Enable 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 binding

Design 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 viewport

License

MIT