pyreon

@pyreon/flow provides reactive flow diagrams for Pyreon. Signal-native nodes and edges, pan/zoom without D3, auto-layout via elkjs, and per-node O(1) reactivity. Built from the ground up for signal-based frameworks.

@pyreon/flowstable

Installation

npm install @pyreon/flow
bun add @pyreon/flow
pnpm add @pyreon/flow
yarn add @pyreon/flow

Quick Start

import { createFlow, Flow, Background, MiniMap, Controls } from '@pyreon/flow'

const flow = createFlow({
  nodes: [
    { id: '1', type: 'input', position: { x: 0, y: 0 }, data: { label: 'Start' } },
    { id: '2', position: { x: 200, y: 100 }, data: { label: 'Process' } },
    { id: '3', type: 'output', position: { x: 400, y: 0 }, data: { label: 'End' } },
  ],
  edges: [
    { source: '1', target: '2' },
    { source: '2', target: '3' },
  ],
})

function WorkflowBuilder() {
  return (
    <Flow instance={flow} fitView>
      <Background />
      <MiniMap />
      <Controls />
    </Flow>
  )
}

No callbacks, no applyNodeChanges. The flow instance manages everything.

Node graph — drag, select, connect

Creating a Flow

createFlow() accepts a config object and returns a reactive FlowInstance:

const flow = createFlow({
  nodes: [...],
  edges: [...],
  snapToGrid: true,
  snapGrid: 20,
  connectionRules: { ... },
  nodeExtent: { x: [0, 1000], y: [0, 800] },
})

Config Options

OptionTypeDefaultDescription
nodesFlowNode[][]Initial nodes
edgesFlowEdge[][]Initial edges
snapToGridbooleanfalseSnap node positions to grid
snapGridnumber20Grid size in pixels
connectionRulesRecord<string, ConnectionRule>Connection validation rules by node type
nodeExtent&#123; x: [min, max], y: [min, max] &#125;Constrain node positions within bounds
minZoomnumber0.1Minimum zoom level
maxZoomnumber4Maximum zoom level

useFlow(config) — Component-Scoped Flows

For flows that live and die with a component, use useFlow instead of createFlow. It wraps the instance with an onUnmount(() => flow.dispose()) so you don't need to write the disposal boilerplate yourself.

import { useFlow, Flow, Background } from '@pyreon/flow'

const MyDiagram = () => {
  const flow = useFlow({
    nodes: [{ id: '1', position: { x: 0, y: 0 }, data: { label: 'Start' } }],
    edges: [],
  })

  return (
    <Flow instance={flow}>
      <Background />
    </Flow>
  )
}

Use createFlow directly when the flow is owned outside the component tree (app store, singleton, SSR-shared state) — those cases require manual flow.dispose() at the correct lifecycle point.

Reactive Signals

All state is exposed as reactive signals:

flow.nodes() // Signal<FlowNode[]>
flow.edges() // Signal<FlowEdge[]>
flow.viewport() // Signal<Viewport> — { x, y, zoom }
flow.zoom() // Computed<number> — just the zoom level
flow.selectedNodes() // Computed<FlowNode[]>
flow.selectedEdges() // Computed<FlowEdge[]>

Node Operations

// Add a node
flow.addNode({
  id: '4',
  position: { x: 300, y: 200 },
  data: { label: 'New Node' },
})

// Remove a node (also removes connected edges)
flow.removeNode('4')

// Update node properties
flow.updateNode('2', { data: { label: 'Updated' } })

// Update position (respects snapToGrid and nodeExtent)
flow.updateNodePosition('2', { x: 250, y: 150 })

// Get a specific node
const node = flow.getNode('2') // FlowNode | undefined

Edge Operations

// Add an edge (id auto-generated if not provided)
flow.addEdge({ source: '1', target: '3' })

// Add with type
flow.addEdge({ source: '1', target: '3', type: 'smoothstep', label: 'yes' })

// Remove an edge
flow.removeEdge('e1-3')

// Get a specific edge
const edge = flow.getEdge('e1-3')

// Duplicate edges are prevented automatically

Edge Types

Four built-in edge path algorithms:

TypeDescription
bezierSmooth cubic bezier curve (default)
smoothstepRight-angle path with rounded corners
stepRight-angle path with sharp corners
straightDirect line between nodes

Edge Waypoints

Add bend points to edges:

flow.addEdgeWaypoint('e1-2', { x: 150, y: 50 })
flow.addEdgeWaypoint('e1-2', { x: 200, y: 75 }, 1) // at specific index
flow.updateEdgeWaypoint('e1-2', 0, { x: 160, y: 60 })
flow.removeEdgeWaypoint('e1-2', 0)

Selection

flow.selectNode('1') // select a node
flow.selectNode('2', { additive: true }) // add to selection
flow.selectEdge('e1-2') // select an edge
flow.selectAll() // select all nodes
flow.clearSelection() // deselect everything
flow.deleteSelected() // remove selected nodes and edges
flow.deselectNode('1') // remove from selection

Viewport

flow.zoomIn() // zoom in by 0.2
flow.zoomOut() // zoom out by 0.2
flow.zoomTo(1.5) // set exact zoom (clamped to min/max)
flow.fitView() // fit all nodes in viewport
flow.fitView(['1', '2']) // fit specific nodes
flow.panTo({ x: 100, y: 200 }) // pan to position

// Reactive zoom level
flow.zoom() // Computed<number>

// Check if a node is visible
flow.isNodeVisible('1') // boolean

Auto-Layout

Layout nodes automatically using elkjs (lazy-loaded — zero cost until called):

// Layered layout (DAG/pipeline)
await flow.layout('layered', { direction: 'RIGHT', nodeSpacing: 50, layerSpacing: 100 })

// Tree layout
await flow.layout('tree', { direction: 'DOWN', nodeSpacing: 40 })

// Force-directed
await flow.layout('force', { nodeSpacing: 80 })

// Available algorithms
await flow.layout('stress')
await flow.layout('radial')
await flow.layout('box')
await flow.layout('rectpacking')

Layout Options

OptionTypeDefaultDescription
direction'DOWN' | 'RIGHT' | 'UP' | 'LEFT''DOWN'Layout direction
nodeSpacingnumber50Spacing between nodes
layerSpacingnumber80Spacing between layers
edgeRouting'orthogonal' | 'splines' | 'polyline''orthogonal'How edges are routed
animatebooleantrueAnimate the layout transition
animationDurationnumber300Animation duration in milliseconds

Algorithm Applicability

Not every option applies to every algorithm. The table below is empirically verified — each cell records whether running the algorithm twice with two different values for that option produces a different layout () or an identical one ().

Optionlayeredtreeforcestressradialboxrectpacking
direction
nodeSpacing
layerSpacing
edgeRouting

direction, layerSpacing, and edgeRouting are namespaced under ELK's layered/tree pipelines. The other algorithms accept the option in LayoutOptions (so it typechecks) but silently ignore the value at layout time. Use layered or tree if you need a directional layout. nodeSpacing is the only option respected by every algorithm.

elkjs is loaded on demand — only imported when flow.layout() is first called.

Connection Rules

Define type-safe rules for which node types can connect:

const flow = createFlow({
  nodes: [...],
  edges: [...],
  connectionRules: {
    input: { allowedTargets: ['process'] },
    process: { allowedTargets: ['process', 'output'] },
    output: { allowedTargets: [] },
  },
})

// Check if a connection is valid
flow.isValidConnection({ source: '1', target: '2' })  // boolean

Graph Queries

// Get all edges connected to a node
flow.getConnectedEdges('2') // FlowEdge[]

// Get upstream nodes (nodes with edges pointing to this node)
flow.getIncomers('2') // FlowNode[]

// Get downstream nodes (nodes this node points to)
flow.getOutgoers('2') // FlowNode[]

Search and Filter

// Find nodes by predicate
flow.findNodes((n) => n.type === 'process') // FlowNode[]

// Search by label text (case-insensitive)
flow.searchNodes('start') // FlowNode[]

Undo / Redo

flow.undo() // restore previous state
flow.redo() // restore undone state

Copy / Paste

flow.copy() // copy selected nodes to clipboard
flow.paste() // paste with offset, new IDs generated

Collision Detection

// Find nodes overlapping with a given node
flow.getOverlappingNodes('2') // FlowNode[]

// Resolve collisions — push overlapping nodes apart
flow.resolveCollisions('2')

Proximity Connect

// Find nearest unconnected node within distance
flow.findNearestNode('1', 200) // FlowNode | null

Serialization

// Export flow state as JSON
const json = flow.toJSON()
// { nodes: [...], edges: [...], viewport: { x, y, zoom } }

// Import flow state
flow.fromJSON(json)
flow.fromJSON(json, { resetViewport: true })

Listeners

// Connection created
flow.onConnect((edge) => {
  console.log('Connected:', edge.source, '→', edge.target)
})

// Node changes (position, add, remove)
flow.onNodesChange((change) => {
  console.log(change.type, change.id)
})

// Click handlers
flow.onNodeClick((nodeId) => { ... })
flow.onEdgeClick((edgeId) => { ... })

// All return unsubscribe functions
const unsub = flow.onConnect(...)
unsub()

Batch Operations

flow.batch(() => {
  flow.addNode({ id: '10', position: { x: 0, y: 0 }, data: { label: 'A' } })
  flow.addNode({ id: '11', position: { x: 200, y: 0 }, data: { label: 'B' } })
  flow.addEdge({ source: '10', target: '11' })
})
// Single signal notification for all changes

Components

<Flow>

The main container component:

<Flow instance={flow} fitView style="width: 100%; height: 600px;">
  <Background />
  <MiniMap />
  <Controls />
</Flow>

<Background>

Decorative background pattern:

<Background variant="dots" gap={20} size={1} />
<Background variant="lines" gap={20} />
<Background variant="cross" gap={20} />

<MiniMap>

Scaled overview with viewport indicator:

<MiniMap
  nodeColor={(node) => (node.type === 'input' ? '#6366f1' : '#94a3b8')}
  maskColor="rgba(0,0,0,0.2)"
/>

<Controls>

Zoom and fit controls:

<Controls showFitView showZoomIn showZoomOut showLock />

<Handle>

Connection points on nodes:

import { Handle, Position } from '@pyreon/flow'

function CustomNode({ data }) {
  return (
    <div class="custom-node">
      <Handle type="target" position={Position.Left} />
      <span>{data.label}</span>
      <Handle type="source" position={Position.Right} />
    </div>
  )
}

<Panel>

Positioned overlay panels:

<Panel position="top-left">
  <SearchBar />
</Panel>
<Panel position="bottom-right">
  <ZoomIndicator />
</Panel>

<NodeResizer>

Drag handles for resizing nodes:

<NodeResizer nodeId="1" minWidth={100} minHeight={50} />

<NodeToolbar>

Floating toolbar that appears when a node is selected:

<NodeToolbar nodeId="1" position={Position.Top}>
  <button>Edit</button>
  <button>Delete</button>
</NodeToolbar>

Edge Path Utilities

Pure functions for generating SVG paths:

import { getBezierPath, getSmoothStepPath, getStraightPath, getStepPath } from '@pyreon/flow'

const [path, labelX, labelY] = getBezierPath({
  sourceX: 0,
  sourceY: 0,
  targetX: 200,
  targetY: 100,
  sourcePosition: Position.Right,
  targetPosition: Position.Left,
})

Position Enum

import { Position } from '@pyreon/flow'

Position.Top // 'top'
Position.Right // 'right'
Position.Bottom // 'bottom'
Position.Left // 'left'

Cleanup

flow.dispose() // remove all listeners, clear state

Comparison with React Flow

FeatureReact Flow@pyreon/flow
Update 1 of 1000 nodesNew array → diff all1 signal → 1 DOM update
Bundle size~1.2MB (React + D3)~50KB + elkjs on demand
State management3 callbacks + applyChangesAutomatic — zero boilerplate
Auto-layoutSeparate elkjs setupflow.layout('layered')
Undo/redoDIYBuilt-in
Connection rulesisValidConnection callbackDeclarative config
Flow