|
|
@@ -0,0 +1,321 @@
|
|
|
+// Auto-layout for the workflow editor canvas.
|
|
|
+//
|
|
|
+// A loop body is laid out as its own nested dagre subgraph (innermost loops
|
|
|
+// first), measured, and then represented in the outer graph as a single
|
|
|
+// composite node exactly the size of that measured layout. This replaces an
|
|
|
+// earlier approach that reserved space for a loop body using an estimate
|
|
|
+// based on node count alone (NODE_HEIGHT * depth + VERTICAL_SPACING * (depth
|
|
|
+// - 1)), which ignored real node sizes and side-by-side branches and could
|
|
|
+// leave a large gap - or too little space - after the loop.
|
|
|
+//
|
|
|
+// This module has no dependency on React or ReactFlow beyond the plain shape
|
|
|
+// of nodes/edges, so it can be exercised headlessly (see
|
|
|
+// scripts/autoLayoutBench.ts) as well as from the editor page.
|
|
|
+
|
|
|
+import dagre from 'dagre'
|
|
|
+
|
|
|
+export interface LayoutNode {
|
|
|
+ id: string
|
|
|
+ /** workflow node type, e.g. 'loop', 'configurator' */
|
|
|
+ type?: string
|
|
|
+ /** ReactFlow's measured width, when available */
|
|
|
+ width?: number | null
|
|
|
+ /** ReactFlow's measured height, when available */
|
|
|
+ height?: number | null
|
|
|
+}
|
|
|
+
|
|
|
+export interface LayoutEdge {
|
|
|
+ source: string
|
|
|
+ target: string
|
|
|
+ sourceHandle?: string | null
|
|
|
+ targetHandle?: string | null
|
|
|
+}
|
|
|
+
|
|
|
+export interface LayoutOptions {
|
|
|
+ gridSize?: number
|
|
|
+ /** Fallback width for a node that has not been measured yet */
|
|
|
+ nodeWidth?: number
|
|
|
+ /** Fallback height for a node that has not been measured yet */
|
|
|
+ nodeHeight?: number
|
|
|
+ horizontalSpacing?: number
|
|
|
+ verticalSpacing?: number
|
|
|
+}
|
|
|
+
|
|
|
+interface ResolvedOptions {
|
|
|
+ gridSize: number
|
|
|
+ nodeWidth: number
|
|
|
+ nodeHeight: number
|
|
|
+ horizontalSpacing: number
|
|
|
+ verticalSpacing: number
|
|
|
+}
|
|
|
+
|
|
|
+type Position = { x: number; y: number }
|
|
|
+
|
|
|
+interface SubgraphLayout {
|
|
|
+ /** top-left position of each node, normalised so the bounding box starts at (0, 0) */
|
|
|
+ positions: Map<string, Position>
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+}
|
|
|
+
|
|
|
+const DEFAULT_OPTIONS: ResolvedOptions = {
|
|
|
+ gridSize: 20,
|
|
|
+ nodeWidth: 220,
|
|
|
+ nodeHeight: 80,
|
|
|
+ horizontalSpacing: 80,
|
|
|
+ verticalSpacing: 100,
|
|
|
+}
|
|
|
+
|
|
|
+function nodeSize(n: LayoutNode | undefined, opts: ResolvedOptions): { width: number; height: number } {
|
|
|
+ return {
|
|
|
+ width: n?.width || opts.nodeWidth,
|
|
|
+ height: n?.height || opts.nodeHeight,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Members of a loop's body: everything reachable from the loop's "loop"
|
|
|
+ * output without crossing back into the loop itself. A nested loop found
|
|
|
+ * along the way is included as a single member (its own body is laid out
|
|
|
+ * separately, recursively), and only its "done" output is followed onward -
|
|
|
+ * its "loop" output is that nested loop's own business.
|
|
|
+ */
|
|
|
+function findBodyNodes(
|
|
|
+ loopId: string,
|
|
|
+ nodeIds: Set<string>,
|
|
|
+ outgoing: Map<string, Array<{ target: string; handle: string }>>,
|
|
|
+ loopIds: Set<string>,
|
|
|
+): Set<string> {
|
|
|
+ const body = new Set<string>()
|
|
|
+ const visited = new Set<string>([loopId])
|
|
|
+ const stack = (outgoing.get(loopId) || [])
|
|
|
+ .filter((e) => e.handle === 'loop')
|
|
|
+ .map((e) => e.target)
|
|
|
+
|
|
|
+ while (stack.length > 0) {
|
|
|
+ const id = stack.pop() as string
|
|
|
+ if (visited.has(id)) continue
|
|
|
+ visited.add(id)
|
|
|
+ if (!nodeIds.has(id)) continue
|
|
|
+ body.add(id)
|
|
|
+
|
|
|
+ const isNestedLoop = loopIds.has(id) && id !== loopId
|
|
|
+ for (const e of outgoing.get(id) || []) {
|
|
|
+ if (e.target === loopId) continue // iteration back-edge, not a body member
|
|
|
+ if (isNestedLoop && e.handle !== 'done') continue // don't descend into a nested loop's own body here
|
|
|
+ stack.push(e.target)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return body
|
|
|
+}
|
|
|
+
|
|
|
+function compositeId(loopId: string): string {
|
|
|
+ return `__composite_${loopId}`
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Lays out one subgraph (either the whole workflow, or a single loop's
|
|
|
+ * body) with dagre, recursing into any loops found within it first so their
|
|
|
+ * measured size is known before the outer graph is built.
|
|
|
+ */
|
|
|
+function layoutSubgraph(
|
|
|
+ nodeIds: Set<string>,
|
|
|
+ nodesById: Map<string, LayoutNode>,
|
|
|
+ outgoing: Map<string, Array<{ target: string; handle: string }>>,
|
|
|
+ edges: LayoutEdge[],
|
|
|
+ loopIds: Set<string>,
|
|
|
+ opts: ResolvedOptions,
|
|
|
+): SubgraphLayout {
|
|
|
+ const loopsHere = [...nodeIds].filter((id) => loopIds.has(id))
|
|
|
+ const bodyOf = new Map<string, Set<string>>()
|
|
|
+ const consumedBody = new Set<string>()
|
|
|
+ const subResults = new Map<string, SubgraphLayout>()
|
|
|
+
|
|
|
+ // Innermost first: recurse into each loop's body before laying out this level.
|
|
|
+ loopsHere.forEach((loopId) => {
|
|
|
+ const body = findBodyNodes(loopId, nodeIds, outgoing, loopIds)
|
|
|
+ bodyOf.set(loopId, body)
|
|
|
+ body.forEach((id) => consumedBody.add(id))
|
|
|
+ if (body.size > 0) {
|
|
|
+ subResults.set(loopId, layoutSubgraph(body, nodesById, outgoing, edges, loopIds, opts))
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const reducedIds = [...nodeIds].filter((id) => !consumedBody.has(id))
|
|
|
+ const reducedSet = new Set(reducedIds)
|
|
|
+
|
|
|
+ const g = new dagre.graphlib.Graph()
|
|
|
+ g.setGraph({
|
|
|
+ rankdir: 'TB',
|
|
|
+ nodesep: opts.horizontalSpacing,
|
|
|
+ ranksep: opts.verticalSpacing,
|
|
|
+ // network-simplex (dagre's default) minimises total edge length, which
|
|
|
+ // keeps a node close to its predecessor instead of pushing it to the
|
|
|
+ // deepest rank it could occupy. longest-path was tried and produced a
|
|
|
+ // large empty gap between a loop's body and whatever follows it, because
|
|
|
+ // it ranks the follow-on node as far down as the longest chain anywhere
|
|
|
+ // in the graph allows rather than right after the loop.
|
|
|
+ ranker: 'network-simplex',
|
|
|
+ })
|
|
|
+ g.setDefaultEdgeLabel(() => ({}))
|
|
|
+
|
|
|
+ const compositeSize = new Map<string, { width: number; height: number }>()
|
|
|
+
|
|
|
+ reducedIds.forEach((id) => {
|
|
|
+ const { width, height } = nodeSize(nodesById.get(id), opts)
|
|
|
+ g.setNode(id, { width, height })
|
|
|
+
|
|
|
+ const sub = subResults.get(id)
|
|
|
+ if (sub) {
|
|
|
+ const size = { width: Math.max(sub.width, opts.nodeWidth), height: Math.max(sub.height, opts.nodeHeight) }
|
|
|
+ compositeSize.set(id, size)
|
|
|
+ g.setNode(compositeId(id), size)
|
|
|
+ // Anchors the body directly beneath its loop, before any of the loop's
|
|
|
+ // other edges (its "done" edge in particular) are considered.
|
|
|
+ g.setEdge(id, compositeId(id))
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ edges.forEach((e) => {
|
|
|
+ const handle = e.sourceHandle || 'main'
|
|
|
+ const sourceInReduced = reducedSet.has(e.source)
|
|
|
+ const targetInReduced = reducedSet.has(e.target)
|
|
|
+
|
|
|
+ if (!sourceInReduced && !targetInReduced) return // internal to some other consumed body
|
|
|
+
|
|
|
+ if (sourceInReduced && targetInReduced) {
|
|
|
+ if (subResults.has(e.source) && handle === 'done') {
|
|
|
+ // Route through the composite so the target ranks after the whole
|
|
|
+ // body, not merely after the loop node itself.
|
|
|
+ g.setEdge(compositeId(e.source), e.target)
|
|
|
+ } else {
|
|
|
+ g.setEdge(e.source, e.target)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (sourceInReduced && !targetInReduced) {
|
|
|
+ // Typically the loop's own "loop" edge into its first body node - that
|
|
|
+ // ordering is already captured by the loop -> composite edge above.
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // !sourceInReduced && targetInReduced: an edge leaving a consumed body.
|
|
|
+ for (const [loopId, body] of bodyOf.entries()) {
|
|
|
+ if (!body.has(e.source)) continue
|
|
|
+ if (e.target === loopId) return // iteration back-edge to the loop itself, not a spatial exit
|
|
|
+ g.setEdge(compositeId(loopId), e.target)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ dagre.layout(g)
|
|
|
+
|
|
|
+ const positions = new Map<string, Position>()
|
|
|
+
|
|
|
+ reducedIds.forEach((id) => {
|
|
|
+ const gn = g.node(id)
|
|
|
+ const { width, height } = nodeSize(nodesById.get(id), opts)
|
|
|
+ positions.set(id, { x: gn.x - width / 2, y: gn.y - height / 2 })
|
|
|
+
|
|
|
+ const sub = subResults.get(id)
|
|
|
+ if (sub) {
|
|
|
+ const gc = g.node(compositeId(id))
|
|
|
+ const size = compositeSize.get(id) as { width: number; height: number }
|
|
|
+ const originX = gc.x - size.width / 2
|
|
|
+ const originY = gc.y - size.height / 2
|
|
|
+ sub.positions.forEach((pos, bodyId) => {
|
|
|
+ positions.set(bodyId, { x: pos.x + originX, y: pos.y + originY })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ let minX = Infinity
|
|
|
+ let minY = Infinity
|
|
|
+ let maxX = -Infinity
|
|
|
+ let maxY = -Infinity
|
|
|
+ positions.forEach((pos, id) => {
|
|
|
+ const { width, height } = nodeSize(nodesById.get(id), opts)
|
|
|
+ minX = Math.min(minX, pos.x)
|
|
|
+ minY = Math.min(minY, pos.y)
|
|
|
+ maxX = Math.max(maxX, pos.x + width)
|
|
|
+ maxY = Math.max(maxY, pos.y + height)
|
|
|
+ })
|
|
|
+ if (!Number.isFinite(minX)) {
|
|
|
+ minX = 0
|
|
|
+ minY = 0
|
|
|
+ maxX = opts.nodeWidth
|
|
|
+ maxY = opts.nodeHeight
|
|
|
+ }
|
|
|
+
|
|
|
+ const normalised = new Map<string, Position>()
|
|
|
+ positions.forEach((pos, id) => normalised.set(id, { x: pos.x - minX, y: pos.y - minY }))
|
|
|
+
|
|
|
+ return { positions: normalised, width: maxX - minX, height: maxY - minY }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Computes top-left node positions for a clean top-to-bottom auto-layout of
|
|
|
+ * the whole workflow. Loop bodies are laid out as nested subgraphs (see
|
|
|
+ * layoutSubgraph); a Configurator is then pulled out to the left of whatever
|
|
|
+ * it configures, since it does not sit in the flow itself.
|
|
|
+ */
|
|
|
+export function computeAutoLayout(
|
|
|
+ nodes: LayoutNode[],
|
|
|
+ edges: LayoutEdge[],
|
|
|
+ options: LayoutOptions = {},
|
|
|
+): Map<string, Position> {
|
|
|
+ if (nodes.length === 0) return new Map()
|
|
|
+
|
|
|
+ const opts: ResolvedOptions = { ...DEFAULT_OPTIONS, ...options }
|
|
|
+ const snapToGrid = (v: number) => Math.round(v / opts.gridSize) * opts.gridSize
|
|
|
+
|
|
|
+ const nodesById = new Map(nodes.map((n) => [n.id, n]))
|
|
|
+ const loopIds = new Set(nodes.filter((n) => n.type === 'loop').map((n) => n.id))
|
|
|
+ const outgoing = new Map<string, Array<{ target: string; handle: string }>>()
|
|
|
+ nodes.forEach((n) => outgoing.set(n.id, []))
|
|
|
+ edges.forEach((e) => {
|
|
|
+ outgoing.get(e.source)?.push({ target: e.target, handle: e.sourceHandle || 'main' })
|
|
|
+ })
|
|
|
+
|
|
|
+ const allIds = new Set(nodes.map((n) => n.id))
|
|
|
+ const layout = layoutSubgraph(allIds, nodesById, outgoing, edges, loopIds, opts)
|
|
|
+
|
|
|
+ const positions = new Map<string, Position>(layout.positions)
|
|
|
+
|
|
|
+ // A Configurator does not sit in the flow - it hangs off the side of the
|
|
|
+ // nodes it configures. Dagre has no idea about that and drops it in the
|
|
|
+ // column like anything else, so it is moved out to the left of whatever it
|
|
|
+ // feeds, level with the topmost one.
|
|
|
+ const CONFIG_GAP = 80
|
|
|
+ nodes.filter((n) => n.type === 'configurator').forEach((cfg) => {
|
|
|
+ const targets = edges
|
|
|
+ .filter((e) => e.source === cfg.id && (e.targetHandle || 'data') === 'config')
|
|
|
+ .map((e) => positions.get(e.target))
|
|
|
+ .filter((p): p is Position => !!p)
|
|
|
+ if (targets.length === 0) return
|
|
|
+ const cfgWidth = nodeSize(cfg, opts).width
|
|
|
+ const leftmost = Math.min(...targets.map((p) => p.x))
|
|
|
+ const topmost = Math.min(...targets.map((p) => p.y))
|
|
|
+ positions.set(cfg.id, { x: leftmost - cfgWidth - CONFIG_GAP, y: topmost })
|
|
|
+ })
|
|
|
+
|
|
|
+ // Two Configurators that ended up on the same spot would hide one another.
|
|
|
+ const takenColumns = new Map<string, number>()
|
|
|
+ nodes.filter((n) => n.type === 'configurator').forEach((cfg) => {
|
|
|
+ const pos = positions.get(cfg.id)
|
|
|
+ if (!pos) return
|
|
|
+ const key = `${Math.round(pos.x)}:${Math.round(pos.y)}`
|
|
|
+ const seen = takenColumns.get(key) || 0
|
|
|
+ if (seen > 0) {
|
|
|
+ const cfgHeight = nodeSize(cfg, opts).height
|
|
|
+ positions.set(cfg.id, { x: pos.x, y: pos.y + seen * (cfgHeight + 40) })
|
|
|
+ }
|
|
|
+ takenColumns.set(key, seen + 1)
|
|
|
+ })
|
|
|
+
|
|
|
+ const snapped = new Map<string, Position>()
|
|
|
+ positions.forEach((pos, id) => snapped.set(id, { x: snapToGrid(pos.x), y: snapToGrid(pos.y) }))
|
|
|
+ return snapped
|
|
|
+}
|