Node Canvas

A pannable, zoomable canvas of draggable nodes and the edges between them — the substrate for an agent pipeline, a retrieval chain or a build DAG. Nodes are real DOM, so you put your own components inside them.

src/components/ui/node-canvas.tsx

About

From the component's own source, where it was written.

A pannable, zoomable canvas of draggable nodes and the edges between them.

The substrate for anything graph-shaped: an agent pipeline, a retrieval chain, a build DAG, a state machine. Nothing here knows what a node *means* — you register node types, and the canvas only owns position, selection and the wires.

**Why it is not a <canvas>.** Nodes are real DOM, so their contents stay selectable, focusable and readable by a screen reader, and a node can be a whole component — a form, a chart, a query builder — rather than a picture of one. Only the edges are drawn, in one SVG layer underneath.

**Event ownership.** A node's contents belong to whoever wrote them, so every gesture here asks *what was actually hit* before acting on it. Dragging skips controls and anything marked data-nodrag; the wheel skips a scrollable region; keys are only the canvas's when the node itself has focus, not a field inside it. Without those checks a text input inside a node cannot take a space, a Backspace deletes the node instead of a character, and dragging to select text moves the node — which is why this used to need a stopPropagation wrapper that made the region undraggable in exchange.

**A drag starts on movement, not on contact.** Nothing is captured until the pointer travels dragThreshold pixels. A click stays a click, so buttons, switches and links inside a node work, and the pointer capture that a slider or a colour picker takes for itself is never stolen.

**Coordinates.** Nodes are positioned in graph space; the whole layer is moved and scaled by a single transform on a wrapper, so panning and zooming cost one style write rather than one per node. toGraph converts a pointer position back through that transform — without it, dragging a node while zoomed moves it by the wrong distance, which is the classic bug in a hand-rolled canvas.

**Keyboard.** Every node is a tab stop, arrows nudge the focused node, and shift-arrow nudges it by ten. Every edge carries a real button to detach it. A graph editor reachable only by mouse is not an editor for everyone, and the arrow-key path is also the precise one.

Install

npx astralyx-ui add node-canvas

Usage

import { NodeCanvas } from '@/components/ui/node-canvas'

// Node types are ordinary components, declared once, referred to by name.
const nodeTypes = { step: StepNode, webhook: WebhookNode }

<NodeCanvas
  nodes={nodes}          // [{ id: 'a', type: 'step', x: 0, y: 60 }, …]
  edges={edges}
  nodeTypes={nodeTypes}
  onNodesChange={setNodes}
  onConnect={(from, to) => setEdges([...edges, { id: `${from}-${to}`, from, to }])}
  onRemoveEdge={(id) => setEdges(edges.filter((edge) => edge.id !== id))}
  onAddConnected={(from, at) => addStep(from, at)}
/>

Composer

Change the props and the source below follows.

Steps

Trigger

Incoming message

Model

Plan the reply

Tool

search_docs

Tool

create_ticket

Output

Send reply

Props
tsx
<NodeCanvas
  nodes={nodes}
  edges={edges}
  nodeTypes={{ step: StepNode }}
  height={340}
  onNodesChange={setNodes}
  onConnect={connect}
  onAddConnected={addStep}
  onDropNode={dropStep}
  onRemoveEdge={detach}
/>

Examples

An agent pipeline you can edit

Nodes that are whole components

Adding nodes, and typing in them

Read-only

API

Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.

NodeCanvas

Extends Omit<ComponentProps<'div'>, 'onSelect'>.

PropTypeDefaultDescription
nodesCanvasNode[]requiredPositions are graph units, not pixels. A node carries a type naming its component and a data payload; nothing about how it looks lives in the graph, so it survives JSON.
edgesCanvasEdge[][]Positions are graph units, not pixels. A node carries a type naming its component and a data payload; nothing about how it looks lives in the graph, so it survives JSON.
nodeTypesNodeTypes—Your node components, by node.type. Write them as ordinary components — a form, a chart, a whole query builder — and the graph only carries the name. Declare the map at module scope: a component created during render is a new component type each time, which remounts every node and takes focus out of the field being typed in.
renderNode(node: CanvasNode, state: { selected: boolean }) => ReactNode—Draws any node without a registered type. Still supported, and still the shortest path for a graph of one shape.
onNodesChange(nodes: CanvasNode[]) => void—Dragging and arrow-key nudges. Omit it and the canvas is read-only, though nodes stay focusable.
selectedIdstring | null——
onSelect(id: string | null) => void——
nodeWidthnumber180Uniform node width in graph units. Individual nodes may override it.
gridnumber24Background dot spacing in graph units. 0 turns the grid off.
snapToGridbooleanfalseRounds to grid while dragging and nudging. The dots implied an alignment model the canvas did not have.
minZoomnumber0.35Clamp for the wheel zoom.
maxZoomnumber2.5—
zoomOnWheelbooleantrueWheel over the canvas zooms, and the page stays put.
defaultPanPoint—Start position and scale of the viewport.
defaultZoomnumber1—
heightnumber | string420Canvas height. Graphs need a stated box; they have no intrinsic one.
labelstring'Node graph'Accessible name for the graph region.
nodeLabel(node: CanvasNode) => string—Names a node for a screen reader, when its contents are not text.
nudgenumber8How far an arrow key moves a node, in graph units.
dragThresholdnumber4A drag begins on movement, not on contact, and nothing is captured until then. That is what keeps a click a click, so a button inside a node fires and a slider keeps the pointer capture it took for itself.
onAddNode(position: Point) => void—Double-click empty canvas, and drops from NodePalette. Both receive graph coordinates, already corrected for pan and zoom.
onDropNode(payload: string, position: Point) => void—Double-click empty canvas, and drops from NodePalette. Both receive graph coordinates, already corrected for pan and zoom.
onConnect(from: string, to: string) => void—Drag from a node’s trailing port onto another node — dropping anywhere on it counts, not only on the 12px dot. Also what makes the ports render at all. A self-link, a duplicate, and a target marked connectable: false are refused before you are asked.
isValidConnection(from: string, to: string) => boolean—The domain rule on top of those. Whether a cycle is legal is yours to decide — a state machine wants them, a build DAG does not.
onRemoveNode(id: string) => void—Backspace or Delete with a node focused, unless the node sets deletable: false.
onRemoveEdge(id: string) => void—Detaching a connection. Hovering a wire reveals a × at its midpoint; it is a real button, so a keyboard reaches it too. Omit and edges are inert.
onAddConnected(fromId: string, position: Point) => void—The + on a node: create a step already wired to it. A real button, so chaining has a keyboard path even though dragging a wire does not.
addConnectedLabelstring'Add a connected step'Accessible name for that button. Receives the source node's label.
removeEdgeLabelstring'Detach this connection'Accessible name for an edge's detach button.
onViewportChange(viewport: { pan: Point; zoom: number }) => void—Reports pan and zoom, for a caller that wants to persist the viewport.
contentautomatic—A press that lands on a control reaches the control, not the drag: inputs, buttons, switches, links, sliders and anything marked data-nodrag are skipped. Keys belong to the node only while the node itself has focus, so a space types, Backspace edits and the arrows move the caret. A scrollable region inside a node keeps its own wheel.

NodePalette

The tray of things you can drag onto a canvas.

Extends Omit<ComponentProps<'div'>, 'onSelect'>.

PropTypeDefaultDescription
items{ id: string; label: ReactNode; hint?: ReactNode }[]required—
onPick(id: string) => void—Clicked rather than dragged — place it wherever your layout prefers.

Hooks

HookSignatureDescription
useCanvasNode() => CanvasContextValueRead at any depth inside a node. update({ data }) writes the node back without a callback threaded down from the top, which is what lets a node own its own state.

Functions

FunctionSignatureDescription
NODE_DRAG_TYPE'application/x-astralyx-node'The dataTransfer type NodePalette writes and the canvas reads.
edgePath(from: Point, to: Point) => stringA cubic bezier with horizontal control arms.

Types

CanvasNode

A pannable, zoomable canvas of draggable nodes and the edges between them.

FieldTypeDescription
idstring—
xnumber—
ynumber—
width?number | 'auto'Falls back to the canvas-level nodeWidth. 'auto' sizes to content.
type?stringWhich registered nodeTypes entry draws this node.
label?ReactNode—
data?unknownMark a title bar with it and the policy inverts — only that strip drags the node. What a node whose whole surface is a form needs, because there is no leftover pixel to grab. Presence, not a selector prop: a selector that matches nothing produces a node that silently cannot be moved.
connectable?booleanOff for a terminal step that nothing may follow. Defaults to on.
draggable?booleanOff to pin a node in place while the rest of the graph stays draggable.
deletable?booleanOff for a node the graph cannot exist without — a trigger, an entry point.
z?numberStacking order. Selected and dragging nodes rise above it regardless.

CanvasEdge

FieldTypeDescription
idstring—
fromstring—
tostring—
label?ReactNodeRides the middle of the curve — which branch this is, what it carries.
dashed?booleanDraws dashed — for a path that is speculative or inactive.
deletable?booleanOff for a connection that must not be detached from the canvas.

CanvasNodeProps

What a registered node type is rendered with.

FieldTypeDescription
nodeCanvasNode—
selectedboolean—
draggingbooleanTrue while this node is being dragged — for a lifted shadow, say.
TypeDefinitionDescription
NodeTypes
Record<string, ComponentType<CanvasNodeProps>>
—

Behaviour

TopicDetailDescription
zoom / panwheel, background dragZoom is anchored to the pointer, so the point under the cursor stays put. minZoom, maxZoom, defaultPan and defaultZoom are all props.
keyboardTab / arrows / Shift+arrowsEvery node is a tab stop; arrows nudge by nudge units and Shift by ten times that. Precise placement is easier this way than with a pointer. Keys are only the canvas’s while the node itself holds focus.
accessibilityrole="group"Not role="button". A button’s contents are presentational to a screen reader, so every field inside a node used to vanish from the accessibility tree — and a node whose body is a form is not a button.
touchknown gapThe viewport sets touch-action: none so a drag is never stolen by a scroll, and that intersects down the tree — so a scrollable region inside a node cannot be flicked with a finger, and there is no pinch-zoom. The wheel and the pointer paths are complete.