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.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:

  • FlowNodeVisualNode + { dx, dy, style, ports, hidden, selectable, draggable, connectable, deletable, resizable } for drag offsets, style overrides, and per-entity behavior
  • FlowEdgeVisualEdge + { label, sourcePort, targetPort, kind, style, labelAnchor } for edge labels, port binding, and edge styling
  • FlowGraphVisualGraph with FlowNode[] and FlowEdge[]

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.

  • 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.

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 attribute

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

License

MIT