feat(frontend): replace flat sugiyama with recursive compound layout for flow graph (#8204)

* feat(frontend): replace flat sugiyama with recursive compound layout for flow graph

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): double forloop wrapper padding and include wrappers in bbox

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): gate debug wrappers behind SHOW_DEBUG_WRAPPERS flag

Remove all debug console.log calls from compoundLayout and gate
WrapperInfo creation and wrapper node rendering behind an exported
SHOW_DEBUG_WRAPPERS constant. Replace wrapper-based bbox computation
with groupLayouts-based loop so no WrapperInfo is needed for correct
layout. Add contentMinX to LayoutResult for the top-level minX shift.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): remove debug wrapper nodes from flow graph

Remove WrapperInfo type, SHOW_DEBUG_WRAPPERS flag, buildDebugWrapperNodes
helper, DebugWrapperNode component, and all related plumbing in
FlowGraphV2. The bbox computation now uses groupLayouts directly,
keeping layout correctness without any debug wrapper overhead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* perf(frontend): optimize compoundLayout recursive algorithm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(frontend): remove dead offset plumbing from flow graph

The old flat sugiyama layout used a CSS margin-left hack (offset) to
indent loop bodies. The new recursive compound layout handles indentation
natively via coordinates, making the entire offset pipeline dead code.

Removes offset from 11 node type definitions, NodeLayout, addNode helper,
processModules parameter, NodeWrapper prop, 9 node renderers, AssetNode
x-position calculations, AIToolNode x-position calculations, DragGhost
nodeOffset function, FlowGraphV2 layout pipeline, util.ts type signatures,
noteUtils NodeDep type, and noteEditor function signature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): remove unused lastXCenter variable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* perf(frontend): optimize compoundLayout hot paths

Replace O(N²) queue.shift() with index pointer in BFS, eliminate
redundant groupOwnedIds double-build, use Set for parent dedup,
track minY in existing bbox loop, and cache maxBranchHeight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove debug artifacts from PR

Remove elk_viewer test page, console log dumps, and layout screenshots
that were used during development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): guard data.module.value access in ModuleNode

When rapidly clicking expand/collapse on a subflow, the graph rebuilds
and data.module can be transiently undefined. Add optional chaining to
prevent "Cannot read properties of undefined (reading 'value')" errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(frontend): simplify CompoundGroup type to 'branch' | 'loop'

The layout never distinguishes branchall/branchone or forloop/whileloop,
so collapse to two variants that match the actual code paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): address PR review feedback on flow layout

- Add max recursion depth guard (50) to layoutLevel to prevent stack
  overflow with malformed flow data
- Log swallowed decrossOpt error as console.debug for debuggability
- Initialize maxY to -Infinity for correctness with negative positions
- Fix indentation artifacts in graphBuilder data objects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* formatting

* fix: remove offset field from asset node data in FlowGraphV2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-03-10 09:06:02 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent bc9f235e1e
commit 0fcb29cfad
19 changed files with 623 additions and 158 deletions
@@ -18,10 +18,6 @@
/** Offset so the cursor indicator icon doesn't overlap the cursor tip */
const CURSOR_INDICATOR_OFFSET = 8
function nodeOffset(n: Node): number {
return ((n.data as Record<string, unknown>)?.offset as number) ?? 0
}
function getSubflowNodesAndEdges(
moduleId: string,
allNodes: Node[],
@@ -68,7 +64,7 @@
maxY = -Infinity
for (const n of sfNodes) {
const abs = absolutePosition(n, allNodes)
const x = abs.x + nodeOffset(n)
const x = abs.x
const y = abs.y
const w = n.measured?.width ?? NODE.width
const h = n.measured?.height ?? NODE.height
@@ -90,7 +86,7 @@
let offsetY = containerHeight / 2
if (mainNode) {
const mainAbs = absolutePosition(mainNode, allNodes)
const mx = mainAbs.x + nodeOffset(mainNode) - minX + PADDING
const mx = mainAbs.x - minX + PADDING
const my = mainAbs.y - minY + PADDING
const mw = mainNode.measured?.width ?? NODE.width
const mh = mainNode.measured?.height ?? NODE.height
@@ -20,7 +20,6 @@
import {
graphBuilder,
isTriggerStep,
topologicalSort,
type InlineScript,
type InsertKind,
type NodeLayout,
@@ -36,7 +35,6 @@
import ResultNode from './renderers/nodes/ResultNode.svelte'
import BaseEdge from './renderers/edges/BaseEdge.svelte'
import EmptyEdge from './renderers/edges/EmptyEdge.svelte'
import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
import { Expand, MousePointer, Hand } from 'lucide-svelte'
import Toggle from '../Toggle.svelte'
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
@@ -71,6 +69,7 @@
import type { MoveManager } from './moveManager.svelte'
import DragCoordinator from './DragCoordinator.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
import { compoundLayout } from './compoundLayout'
import { deepEqual } from 'fast-equals'
import type { AssetWithAltAccessType } from '../assets/lib'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
@@ -333,7 +332,6 @@
type NodeDep = {
id: string
parentIds?: string[]
offset?: number
data?: { assets?: AssetWithAltAccessType[] }
}
type NodePos = { position: { x: number; y: number } }
@@ -354,59 +352,21 @@
seenId.push(n.id)
}
let nodeWidths: Record<string, number> = {}
const nodes2: (NodeDep & NodePos)[] = nodes.map((n) => {
return { ...n, position: { x: 0, y: 0 } }
// Run recursive compound layout
const { positions, bbox } = compoundLayout(nodes, {
nodeWidth: NODE.width,
nodeHeight: NODE.height,
gapH: NODE.gap.horizontal,
gapV: NODE.gap.vertical
})
for (const n of topologicalSort(nodes)) {
const endId = n.id + '-end'
if (nodeWidths[endId] != undefined) {
nodeWidths[n.id] = Math.max(nodeWidths[n.id] ?? 0, nodeWidths[endId])
}
if (n.parentIds && n.parentIds?.length == 1) {
const parent = n.parentIds[0]
const nodeWidth = nodeWidths[n.id] ?? 1
nodeWidths[parent] = (nodeWidths[parent] ?? 0) + nodeWidth
}
}
const dag = dagStratify().id(({ id }: NodeDep & NodePos) => id)(nodes2)
let boxSize: any
try {
const layout = sugiyama()
.decross(nodes.length > 20 ? decrossTwoLayer() : decrossOpt())
.coord(coordCenter())
.nodeSize((d) => {
return [
(nodeWidths[d?.data?.['id'] ?? ''] ?? 1) * (NODE.width + NODE.gap.horizontal * 1),
NODE.height + NODE.gap.vertical
] as readonly [number, number]
})
boxSize = layout(dag as any)
} catch {
const layout = sugiyama()
.decross(decrossTwoLayer())
.coord(coordCenter())
.nodeSize(() => [NODE.width + NODE.gap.horizontal, NODE.height + NODE.gap.vertical])
boxSize = layout(dag as any)
}
const newNodes = dag.descendants().map((des) => ({
id: des.data.id,
// Center horizontally
const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
const newNodes = nodes.map((n) => ({
id: n.id,
position: {
x: des.x
? // @ts-ignore
(des.data.offset ?? 0) +
// @ts-ignore
des.x +
(fullSize ? fullWidth : width) / 2 -
boxSize.width / 2 -
NODE.width / 2 -
(width - fullWidth) / 2
: 0,
y: des.y || 0
x: (positions.get(n.id)?.x ?? 0) + xCenter - NODE.width / 2,
y: positions.get(n.id)?.y ?? 0
}
}))
@@ -631,7 +591,6 @@
Object.values(graph.nodes).map((n) => ({
id: n.id,
parentIds: n.parentIds,
offset: n.data.offset ?? 0,
data: { assets: (n.data as any).assets }
}))
)
@@ -640,10 +599,7 @@
let assetNodesResult = $showAssets
? computeAssetNodes(
newNodes.map((n) => ({
data: {
assets: n.data?.assets as AssetWithAltAccessType[],
offset: n.data?.offset as number
},
data: { assets: n.data?.assets as AssetWithAltAccessType[] },
id: n.id,
position: n.position
}))
@@ -674,7 +630,6 @@
id: n.id,
position: n.position,
parentIds: n.parentIds,
offset: n.data?.offset ?? 0,
data: { assets: (n.data as any)?.assets },
type: n.type
})),
@@ -1039,8 +994,8 @@
{#if multiSelectEnabled}
<SelectionBoundingBox
selectedNodes={selectionManager.selectedIds.filter(id =>
nodesWithOffset.some(n => n.id === id)
selectedNodes={selectionManager.selectedIds.filter((id) =>
nodesWithOffset.some((n) => n.id === id)
)}
allNodes={nodesWithOffset as (Node & { type: string })[]}
onDeleteSelected={() => onDeleteMultiple?.(resolvedModuleIds)}
@@ -1061,7 +1016,12 @@
{@render leftHeader()}
</div>
{:else}
<Controls position="top-right" orientation="horizontal" showLock={false}>
<Controls
position="top-right"
orientation="horizontal"
showLock={false}
fitViewOptions={{ nodes: nodes.filter((n) => n.type !== 'note') }}
>
{#if multiSelectEnabled}
<div class="flex items-center gap-2">
<Tooltip>
@@ -0,0 +1,555 @@
import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
import { NODE } from './util'
type LayoutNode = {
id: string
parentIds?: string[]
}
type LayoutConstants = {
nodeWidth: number
nodeHeight: number
gapH: number
gapV: number
}
type CompoundGroup = {
type: 'branch' | 'loop'
headId: string
endId: string
branches: {
labelId: string
innerIds: string[]
}[]
}
type LayoutResult = {
positions: Map<string, { x: number; y: number }>
bbox: { width: number; height: number }
contentMinX: number
}
const LOOP_INDENT = 25
/**
* Detect compound groups from a flat list of node IDs.
* Uses ID naming conventions from graphBuilder:
* - BranchAll/BranchOne: node X has children X-branch-N and X-end
* - ForLoop/WhileLoop: node X has child X-start and X-end
*/
function detectGroups(
nodeIds: Set<string>,
allNodes: Map<string, LayoutNode>,
childrenMap: Map<string, string[]>
): CompoundGroup[] {
const groups: CompoundGroup[] = []
for (const id of nodeIds) {
if (!id.endsWith('-end')) continue
// Extract base ID (everything before -end)
const baseId = id.slice(0, -4)
if (!nodeIds.has(baseId)) continue
const baseNode = allNodes.get(baseId)
if (!baseNode) continue
// Check for branch pattern: probe for baseId-branch-N nodes directly
const branchLabelIds: string[] = []
if (nodeIds.has(`${baseId}-branch-default`)) {
branchLabelIds.push(`${baseId}-branch-default`)
}
for (let i = 0; nodeIds.has(`${baseId}-branch-${i}`); i++) {
branchLabelIds.push(`${baseId}-branch-${i}`)
}
// Check for loop pattern: baseId-start node
const hasStart = nodeIds.has(`${baseId}-start`)
if (branchLabelIds.length > 0) {
// Branches are already in correct order: default first, then 0, 1, 2...
const branches = branchLabelIds.map((labelId) => ({
labelId,
innerIds: findInnerIds(labelId, id, nodeIds, childrenMap)
}))
groups.push({ type: 'branch', headId: baseId, endId: id, branches })
} else if (hasStart) {
const innerIds = findInnerIds(`${baseId}-start`, id, nodeIds, childrenMap)
groups.push({
type: 'loop',
headId: baseId,
endId: id,
branches: [{ labelId: `${baseId}-start`, innerIds }]
})
}
}
return groups
}
/**
* Find inner node IDs between a label/start node and an end node.
* These are nodes that are reachable from the label node but not including
* the label or end node themselves.
*/
function findInnerIds(
labelId: string,
endId: string,
nodeIds: Set<string>,
childrenMap: Map<string, string[]>
): string[] {
const inner: string[] = []
const visited = new Set<string>()
// BFS from label to find all reachable nodes before end
const queue = [labelId]
visited.add(labelId)
visited.add(endId) // Don't traverse past end
let qi = 0
while (qi < queue.length) {
const current = queue[qi++]
const kids = childrenMap.get(current) ?? []
for (const kid of kids) {
if (visited.has(kid)) continue
if (!nodeIds.has(kid)) continue
visited.add(kid)
inner.push(kid)
queue.push(kid)
}
}
return inner
}
/**
* Run sugiyama layout on a set of nodes with parent relationships.
* Returns x,y positions for each node, centered at x=0.
*/
function runSugiyama(
nodes: { id: string; parentIds?: string[] }[],
constants: LayoutConstants,
nodeSizes?: Map<string, { width: number; height: number }>
): { positions: Map<string, { x: number; y: number }>; width: number; height: number } {
if (nodes.length === 0) {
return { positions: new Map(), width: 0, height: 0 }
}
if (nodes.length === 1) {
const pos = new Map<string, { x: number; y: number }>()
pos.set(nodes[0].id, { x: 0, y: 0 })
const w = nodeSizes?.get(nodes[0].id)?.width ?? constants.nodeWidth
const h = nodeSizes?.get(nodes[0].id)?.height ?? constants.nodeHeight
return { positions: pos, width: w, height: h }
}
const nodeIdSet = new Set(nodes.map((n) => n.id))
const dagNodes = nodes.map((n) => ({
id: n.id,
parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid))
}))
const dag = dagStratify().id(({ id }: { id: string }) => id)(dagNodes)
let boxSize: { width: number; height: number }
try {
const layout = sugiyama()
.decross(nodes.length > 20 ? decrossTwoLayer() : decrossOpt())
.coord(coordCenter())
.nodeSize((d: any) => {
const nodeId = d?.data?.id ?? ''
const size = nodeSizes?.get(nodeId)
const w = size?.width ?? constants.nodeWidth
const h = size?.height ?? constants.nodeHeight
return [w + constants.gapH, h + constants.gapV] as readonly [number, number]
})
boxSize = layout(dag as any) as any
} catch (e) {
console.debug('[compoundLayout] decrossOpt failed, falling back to decrossTwoLayer:', e)
const layout = sugiyama()
.decross(decrossTwoLayer())
.coord(coordCenter())
.nodeSize((d: any) => {
const nodeId = d?.data?.id ?? ''
const size = nodeSizes?.get(nodeId)
const h = size?.height ?? constants.nodeHeight
return [constants.nodeWidth + constants.gapH, h + constants.gapV]
})
boxSize = layout(dag as any) as any
}
const positions = new Map<string, { x: number; y: number }>()
for (const desc of dag.descendants()) {
const nodeId = desc.data.id
// sugiyama returns CENTER positions; convert to TOP by subtracting half the node's allocated height
const h = nodeSizes?.get(nodeId)?.height ?? constants.nodeHeight
const rawY = (desc as any).y ?? 0
positions.set(nodeId, {
x: (desc as any).x ?? 0,
y: rawY - (h + constants.gapV) / 2
})
}
// Normalize y so minimum = 0
let minY = Infinity
for (const pos of positions.values()) {
minY = Math.min(minY, pos.y)
}
if (minY !== Infinity && minY !== 0) {
for (const pos of positions.values()) {
pos.y -= minY
}
}
// Normalize x so center of bbox = 0 (important for nested branch placement)
let minX = Infinity
let maxX = -Infinity
for (const pos of positions.values()) {
minX = Math.min(minX, pos.x)
maxX = Math.max(maxX, pos.x)
}
if (minX !== Infinity) {
const centerX = (minX + maxX) / 2
for (const pos of positions.values()) {
pos.x -= centerX
}
}
return { positions, width: boxSize.width, height: boxSize.height }
}
/**
* Recursive compound layout.
*
* 1. Detect compound groups at this level
* 2. For each group, recursively lay out each branch
* 3. Compute wrapper bbox for each group
* 4. Replace group nodes with a single wrapper pseudo-node
* 5. Run sugiyama on the simplified graph
* 6. Expand wrapper positions back to absolute positions
*/
const MAX_RECURSION_DEPTH = 50
function layoutLevel(
nodeIds: string[],
allNodes: Map<string, LayoutNode>,
constants: LayoutConstants,
childrenMap: Map<string, string[]>,
depth: number = 0
): LayoutResult {
const positions = new Map<string, { x: number; y: number }>()
const nodeIdSet = new Set(nodeIds)
if (nodeIds.length === 0) {
return {
positions,
bbox: { width: constants.nodeWidth, height: 0 },
contentMinX: 0
}
}
if (depth >= MAX_RECURSION_DEPTH) {
console.warn('[compoundLayout] Max recursion depth reached, falling back to flat layout')
const flatNodes = nodeIds.map((id) => {
const n = allNodes.get(id)!
return { id, parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid)) }
})
const result = runSugiyama(flatNodes, constants)
for (const [id, pos] of result.positions) {
positions.set(id, pos)
}
return { positions, bbox: { width: result.width, height: result.height }, contentMinX: 0 }
}
// Step 1: detect compound groups at this level
const groups = detectGroups(nodeIdSet, allNodes, childrenMap)
// First pass: quick set of ALL group-owned inner IDs (just for filtering nested heads)
const allGroupOwnedIds = new Set<string>()
for (const group of groups) {
if (!nodeIdSet.has(group.headId)) continue
for (const branch of group.branches) {
for (const innerId of branch.innerIds) {
allGroupOwnedIds.add(innerId)
}
}
}
// Filter to top-level groups (head not owned by another group)
const topLevelGroups = groups.filter(
(g) => nodeIdSet.has(g.headId) && !allGroupOwnedIds.has(g.headId)
)
// Build final groupOwnedIds and groupByHeadId from top-level only
const groupOwnedIds = new Set<string>()
const groupByHeadId = new Map<string, CompoundGroup>()
for (const group of topLevelGroups) {
groupByHeadId.set(group.headId, group)
groupOwnedIds.add(group.endId)
for (const branch of group.branches) {
groupOwnedIds.add(branch.labelId)
for (const innerId of branch.innerIds) {
groupOwnedIds.add(innerId)
}
}
}
// Step 2-3: Recursively lay out each group and compute wrapper sizes
type GroupLayout = {
group: CompoundGroup
branchLayouts: {
labelId: string
result: LayoutResult
bbox: { width: number; height: number }
}[]
branchWidths: number[]
totalWidth: number
wrapperWidth: number
wrapperHeight: number
maxBranchHeight: number
}
const groupLayouts = new Map<string, GroupLayout>()
const wrapperSizes = new Map<string, { width: number; height: number }>()
for (const group of topLevelGroups) {
const branchLayouts: GroupLayout['branchLayouts'] = []
const isBranch = group.type === 'branch'
for (const branch of group.branches) {
const branchNodeIds = [branch.labelId, ...branch.innerIds]
// Find sub-groups within this branch
const result = layoutLevel(branchNodeIds, allNodes, constants, childrenMap, depth + 1)
branchLayouts.push({
labelId: branch.labelId,
result,
bbox: result.bbox
})
}
// Compute wrapper dimensions
let wrapperWidth: number
let wrapperHeight: number
let branchWidths: number[] = []
let totalWidth = 0
let maxBranchHeight = 0
const rowHeight = constants.nodeHeight + constants.gapV
if (isBranch) {
// Place branches side by side horizontally
branchWidths = branchLayouts.map((bl) => Math.max(bl.bbox.width, constants.nodeWidth))
const gaps = Math.max(0, branchWidths.length - 1) * constants.gapH
totalWidth = branchWidths.reduce((s, w) => s + w, 0) + gaps
wrapperWidth = Math.max(totalWidth, constants.nodeWidth)
maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height))
// head row + branch content + end row
wrapperHeight = rowHeight + maxBranchHeight + rowHeight
} else {
// Loop: body is indented
const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth
const bodyHeight = branchLayouts[0]?.bbox.height ?? 0
wrapperWidth = Math.max(bodyWidth + LOOP_INDENT * 2, constants.nodeWidth)
// head row + start row + body + end row
wrapperHeight = rowHeight + bodyHeight + rowHeight
}
groupLayouts.set(group.headId, {
group,
branchLayouts,
branchWidths,
totalWidth,
wrapperWidth,
wrapperHeight,
maxBranchHeight
})
wrapperSizes.set(group.headId, { width: wrapperWidth, height: wrapperHeight })
}
// Step 4: Build flattened node list for sugiyama
// Merge into a single pass: create flatNode and compute final parentIds with end→head redirection
const endToHead = new Map<string, string>()
for (const group of topLevelGroups) {
endToHead.set(group.endId, group.headId)
}
const flatNodes: { id: string; parentIds?: string[] }[] = []
for (const nid of nodeIds) {
if (groupOwnedIds.has(nid)) continue
const originalNode = allNodes.get(nid)!
const seen = new Set<string>()
const newParents: string[] = []
for (const pid of originalNode.parentIds ?? []) {
if (!nodeIdSet.has(pid)) continue
const resolved = endToHead.get(pid) ?? (groupOwnedIds.has(pid) ? undefined : pid)
if (resolved && !seen.has(resolved)) {
seen.add(resolved)
newParents.push(resolved)
}
}
flatNodes.push({ id: nid, parentIds: newParents })
}
// Step 5: Run sugiyama on flattened nodes
const sugResult = runSugiyama(flatNodes, constants, wrapperSizes)
// Step 6: Resolve absolute positions
// First, set positions for regular (non-group) nodes
for (const [nid, pos] of sugResult.positions) {
if (groupByHeadId.has(nid)) continue // Handle groups separately
positions.set(nid, { x: pos.x, y: pos.y })
}
// Now expand group wrappers into absolute positions
for (const [headId, gl] of groupLayouts) {
const wrapperPos = sugResult.positions.get(headId)
if (!wrapperPos) continue
const rowHeight = constants.nodeHeight + constants.gapV
const isBranch = gl.group.type === 'branch'
// Position the head node at the top-center of the wrapper
positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y })
if (isBranch) {
// Reuse cached branchWidths and totalWidth
let currentX = wrapperPos.x - gl.totalWidth / 2
for (let bi = 0; bi < gl.branchLayouts.length; bi++) {
const bl = gl.branchLayouts[bi]
const bw = gl.branchWidths[bi]
const branchCenterX = currentX + bw / 2
// Offset all branch positions relative to the branch center
for (const [innerNodeId, innerPos] of bl.result.positions) {
positions.set(innerNodeId, {
x: branchCenterX + innerPos.x,
y: wrapperPos.y + rowHeight + innerPos.y
})
}
currentX += bw + constants.gapH
}
// Position end node below all branches
const maxBranchHeight = gl.maxBranchHeight
positions.set(gl.group.endId, {
x: wrapperPos.x,
y: wrapperPos.y + rowHeight + maxBranchHeight + constants.gapV
})
} else {
// Loop: position start, body, and end
const bl = gl.branchLayouts[0]
if (bl) {
// Position body nodes with indent
for (const [innerNodeId, innerPos] of bl.result.positions) {
positions.set(innerNodeId, {
x: wrapperPos.x + LOOP_INDENT + innerPos.x,
y: wrapperPos.y + rowHeight + innerPos.y
})
}
}
// Position end node below body
const bodyHeight = bl?.bbox.height ?? 0
positions.set(gl.group.endId, {
x: wrapperPos.x,
y: wrapperPos.y + rowHeight + bodyHeight + constants.gapV
})
}
}
// Compute overall bbox (nodes + group wrapper extents)
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
for (const pos of positions.values()) {
minX = Math.min(minX, pos.x - constants.nodeWidth / 2)
maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2)
minY = Math.min(minY, pos.y)
maxY = Math.max(maxY, pos.y + constants.nodeHeight)
}
// Account for group wrapper extents in bbox (e.g. LOOP_INDENT makes wrappers wider than nodes)
for (const [headId, gl] of groupLayouts) {
const pos = sugResult.positions.get(headId)
if (!pos) continue
minX = Math.min(minX, pos.x - gl.wrapperWidth / 2)
maxX = Math.max(maxX, pos.x + gl.wrapperWidth / 2)
maxY = Math.max(maxY, pos.y + gl.wrapperHeight)
}
const contentMinX = minX === Infinity ? 0 : minX
const bboxWidth = maxX - minX
const bboxHeight = maxY - (positions.size > 0 ? minY : 0)
const finalBbox = {
width: Math.max(bboxWidth, constants.nodeWidth),
height: Math.max(bboxHeight, 0)
}
return { positions, bbox: finalBbox, contentMinX }
}
/**
* Main entry point for compound layout.
*
* Takes the flat list of nodes and edges from graphBuilder and produces
* absolute positions that account for compound structure (branches, loops).
*/
export function compoundLayout(
nodes: { id: string; parentIds?: string[] }[],
constants?: Partial<LayoutConstants>
): LayoutResult {
const c: LayoutConstants = {
nodeWidth: constants?.nodeWidth ?? NODE.width,
nodeHeight: constants?.nodeHeight ?? NODE.height,
gapH: constants?.gapH ?? NODE.gap.horizontal,
gapV: constants?.gapV ?? NODE.gap.vertical
}
// Build node map
const allNodes = new Map<string, LayoutNode>()
for (const n of nodes) {
allNodes.set(n.id, n)
}
// Build children map once (reverse of parentIds), shared across all recursion levels
const childrenMap = new Map<string, string[]>()
for (const [nid, node] of allNodes) {
for (const pid of node.parentIds ?? []) {
if (!childrenMap.has(pid)) childrenMap.set(pid, [])
childrenMap.get(pid)!.push(nid)
}
}
const nodeIds = nodes.map((n) => n.id)
const result = layoutLevel(nodeIds, allNodes, c, childrenMap)
// Shift positions so minX=0 (left-aligned).
// FlowGraphV2 centers with: xCenter = viewport/2 - bbox.width/2
// which assumes positions start at x=0.
if (result.positions.size > 0) {
const minX = result.contentMinX
if (minX !== 0 && minX !== Infinity) {
for (const pos of result.positions.values()) {
pos.x -= minX
}
}
}
// Check for missing nodes
const missing = nodes.filter((n) => !result.positions.has(n.id))
if (missing.length > 0) {
console.warn(
'[compoundLayout] MISSING positions for:',
missing.map((n) => n.id)
)
}
return result
}
@@ -88,9 +88,7 @@ export function buildPrefix(prefix: string | undefined, id: string): string {
export type NodeLayout = {
id: string
parentIds?: string[]
data: {
offset?: number
}
data: {}
selectable?: boolean
} & FlowNode
@@ -138,7 +136,6 @@ export type InputN = {
export type ModuleN = {
type: 'module'
data: {
offset: number
module: FlowModule
id: string
parentIds: string[]
@@ -157,7 +154,6 @@ export type ModuleN = {
export type BranchAllStartN = {
type: 'branchAllStart'
data: {
offset: number
label: string
id: string
branchIndex: number
@@ -171,7 +167,6 @@ export type BranchAllStartN = {
export type BranchAllEndN = {
type: 'branchAllEnd'
data: {
offset: number
id: string
eventHandlers: GraphEventHandlers
flowModuleState: GraphModuleState | undefined
@@ -181,7 +176,6 @@ export type BranchAllEndN = {
export type ForLoopEndN = {
type: 'forLoopEnd'
data: {
offset: number
id: string
eventHandlers: GraphEventHandlers
simplifiedTriggerView: boolean
@@ -192,7 +186,6 @@ export type ForLoopEndN = {
export type ForLoopStartN = {
type: 'forLoopStart'
data: {
offset: number
id: string
eventHandlers: GraphEventHandlers
flowModuleState: GraphModuleState | undefined
@@ -217,22 +210,18 @@ export type ResultN = {
export type WhileLoopStartN = {
type: 'whileLoopStart'
data: {
offset: number
eventHandlers: GraphEventHandlers
}
}
export type WhileLoopEndN = {
type: 'whileLoopEnd'
data: {
offset: number
}
data: {}
}
export type BranchOneStartN = {
type: 'branchOneStart'
data: {
offset: number
id: string
eventHandlers: GraphEventHandlers
flowModuleState: GraphModuleState | undefined
@@ -248,7 +237,6 @@ export type BranchOneStartN = {
export type BranchOneEndN = {
type: 'branchOneEnd'
data: {
offset: number
id: string
eventHandlers: GraphEventHandlers
flowModuleState: GraphModuleState | undefined
@@ -258,7 +246,6 @@ export type BranchOneEndN = {
export type SubflowBoundN = {
type: 'subflowBound'
data: {
offset: number
id: string
eventHandlers: GraphEventHandlers
label: string
@@ -271,7 +258,6 @@ export type SubflowBoundN = {
export type NoBranchN = {
type: 'noBranch'
data: {
offset: number
id: string
eventHandlers: GraphEventHandlers
flowModuleState: GraphModuleState | undefined
@@ -416,7 +402,7 @@ export function graphBuilder(
const nodes: NodeLayout[] = []
const edges: Edge[] = []
function addNode(module: FlowModule, offset: number) {
function addNode(module: FlowModule) {
const duplicated = nodes.find((n) => n.id === module.id)
if (duplicated) {
console.log('Duplicated node detected: ', module, duplicated)
@@ -426,7 +412,6 @@ export function graphBuilder(
nodes.push({
id: module.id,
data: {
offset: offset,
module: module,
id: module.id,
parentIds: [],
@@ -611,7 +596,6 @@ export function graphBuilder(
nextNode: NodeLayout | undefined,
simplifiedTriggerView: boolean,
prefix: string | undefined,
currentOffset = 0,
disableMoveIds: string[] = [],
parentIndex?: string
) {
@@ -646,13 +630,12 @@ export function graphBuilder(
if (module.value.type === 'branchall') {
// Start
addNode(module, currentOffset)
addNode(module)
// "Collect result of each branch" node
const endNode: NodeLayout = {
id: `${module.id}-end`,
data: {
offset: currentOffset,
id: module.id,
eventHandlers: eventHandlers,
flowModuleState: extra.flowModuleStates?.[module.id]
@@ -667,7 +650,6 @@ export function graphBuilder(
const startNode: NodeLayout = {
id: `${module.id}-branch-0`,
data: {
offset: currentOffset,
id: module.id,
branchIndex: -1,
eventHandlers: eventHandlers,
@@ -693,7 +675,6 @@ export function graphBuilder(
const startNode: NodeLayout = {
id: `${module.id}-branch-${branchIndex}`,
data: {
offset: currentOffset,
label: defaultIfEmptyString(branch.summary, `Branch ${branchIndex + 1}`),
id: module.id,
branchIndex: branchIndex,
@@ -724,7 +705,6 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset,
localDisableMoveIds,
parentIndex ? `${parentIndex}-${index}-${branchIndex}` : `${index}-${branchIndex}`
)
@@ -734,13 +714,12 @@ export function graphBuilder(
previousId = endNode.id
} else if (module.value.type === 'forloopflow') {
if (!simplifiedTriggerView) {
addNode(module, currentOffset)
addNode(module)
}
const startNode: NodeLayout = {
id: `${module.id}-start`,
data: {
offset: currentOffset + 25,
id: module.id,
module: module,
simplifiedTriggerView,
@@ -765,7 +744,6 @@ export function graphBuilder(
const endNode: NodeLayout = {
id: `${module.id}-end`,
data: {
offset: currentOffset,
id: module.id,
eventHandlers: eventHandlers,
simplifiedTriggerView,
@@ -786,7 +764,6 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset + 25,
localDisableMoveIds,
parentIndex
? `${parentIndex}-${index}-${selectedIterIndex ?? '?'}`
@@ -795,12 +772,11 @@ export function graphBuilder(
previousId = endNode.id
} else if (module.value.type === 'whileloopflow') {
addNode(module, currentOffset)
addNode(module)
const startNode: NodeLayout = {
id: `${module.id}-start`,
data: {
offset: currentOffset + 25,
eventHandlers: eventHandlers
},
type: 'whileLoopStart'
@@ -811,7 +787,7 @@ export function graphBuilder(
const endNode: NodeLayout = {
id: `${module.id}-end`,
data: { offset: currentOffset, ...extra },
data: { ...extra },
type: 'whileLoopEnd'
}
@@ -827,7 +803,6 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset + 25,
localDisableMoveIds,
parentIndex
? `${parentIndex}-${index}-${selectedIterIndex ?? '?'}`
@@ -836,12 +811,11 @@ export function graphBuilder(
previousId = endNode.id
} else if (module.value.type === 'branchone') {
addNode(module, currentOffset)
addNode(module)
const endNode: NodeLayout = {
id: `${module.id}-end`,
data: {
offset: currentOffset,
eventHandlers: eventHandlers,
flowModuleState: extra.flowModuleStates?.[module.id],
id: module.id
@@ -854,7 +828,7 @@ export function graphBuilder(
// const defaultBranch: NodeLayout = {
// id: `${module.id}-default`,
// data: {
// offset: currentOffset,
// offset: 0,
// label: 'Default',
// id: module.id,
// branchIndex: -1,
@@ -868,7 +842,6 @@ export function graphBuilder(
const defaultBranch: NodeLayout = {
id: `${module.id}-branch-default`,
data: {
offset: currentOffset,
label: 'Default',
id: module.id,
branchIndex: -1,
@@ -895,7 +868,6 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset,
localDisableMoveIds,
parentIndex ? `${parentIndex}-${index}` : index.toString()
)
@@ -906,7 +878,6 @@ export function graphBuilder(
const startNode: NodeLayout = {
id: `${module.id}-branch-${branchIndex}`,
data: {
offset: currentOffset,
label: defaultIfEmptyString(branch.summary, 'Branch ' + (branchIndex + 1)),
preLabel: branch.summary ? '' : branch.expr,
id: module.id,
@@ -933,7 +904,6 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset,
localDisableMoveIds,
parentIndex ? `${parentIndex}-${index}` : index.toString()
)
@@ -951,7 +921,6 @@ export function graphBuilder(
const startNode: NodeLayout = {
id: startId,
data: {
offset: currentOffset,
label: `Start of subflow ${idWithoutPrefix}`,
id: startId,
subflowId: module.id,
@@ -980,7 +949,6 @@ export function graphBuilder(
const endNode: NodeLayout = {
id: endId,
data: {
offset: currentOffset,
label: `End of subflow ${idWithoutPrefix}`,
id: endId,
subflowId: module.id,
@@ -1000,13 +968,12 @@ export function graphBuilder(
endNode,
false,
buildPrefix(prefix, module['oid'] ?? module.id),
currentOffset,
localDisableMoveIds
)
previousId = endNode.id
} else {
addNode(module, currentOffset)
addNode(module)
previousId = module.id
}
}
@@ -1047,19 +1014,19 @@ export function graphBuilder(
})
Object.entries(toAdd).forEach((x) => {
addNode({ ...failureModule, id: x[1] }, 0)
addNode({ ...failureModule, id: x[1] })
addEdge(x[0], x[1], undefined, undefined, { type: 'empty' })
})
}
if (preprocessorModule) {
addNode(preprocessorModule, 0)
addNode(preprocessorModule)
const id = JSON.parse(JSON.stringify(preprocessorModule.id))
addEdge(id, 'Input', undefined, undefined, { type: 'empty' })
}
if (failureModule && !extra.flowModuleStates) {
addNode(failureModule, 0)
addNode(failureModule)
}
Object.keys(parents).forEach((key) => {
@@ -219,7 +219,7 @@ export class NoteEditor {
/**
* Clean up group notes using DAG path completion
*/
cleanupGroupNotes(flowNodes: { id: string; parentIds?: string[]; offset?: number }[]): void {
cleanupGroupNotes(flowNodes: { id: string; parentIds?: string[] }[]): void {
if (!this.isAvailable()) {
return
}
@@ -14,7 +14,6 @@ export type NodeDep = {
position: { x: number; y: number }
data?: { assets?: AssetWithAltAccessType[] }
parentIds?: string[]
offset?: number
type?: string
}
@@ -207,7 +206,6 @@ function calculateGroupNoteLayout(
nodes.map((n) => ({
id: n.id,
position: n.position,
data: { offset: n.offset ?? 0 },
type: n.type ?? ''
}))
)
@@ -337,7 +335,6 @@ export function computeNoteNodes(
return {
...n,
data: origNode?.data,
offset: origNode?.offset,
type: origNode?.type
}
})
@@ -175,7 +175,7 @@
? inputToolWidth + inputToolXGap
: isLastRow && tools.length % 2 === 1
? (ROW_WIDTH - inputToolWidth) / 2
: 0) + node.data.offset,
: 0),
y:
baseOffset +
rowOffset *
@@ -207,7 +207,7 @@
parentId: node.id,
width: NEW_TOOL_NODE_WIDTH,
position: {
x: (ROW_WIDTH - NEW_TOOL_NODE_WIDTH) / 2 + node.data.offset,
x: (ROW_WIDTH - NEW_TOOL_NODE_WIDTH) / 2,
y: baseOffset + rowOffset
},
selectable: false
@@ -11,7 +11,7 @@
let computeAssetNodesCache: [NodeDep[], ReturnType<typeof computeAssetNodes>] | undefined
type NodeDep = {
data: object & { assets?: AssetWithAltAccessType[] | undefined; offset?: number }
data: object & { assets?: AssetWithAltAccessType[] | undefined }
id: string
position: { x: number; y: number }
}
@@ -78,14 +78,13 @@
width: inputAssetWidth,
position: {
x:
(node.data.offset ?? 0) +
(displayedInputAssets.length === 1
displayedInputAssets.length === 1
? (NODE.width - inputAssetWidth) / 2 - 10 // Ensure we see the edge
: (inputAssetWidth + inputAssetXGap) * (i - displayedInputAssets.length / 2) +
(NODE.width + inputAssetXGap) / 2 +
(overflowedInputAssets.length
? (-ASSETS_OVERFLOWED_NODE_WIDTH - inputAssetXGap) / 2
: 0)),
: 0),
y: READ_ASSET_Y_OFFSET
},
selectable: false
@@ -116,14 +115,13 @@
width: outputAssetWidth,
position: {
x:
(node.data.offset ?? 0) +
(displayedOutputAssets.length === 1
displayedOutputAssets.length === 1
? (NODE.width - outputAssetWidth) / 2 - 10 // Ensure we see the edge
: (outputAssetWidth + outputAssetXGap) * (i - displayedOutputAssets.length / 2) +
(NODE.width + outputAssetXGap) / 2 +
(overflowedOutputAssets.length
? (-ASSETS_OVERFLOWED_NODE_WIDTH - outputAssetXGap) / 2
: 0)),
: 0),
y: WRITE_ASSET_Y_OFFSET
},
selectable: false
@@ -157,7 +155,7 @@
parentId: node.id,
width: ASSETS_OVERFLOWED_NODE_WIDTH,
position: {
x: (node.data.offset ?? 0) + MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
y: READ_ASSET_Y_OFFSET
}
} satisfies Node & AssetsOverflowedN)
@@ -176,7 +174,7 @@
parentId: node.id,
width: ASSETS_OVERFLOWED_NODE_WIDTH,
position: {
x: (node.data.offset ?? 0) + MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
y: WRITE_ASSET_Y_OFFSET
}
} satisfies Node & AssetsOverflowedN)
@@ -13,7 +13,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle nodeId={id}>
<NodeWrapper enableSourceHandle enableTargetHandle nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={'Collect result from all branches'}
@@ -16,7 +16,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset} nodeId={id}>
<NodeWrapper nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.label}
@@ -15,7 +15,7 @@
let { data, id }: Props = $props()
</script>
<NodeWrapper offset={data.offset} nodeId={id}>
<NodeWrapper nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.label}
@@ -13,7 +13,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset} nodeId={id}>
<NodeWrapper nodeId={id}>
{#snippet children({ darkMode })}
{#if data.simplifiedTriggerView}
<VirtualItem
@@ -56,7 +56,7 @@
let filteredInput = $derived(filterIterFromInput($pickablePropertiesFiltered?.flow_input))
</script>
<NodeWrapper offset={data.offset} nodeId={id}>
<NodeWrapper nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.simplifiedTriggerView ? 'For each new event' : 'Do one iteration'}
@@ -89,7 +89,7 @@
)
</script>
<NodeWrapper offset={data.offset} {menuItems}>
<NodeWrapper {menuItems}>
{#snippet children({ darkMode })}
<MapItem
moduleId={data.id}
@@ -99,7 +99,7 @@
moduleAction={data.moduleAction}
{menuItems}
annotation={flowJobs &&
(data.module.value.type === 'forloopflow' || data.module.value.type === 'whileloopflow')
(data.module?.value?.type === 'forloopflow' || data.module?.value?.type === 'whileloopflow')
? 'Iteration: ' +
((state?.selectedForloopIndex ?? 0) >= 0
? (state?.selectedForloopIndex ?? 0) + 1
@@ -136,15 +136,18 @@
onEditInput={data.eventHandlers.editInput}
flowJob={data.flowJob}
isOwner={data.isOwner}
maximizeSubflow={data.module.value.type == 'flow' && 'path' in data.module.value
maximizeSubflow={data.module?.value?.type == 'flow' && 'path' in data.module.value
? () => {
data.eventHandlers.expandSubflow(data.id, data.module.value['path'])
const path = data.module?.value && 'path' in data.module.value ? data.module.value['path'] as string : undefined
if (path) {
data.eventHandlers.expandSubflow(data.id, path)
}
}
: undefined}
/>
<div class="absolute -bottom-10 left-1/2 transform -translate-x-1/2 z-10">
{#if (data.module.value.type === 'branchall' || data.module.value.type === 'branchone') && data.insertable}
{#if (data.module?.value?.type === 'branchall' || data.module?.value?.type === 'branchone') && data.insertable}
<button
title="Add branch"
class="rounded text-secondary border hover:bg-surface-hover bg-surface p-1"
@@ -10,7 +10,7 @@
let { data, id }: Props = $props()
</script>
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle nodeId={id}>
<NodeWrapper enableSourceHandle enableTargetHandle nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.label ?? 'No branches'}
@@ -9,7 +9,6 @@
interface Props {
enableSourceHandle?: boolean
enableTargetHandle?: boolean
offset?: number
wrapperClass?: string
contextMenuItems?: ContextMenuItem[]
menuItems?: Item[]
@@ -21,7 +20,6 @@
let {
enableSourceHandle = true,
enableTargetHandle = true,
offset = 0,
wrapperClass = '',
contextMenuItems = undefined,
menuItems = undefined,
@@ -58,14 +56,14 @@
{#if resolvedContextMenuItems && resolvedContextMenuItems.length > 0}
<ContextMenu items={resolvedContextMenuItems}>
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)} style={`margin-left: ${offset}px;`}>
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)}>
{@render children?.({ darkMode })}
</div>
{@render handles()}
</ContextMenu>
{:else}
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)} style={`margin-left: ${offset}px;`}>
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)}>
{@render children?.({ darkMode })}
</div>
@@ -78,7 +76,6 @@
type="source"
isConnectable={false}
position={Position.Bottom}
style={`margin-left: ${offset / 2}px;`}
/>
{/if}
@@ -87,7 +84,6 @@
type="target"
isConnectable={false}
position={Position.Top}
style={`margin-left: ${offset / 2}px;`}
/>
{/if}
{/snippet}
@@ -17,7 +17,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
<NodeWrapper>
{#snippet children({ darkMode })}
<VirtualItem
label={data.label}
@@ -13,7 +13,7 @@
let { data, id }: Props = $props()
</script>
<NodeWrapper offset={data.offset} nodeId={id}>
<NodeWrapper nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={'Collect result from chosen branch'}
+3 -10
View File
@@ -215,7 +215,7 @@ export function getNodeColorClasses(state: FlowNodeState, selected: boolean): Fl
}
/**
* Calculate the bounding box for a collection of nodes, accounting for CSS offset
* Calculate the bounding box for a collection of nodes.
* Also includes expanded subflow nodes when calculating bounds for subflow containers
* @param containedIds - Array of node IDs to calculate bounds for
* @param allNodes - Array of all nodes to search for expanded subflow nodes
@@ -226,7 +226,6 @@ export function calculateNodesBoundsWithOffset(
allNodes: Array<{
id: string
position: { x: number; y: number }
data?: { offset?: number }
type: string
}>
): {
@@ -240,14 +239,10 @@ export function calculateNodesBoundsWithOffset(
return nodesToCalculate.reduce(
(acc, node) => {
// Account for CSS offset applied by NodeWrapper
const cssOffset = node.data?.offset ?? 0
const visualX = node.position.x + cssOffset
return {
minX: Math.min(acc.minX, visualX),
minX: Math.min(acc.minX, node.position.x),
minY: Math.min(acc.minY, node.position.y),
maxX: Math.max(acc.maxX, visualX + NODE.width),
maxX: Math.max(acc.maxX, node.position.x + NODE.width),
maxY: Math.max(acc.maxY, node.position.y + NODE.height)
}
},
@@ -271,13 +266,11 @@ function getAllRelatedSubflowNodes(
allNodes: Array<{
id: string
position: { x: number; y: number }
data?: { offset?: number }
type: string
}>
): Array<{
id: string
position: { x: number; y: number }
data?: { offset?: number }
}> {
const relatedNodeIds = new Set<string>()