/** * Draggable node-graph canvas (direct-manipulation primitive). * * Generic over node payloads: the caller owns the graph state and receives * `onMoveNode` / `onConnect` / `onSelect` callbacks; this component renders * nodes (default card rendering or a custom `renderNode`), bezier edges with * arrowheads, drag with snap-to-grid, and interactive edge drawing from a * node's output port to another node's input port. * * Used by the Pipeline Builder; reusable for any stage/flow editor. */ import { useState, type ReactNode } from 'react'; import CanvasSurface, { snapTo, worldDrag, type CanvasApi } from './canvas'; import { tokens } from '../../theme'; export interface GraphNode { id: string; x: number; y: number; label: string; sublabel?: string; color?: string; w?: number; h?: number; badge?: string; } export interface GraphEdge { from: string; to: string; label?: string; color?: string; dashed?: boolean; } interface NodeCanvasProps { nodes: GraphNode[]; edges: GraphEdge[]; selectedId?: string | null; onSelect?: (id: string | null) => void; onMoveNode?: (id: string, x: number, y: number) => void; /** When provided, dragging from a node's output port draws a new edge. */ onConnect?: (fromId: string, toId: string) => void; height?: number | string; world?: { x: number; y: number; w: number; h: number }; snap?: number; nodeW?: number; nodeH?: number; renderNode?: (node: GraphNode, selected: boolean) => ReactNode; ariaLabel?: string; } const DEFAULT_W = 132; const DEFAULT_H = 56; function edgePath(from: GraphNode, to: GraphNode, w: number, h: number): string { const fw = from.w ?? w; const tw = to.w ?? w; const sx = from.x + fw / 2; const sy = from.y; const ex = to.x - tw / 2 - 4; const ey = to.y; if (ex < sx - 20) { // feedback edge: loop under both nodes const fh = from.h ?? h; const drop = Math.max(from.y, to.y) + fh + 40; return `M ${from.x} ${from.y + fh / 2} C ${from.x} ${drop}, ${to.x} ${drop}, ${to.x} ${to.y + (to.h ?? h) / 2 + 4}`; } const mx = (sx + ex) / 2; return `M ${sx} ${sy} C ${mx} ${sy}, ${mx} ${ey}, ${ex} ${ey}`; } export default function NodeCanvas({ nodes, edges, selectedId, onSelect, onMoveNode, onConnect, height = 480, world = { x: 0, y: 0, w: 1200, h: 520 }, snap = 20, nodeW = DEFAULT_W, nodeH = DEFAULT_H, renderNode, ariaLabel, }: NodeCanvasProps) { const byId = new Map(nodes.map((n) => [n.id, n])); const [dragOffset, setDragOffset] = useState<{ id: string; dx: number; dy: number } | null>(null); const [pendingEdge, setPendingEdge] = useState<{ fromId: string; x: number; y: number; overId: string | null } | null>(null); const nodeAt = (x: number, y: number): GraphNode | null => nodes.find((n) => { const w2 = (n.w ?? nodeW) / 2; const h2 = (n.h ?? nodeH) / 2; return x >= n.x - w2 - 8 && x <= n.x + w2 + 8 && y >= n.y - h2 - 8 && y <= n.y + h2 + 8; }) ?? null; return ( onSelect?.(null)} ariaLabel={ariaLabel ?? 'Node graph canvas'} > {(api: CanvasApi) => ( <> {edges.map((edge, i) => { const from = byId.get(edge.from); const to = byId.get(edge.to); if (!from || !to) return null; const hot = selectedId === edge.from || selectedId === edge.to; return ( ${edge.to}-${i}`}> {edge.label ? ( {edge.label} ) : null} ); })} {/* edge being drawn */} {pendingEdge ? (() => { const from = byId.get(pendingEdge.fromId); if (!from) return null; return ( ); })() : null} {nodes.map((node) => { const w = node.w ?? nodeW; const h = node.h ?? nodeH; const selected = selectedId === node.id; const targeted = pendingEdge?.overId === node.id && pendingEdge.fromId !== node.id; const drag = onMoveNode ? worldDrag(api, { onStart: (p) => { setDragOffset({ id: node.id, dx: node.x - p.x, dy: node.y - p.y }); onSelect?.(node.id); }, onMove: (p) => { const off = dragOffset && dragOffset.id === node.id ? dragOffset : { dx: 0, dy: 0 }; onMoveNode(node.id, snapTo(p.x + off.dx, snap), snapTo(p.y + off.dy, snap)); }, onEnd: () => setDragOffset(null), }) : { onPointerDown: (e: React.PointerEvent) => { e.stopPropagation(); onSelect?.(node.id); } }; return ( {renderNode ? ( renderNode(node, selected) ) : ( <> {node.label} {node.sublabel ? ( {node.sublabel} ) : null} {node.badge ? ( {node.badge} ) : null} )} {/* connection ports */} {onConnect ? ( <> {/* input port (left) */} {/* output port (right) — drag to draw an edge */} setPendingEdge({ fromId: node.id, x: p.x, y: p.y, overId: null }), onMove: (p) => setPendingEdge({ fromId: node.id, x: p.x, y: p.y, overId: nodeAt(p.x, p.y)?.id ?? null }), onEnd: (p) => { const target = nodeAt(p.x, p.y); if (target && target.id !== node.id) onConnect(node.id, target.id); setPendingEdge(null); }, })} /> ) : null} ); })} )} ); }