Ver código fonte

fix: lay out loop bodies as nested subgraphs, center nodes by real width

The workflow editor's auto-layout hard-coded a 220x80 box for every node
and centered it at x - 110, but WorkflowNode has no fixed width (only
min-w-150px), so any node whose name made it wider than 220px landed
visibly off-center - the wider the name, the worse. Nodes are now
centered using their own ReactFlow-measured width and height, falling
back to the old constants only when a node has not been measured yet
(e.g. one just added to the canvas).

The bigger problem: a loop body's vertical space was reserved by a
virtual spacer sized from an estimate (NODE_HEIGHT * depth +
VERTICAL_SPACING * (depth - 1)), which ignored real node sizes and any
branching inside the body. When the estimate overshot, everything after
the loop was pushed far down the canvas with a large empty gap.

Loop bodies are now laid out as genuine nested dagre subgraphs,
innermost loop first, and measured for their actual bounding box. The
outer graph gets a composite node sized exactly to that measurement in
place of the spacer, so the reserved space matches the real layout
instead of an estimate. This also fixes nested loops: the previous body
walk refused to follow an edge into another loop at all, orphaning
anything downstream of a nested loop's "done" output; the new walk
includes a nested loop as a body member but only continues past its
"done" edge, leaving its own body to its own recursive layout pass.

Also switched the ranker from 'longest-path' to dagre's default
'network-simplex', which minimizes total edge length instead of pushing
every node to the deepest rank it could occupy - on the workflows tested
this closed most of the remaining gap after a loop on its own.

The layout computation is extracted into utils/autoLayout.ts (pure,
no React/ReactFlow dependency) so it can be exercised headlessly.
Verified against three real workflows fetched from the running
instance: a loop with a branching body and a done-edge chain afterward
(the reported case - gap after the loop went from 340px to 94px with
real 220x80-vs-measured node sizes), a long branching chain with no
loop, and a trivial two-node workflow, using node widths/heights
measured live from the running editor's DOM rather than assumed.
fszontagh 1 mês atrás
pai
commit
ccdde007eb
2 arquivos alterados com 339 adições e 296 exclusões
  1. 18 296
      webui/src/pages/WorkflowEditorPage.tsx
  2. 321 0
      webui/src/utils/autoLayout.ts

+ 18 - 296
webui/src/pages/WorkflowEditorPage.tsx

@@ -1,7 +1,7 @@
 import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import dagre from 'dagre'
 import { workflowsApi, nodesApi, executionsApi, Workflow, NodeDefinition, NodeOutput } from '../api/workflows'
+import { computeAutoLayout, LayoutNode, LayoutEdge } from '../utils/autoLayout'
 import ReactFlow, {
   Background,
   Controls,
@@ -2183,305 +2183,27 @@ function WorkflowEditorInner() {
     setShowDeleteConfirm(null)
   }, [setNodes, setEdges])
 
-  // Auto-layout nodes using dagre for clean top-to-bottom flow
+  // Auto-layout nodes using dagre for clean top-to-bottom flow. The actual
+  // graph computation lives in utils/autoLayout.ts, laying out each loop
+  // body as its own nested subgraph rather than reserving an estimated block
+  // of vertical space for it - see that file for the detail.
   const autoLayout = useCallback(() => {
     if (nodes.length === 0) return
 
-    const GRID_SIZE = 20
-    const NODE_WIDTH = 220
-    const NODE_HEIGHT = 80
-    const HORIZONTAL_SPACING = 80
-    const VERTICAL_SPACING = 100
-
-    const snapToGrid = (value: number) => Math.round(value / GRID_SIZE) * GRID_SIZE
-
-    // Build edge maps for loop detection
-    const outgoingEdges = new Map<string, Array<{ target: string; handle: string }>>()
-    nodes.forEach(n => outgoingEdges.set(n.id, []))
-    edges.forEach(e => {
-      outgoingEdges.get(e.source)?.push({ target: e.target, handle: e.sourceHandle || 'main' })
-    })
-
-    // Identify loop nodes and their body nodes
-    const loopNodes = new Set<string>()
-    const loopBodyNodes = new Map<string, Set<string>>()
-
-    nodes.forEach(n => {
-      if (n.data.type === 'loop') {
-        loopNodes.add(n.id)
-        loopBodyNodes.set(n.id, new Set())
-      }
-    })
-
-    // Find body nodes for each loop (nodes connected via "loop" output, not "done")
-    const findLoopBodyNodes = (loopId: string, nodeId: string, visited: Set<string>) => {
-      if (visited.has(nodeId)) return
-      visited.add(nodeId)
-
-      const bodySet = loopBodyNodes.get(loopId)!
-      if (nodeId !== loopId) {
-        bodySet.add(nodeId)
-      }
-
-      const outEdges = outgoingEdges.get(nodeId) || []
-      for (const edge of outEdges) {
-        // Don't follow "done" edges from the loop itself
-        if (nodeId === loopId && edge.handle === 'done') continue
-        // Don't include other loops as body nodes
-        if (loopNodes.has(edge.target) && edge.target !== loopId) continue
-        findLoopBodyNodes(loopId, edge.target, visited)
-      }
-    }
-
-    loopNodes.forEach(loopId => {
-      const outEdges = outgoingEdges.get(loopId) || []
-      const loopEdges = outEdges.filter(e => e.handle === 'loop')
-      const visited = new Set<string>()
-      loopEdges.forEach(edge => findLoopBodyNodes(loopId, edge.target, visited))
-    })
-
-    // Calculate depth of loop body (how many nodes deep)
-    const getLoopBodyDepth = (loopId: string): number => {
-      const bodyNodes = loopBodyNodes.get(loopId)
-      if (!bodyNodes || bodyNodes.size === 0) return 1
-
-      let maxDepth = 0
-      const visited = new Set<string>()
-
-      const dfs = (nodeId: string, depth: number) => {
-        if (visited.has(nodeId)) return
-        visited.add(nodeId)
-        maxDepth = Math.max(maxDepth, depth)
-
-        const outEdges = outgoingEdges.get(nodeId) || []
-        for (const edge of outEdges) {
-          if (bodyNodes.has(edge.target)) {
-            dfs(edge.target, depth + 1)
-          }
-        }
-      }
-
-      // Start from loop's direct children
-      const loopOutEdges = outgoingEdges.get(loopId) || []
-      loopOutEdges.filter(e => e.handle === 'loop').forEach(e => {
-        dfs(e.target, 1)
-      })
-
-      return Math.max(maxDepth, 1)
-    }
-
-    // Create dagre graph
-    const g = new dagre.graphlib.Graph()
-    g.setGraph({
-      rankdir: 'TB',
-      nodesep: HORIZONTAL_SPACING,
-      ranksep: VERTICAL_SPACING,
-      ranker: 'longest-path',
-    })
-    g.setDefaultEdgeLabel(() => ({}))
-
-    // Add all real nodes
-    nodes.forEach(n => {
-      g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
-    })
-
-    // Add virtual spacer nodes for loop bodies to reserve vertical space
-    const spacerNodes = new Map<string, string>()
-    loopNodes.forEach(loopId => {
-      const spacerId = `spacer_${loopId}`
-      spacerNodes.set(loopId, spacerId)
-      const depth = getLoopBodyDepth(loopId)
-      // Create spacer with height based on loop body depth
-      g.setNode(spacerId, {
-        width: NODE_WIDTH,
-        height: NODE_HEIGHT * depth + VERTICAL_SPACING * (depth - 1)
-      })
-    })
-
-    // Find nodes that exit from loop body to main flow (connected to nodes outside the loop body)
-    const loopExitTargets = new Map<string, Set<string>>()
-    loopNodes.forEach(loopId => {
-      loopExitTargets.set(loopId, new Set())
-      const bodyNodes = loopBodyNodes.get(loopId)
-      if (!bodyNodes) return
-
-      bodyNodes.forEach(bodyNodeId => {
-        const outEdges = outgoingEdges.get(bodyNodeId) || []
-        outEdges.forEach(edge => {
-          // If edge target is not a body node and not the loop itself, it's an exit edge
-          if (!bodyNodes.has(edge.target) && edge.target !== loopId) {
-            loopExitTargets.get(loopId)?.add(edge.target)
-          }
-        })
-      })
-    })
-
-    // Add edges to dagre, routing loop flows through spacers
-    edges.forEach(e => {
-      const sourceNode = nodes.find(n => n.id === e.source)
-      const sourceHandle = e.sourceHandle || 'main'
-
-      if (sourceNode?.data.type === 'loop') {
-        const spacerId = spacerNodes.get(e.source)!
-        if (sourceHandle === 'loop') {
-          // Loop body connection: loop -> spacer (don't add to dagre, handled separately)
-          return
-        } else if (sourceHandle === 'done') {
-          // Done connection: goes from spacer to target
-          g.setEdge(spacerId, e.target)
-          return
-        }
-      }
-
-      // Check if this edge is from a loop body node to something outside
-      let isExitEdge = false
-      loopNodes.forEach(loopId => {
-        const bodyNodes = loopBodyNodes.get(loopId)
-        if (bodyNodes?.has(e.source) && !bodyNodes.has(e.target) && e.target !== loopId) {
-          isExitEdge = true
-          // Route through spacer to ensure proper ordering
-          const spacerId = spacerNodes.get(loopId)!
-          g.setEdge(spacerId, e.target)
-        }
-      })
-
-      if (!isExitEdge) {
-        // Regular edge - only add if not loop body internal
-        let isLoopBodyInternal = false
-        loopNodes.forEach(loopId => {
-          const bodyNodes = loopBodyNodes.get(loopId)
-          if (bodyNodes?.has(e.source) && (bodyNodes.has(e.target) || e.target === loopId)) {
-            isLoopBodyInternal = true
-          }
-        })
-
-        if (!isLoopBodyInternal) {
-          g.setEdge(e.source, e.target)
-        }
-      }
-    })
-
-    // Connect loops to their spacers
-    loopNodes.forEach(loopId => {
-      const spacerId = spacerNodes.get(loopId)!
-      g.setEdge(loopId, spacerId)
-    })
-
-    // Run dagre layout
-    dagre.layout(g)
-
-    // Extract positions
-    const newPositions = new Map<string, { x: number; y: number }>()
-
-    // Position real nodes
-    nodes.forEach(n => {
-      const nodeData = g.node(n.id)
-      if (nodeData) {
-        newPositions.set(n.id, {
-          x: snapToGrid(nodeData.x - NODE_WIDTH / 2),
-          y: snapToGrid(nodeData.y - NODE_HEIGHT / 2),
-        })
-      }
-    })
-
-    // Position loop body nodes based on their loop's spacer position
-    loopNodes.forEach(loopId => {
-      const spacerId = spacerNodes.get(loopId)!
-      const spacerData = g.node(spacerId)
-      const loopPos = newPositions.get(loopId)
-      const bodyNodes = loopBodyNodes.get(loopId)
-
-      if (!spacerData || !loopPos || !bodyNodes || bodyNodes.size === 0) return
-
-      // Create a mini-graph for loop body nodes
-      const bodyGraph = new dagre.graphlib.Graph()
-      bodyGraph.setGraph({
-        rankdir: 'TB',
-        nodesep: HORIZONTAL_SPACING / 2,
-        ranksep: VERTICAL_SPACING / 2,
-      })
-      bodyGraph.setDefaultEdgeLabel(() => ({}))
-
-      bodyNodes.forEach(bodyId => {
-        bodyGraph.setNode(bodyId, { width: NODE_WIDTH, height: NODE_HEIGHT })
-      })
-
-      // Add edges between body nodes
-      edges.forEach(e => {
-        if (bodyNodes.has(e.source) && bodyNodes.has(e.target)) {
-          bodyGraph.setEdge(e.source, e.target)
-        }
-      })
-
-      // Add edges from loop to first body nodes
-      const loopOutEdges = outgoingEdges.get(loopId) || []
-      loopOutEdges.filter(e => e.handle === 'loop').forEach(e => {
-        if (bodyNodes.has(e.target)) {
-          bodyGraph.setNode('_loop_start', { width: 0, height: 0 })
-          bodyGraph.setEdge('_loop_start', e.target)
-        }
-      })
-
-      dagre.layout(bodyGraph)
-
-      // Position body nodes below and indented from the loop
-      const loopCenterX = loopPos.x + NODE_WIDTH / 2
-      const bodyStartY = loopPos.y + NODE_HEIGHT + VERTICAL_SPACING / 2
-
-      let minX = Infinity, maxX = -Infinity
-      bodyNodes.forEach(bodyId => {
-        const bodyData = bodyGraph.node(bodyId)
-        if (bodyData) {
-          minX = Math.min(minX, bodyData.x)
-          maxX = Math.max(maxX, bodyData.x)
-        }
-      })
-
-      const bodyWidth = maxX - minX
-      const offsetX = loopCenterX - (minX + bodyWidth / 2) + 40 // Indent to the right
-
-      bodyNodes.forEach(bodyId => {
-        const bodyData = bodyGraph.node(bodyId)
-        if (bodyData) {
-          newPositions.set(bodyId, {
-            x: snapToGrid(bodyData.x - NODE_WIDTH / 2 + offsetX),
-            y: snapToGrid(bodyData.y - NODE_HEIGHT / 2 + bodyStartY),
-          })
-        }
-      })
-    })
-
-    // 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 = NODE_WIDTH + 80
-    nodes.filter(n => n.data.type === 'configurator').forEach(cfg => {
-      const targets = edges
-        .filter(e => e.source === cfg.id && (e.targetHandle || 'data') === 'config')
-        .map(e => newPositions.get(e.target))
-        .filter((p): p is { x: number; y: number } => !!p)
-      if (targets.length === 0) return
-      const leftmost = Math.min(...targets.map(p => p.x))
-      const topmost = Math.min(...targets.map(p => p.y))
-      newPositions.set(cfg.id, {
-        x: snapToGrid(leftmost - CONFIG_GAP),
-        y: snapToGrid(topmost),
-      })
-    })
+    const layoutNodes: LayoutNode[] = nodes.map(n => ({
+      id: n.id,
+      type: n.data.type,
+      width: n.width,
+      height: n.height,
+    }))
+    const layoutEdges: LayoutEdge[] = edges.map(e => ({
+      source: e.source,
+      target: e.target,
+      sourceHandle: e.sourceHandle,
+      targetHandle: e.targetHandle,
+    }))
 
-    // Two Configurators that ended up on the same spot would hide one another.
-    const takenColumns = new Map<string, number>()
-    nodes.filter(n => n.data.type === 'configurator').forEach(cfg => {
-      const pos = newPositions.get(cfg.id)
-      if (!pos) return
-      const key = `${pos.x}:${pos.y}`
-      const seen = takenColumns.get(key) || 0
-      if (seen > 0) {
-        newPositions.set(cfg.id, { x: pos.x, y: snapToGrid(pos.y + seen * (NODE_HEIGHT + 40)) })
-      }
-      takenColumns.set(key, seen + 1)
-    })
+    const newPositions = computeAutoLayout(layoutNodes, layoutEdges)
 
     // Check if any positions actually changed
     const positionsChanged = nodes.some(n => {

+ 321 - 0
webui/src/utils/autoLayout.ts

@@ -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
+}