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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-03-03 20:24:52 +00:00
parent c97d8b4715
commit 1aede4a24c
6 changed files with 882 additions and 87 deletions

View File

@@ -20,7 +20,6 @@
import {
graphBuilder,
isTriggerStep,
topologicalSort,
type InlineScript,
type InsertKind,
type NodeLayout,
@@ -36,7 +35,7 @@
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'
// d3-dag imports now used inside compoundLayout.ts
import { Expand, MousePointer, Hand } from 'lucide-svelte'
import Toggle from '../Toggle.svelte'
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
@@ -71,6 +70,8 @@
import type { MoveManager } from './moveManager.svelte'
import DragCoordinator from './DragCoordinator.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
import { compoundLayout, type WrapperInfo } from './compoundLayout'
import DebugWrapperNode from './renderers/nodes/DebugWrapperNode.svelte'
import { deepEqual } from 'fast-equals'
import type { AssetWithAltAccessType } from '../assets/lib'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
@@ -323,6 +324,8 @@
}
type NodePos = { position: { x: number; y: number } }
let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined
let lastWrappers: WrapperInfo[] = []
let lastXCenter = 0
function layoutNodes(nodes: NodeDep[]): (NodeDep & NodePos)[] {
let lastResult = lastNodes?.[1]
@@ -339,59 +342,31 @@
seenId.push(n.id)
}
let nodeWidths: Record<string, number> = {}
const nodes2: (NodeDep & NodePos)[] = nodes.map((n) => {
return { ...n, position: { x: 0, y: 0 } }
// Build edges from parentIds
const edges = nodes.flatMap((n) =>
(n.parentIds ?? []).map((pid) => ({ source: pid, target: n.id }))
)
// Run recursive compound layout
const { positions, bbox, wrappers } = compoundLayout(nodes, edges, {
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
}
}
lastWrappers = wrappers
const dag = dagStratify().id(({ id }: NodeDep & NodePos) => id)(nodes2)
// Center horizontally
const xCenter =
(fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
lastXCenter = xCenter
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,
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
}
}))
@@ -642,8 +617,35 @@
}))
}
// Build debug wrapper nodes from compound layout
// compoundLayout positions use x as CENTER of each node.
// layoutNodes transforms: screenX = compoundX + lastXCenter - NODE.width/2
// For wrappers: screenX = w.x + lastXCenter - w.width/2
const wrapperNodes: Node[] = lastWrappers.map((w) => {
return {
id: w.id,
type: 'debugWrapper',
position: {
x: w.x + lastXCenter - w.width / 2,
y: w.y
},
data: {
headId: w.headId,
type: w.type,
level: w.level,
label: w.label,
wrapperWidth: w.width,
wrapperHeight: w.height
},
selectable: false,
draggable: false,
zIndex: w.level === 'group' ? -10 : -5,
style: `width: ${w.width}px; height: ${w.height}px;`
} satisfies Node
})
// update nodes
nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])]
nodes = [...finalNodes, ...wrapperNodes, ...(noteNodesResult?.noteNodes ?? [])]
edges = [
...(assetNodesResult?.newAssetEdges ?? []),
@@ -689,7 +691,8 @@
assetsOverflowed: AssetsOverflowedNode,
aiTool: AiToolNode,
newAiTool: NewAiToolNode,
note: NoteNode
note: NoteNode,
debugWrapper: DebugWrapperNode
} as any
const edgeTypes = {

View File

@@ -0,0 +1,738 @@
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: 'branchall' | 'branchone' | 'forloop' | 'whileloop'
headId: string
endId: string
branches: {
labelId: string
innerIds: string[]
}[]
}
export type WrapperInfo = {
id: string
headId: string
type: CompoundGroup['type']
level: 'group' | 'branch' | 'loop-body'
label: string
x: number
y: number
width: number
height: number
}
type LayoutResult = {
positions: Map<string, { x: number; y: number }>
bbox: { width: number; height: number }
wrappers: WrapperInfo[]
}
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>): 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: baseId-branch-N nodes
const branchLabelIds: string[] = []
for (const nid of nodeIds) {
const branchMatch = nid.match(new RegExp(`^${escapeRegExp(baseId)}-branch-(\\d+|default)$`))
if (branchMatch) {
branchLabelIds.push(nid)
}
}
// Check for loop pattern: baseId-start node
const hasStart = nodeIds.has(`${baseId}-start`)
if (branchLabelIds.length > 0) {
// This is a branch group (branchall or branchone)
const isBranchOne = branchLabelIds.some((lid) => lid.endsWith('-branch-default'))
const type = isBranchOne ? 'branchone' : 'branchall'
// Sort branch labels: default first for branchone, then numeric
branchLabelIds.sort((a, b) => {
if (a.endsWith('-default')) return -1
if (b.endsWith('-default')) return 1
const aNum = parseInt(a.split('-branch-').pop()!)
const bNum = parseInt(b.split('-branch-').pop()!)
return aNum - bNum
})
const branches = branchLabelIds.map((labelId) => ({
labelId,
innerIds: findInnerIds(labelId, id, nodeIds, allNodes)
}))
groups.push({ type, headId: baseId, endId: id, branches })
} else if (hasStart) {
// Both forloop and whileloop have identical structure for layout purposes
const type = determineLoopType(baseId, allNodes)
const innerIds = findInnerIds(`${baseId}-start`, id, nodeIds, allNodes)
groups.push({
type,
headId: baseId,
endId: id,
branches: [{ labelId: `${baseId}-start`, innerIds }]
})
}
}
return groups
}
/**
* Determine if a loop is a forloop or whileloop.
* We look at children of the start node - whileloop start nodes have type 'whileLoopStart'.
* Since we don't have type info in LayoutNode, we check the ID patterns.
* Both are laid out identically, so this is mainly for documentation.
*/
function determineLoopType(
_baseId: string,
_allNodes: Map<string, LayoutNode>
): 'forloop' | 'whileloop' {
// Both loop types have identical structure for layout purposes
// The distinction doesn't affect layout, so we default to 'forloop'
return 'forloop'
}
/**
* 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>,
allNodes: Map<string, LayoutNode>
): string[] {
const inner: string[] = []
const visited = new Set<string>()
// Build children map (reverse of parentIds)
const children = new Map<string, string[]>()
for (const [nid, node] of allNodes) {
if (!nodeIds.has(nid)) continue
for (const pid of node.parentIds ?? []) {
if (!nodeIds.has(pid)) continue
if (!children.has(pid)) children.set(pid, [])
children.get(pid)!.push(nid)
}
}
// BFS from label to find all reachable nodes before end
const queue = [labelId]
visited.add(labelId)
visited.add(endId) // Don't traverse past end
while (queue.length > 0) {
const current = queue.shift()!
const kids = children.get(current) ?? []
for (const kid of kids) {
if (visited.has(kid)) continue
visited.add(kid)
inner.push(kid)
queue.push(kid)
}
}
return inner
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* 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 dagNodes = nodes.map((n) => ({
id: n.id,
parentIds: (n.parentIds ?? []).filter((pid) => nodes.some((nn) => nn.id === 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 {
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
*/
function layoutLevel(
nodeIds: string[],
allNodes: Map<string, LayoutNode>,
constants: LayoutConstants,
depth = 0
): LayoutResult {
const prefix = ' '.repeat(depth)
console.log(`${prefix}[layoutLevel] depth=${depth} nodeIds=`, nodeIds)
const positions = new Map<string, { x: number; y: number }>()
const nodeIdSet = new Set(nodeIds)
if (nodeIds.length === 0) {
console.log(`${prefix}[layoutLevel] empty → returning`)
return { positions, bbox: { width: constants.nodeWidth, height: 0 }, wrappers: [] }
}
// Step 1: detect compound groups at this level
const groups = detectGroups(nodeIdSet, allNodes)
console.log(
`${prefix}[layoutLevel] detected groups:`,
groups.map((g) => ({
type: g.type,
headId: g.headId,
endId: g.endId,
branches: g.branches.map((b) => ({ labelId: b.labelId, innerIds: b.innerIds }))
}))
)
// Build a set of all IDs that belong to groups (to exclude from top-level layout)
const groupOwnedIds = new Set<string>()
const groupByHeadId = new Map<string, CompoundGroup>()
for (const group of groups) {
// Only process groups whose head is at this level (not nested)
if (!nodeIdSet.has(group.headId)) continue
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)
}
}
}
// Filter to only top-level groups (head is at this level, not owned by another group)
const topLevelGroups = groups.filter(
(g) => nodeIdSet.has(g.headId) && !groupOwnedIds.has(g.headId)
)
// Rebuild groupOwnedIds for only top-level groups
groupOwnedIds.clear()
groupByHeadId.clear()
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)
}
}
}
console.log(
`${prefix}[layoutLevel] topLevelGroups:`,
topLevelGroups.map((g) => g.headId)
)
console.log(`${prefix}[layoutLevel] groupOwnedIds:`, [...groupOwnedIds])
// 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 }
}[]
wrapperWidth: number
wrapperHeight: 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 === 'branchall' || group.type === 'branchone'
for (const branch of group.branches) {
const branchNodeIds = [branch.labelId, ...branch.innerIds]
console.log(
`${prefix} [group ${group.headId}] laying out branch labelId=${branch.labelId} nodes=`,
branchNodeIds
)
// Find sub-groups within this branch
const result = layoutLevel(branchNodeIds, allNodes, constants, depth + 1)
console.log(
`${prefix} [group ${group.headId}] branch result bbox=`,
result.bbox,
'positions=',
Object.fromEntries(result.positions)
)
branchLayouts.push({
labelId: branch.labelId,
result,
bbox: result.bbox
})
}
// Compute wrapper dimensions
let wrapperWidth: number
let wrapperHeight: number
const rowHeight = constants.nodeHeight + constants.gapV
if (isBranch) {
// Place branches side by side horizontally
const totalBranchWidth = branchLayouts.reduce(
(sum, bl) => sum + Math.max(bl.bbox.width, constants.nodeWidth),
0
)
const gaps = Math.max(0, branchLayouts.length - 1) * constants.gapH
wrapperWidth = Math.max(totalBranchWidth + gaps, constants.nodeWidth)
const maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height))
// head row + branch content + end row
wrapperHeight = rowHeight + maxBranchHeight + rowHeight
console.log(
`${prefix} [group ${group.headId}] BRANCH wrapper: totalBranchWidth=${totalBranchWidth} gaps=${gaps} maxBranchHeight=${maxBranchHeight}${wrapperWidth}x${wrapperHeight}`
)
} 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, constants.nodeWidth)
// head row + start row + body + end row
wrapperHeight = rowHeight + bodyHeight + rowHeight
console.log(
`${prefix} [group ${group.headId}] LOOP wrapper: bodyWidth=${bodyWidth} bodyHeight=${bodyHeight} rowHeight=${rowHeight}${wrapperWidth}x${wrapperHeight}`
)
}
groupLayouts.set(group.headId, {
group,
branchLayouts,
wrapperWidth,
wrapperHeight
})
wrapperSizes.set(group.headId, { width: wrapperWidth, height: wrapperHeight })
}
// Step 4: Build flattened node list for sugiyama
// Regular nodes + wrapper pseudo-nodes (replacing groups)
const flatNodes: { id: string; parentIds?: string[] }[] = []
for (const nid of nodeIds) {
if (groupOwnedIds.has(nid)) continue // Skip group internals
if (groupByHeadId.has(nid)) {
// This is a group head — acts as the wrapper node
const headNode = allNodes.get(nid)!
flatNodes.push({
id: nid,
parentIds: (headNode.parentIds ?? []).filter(
(pid) => nodeIdSet.has(pid) && !groupOwnedIds.has(pid)
)
})
} else {
// Regular node
const node = allNodes.get(nid)!
flatNodes.push({
id: nid,
parentIds: (node.parentIds ?? []).filter((pid) => {
if (!nodeIdSet.has(pid)) return false
if (groupOwnedIds.has(pid)) {
// This node's parent is inside a group — it should connect to the group head instead
return false
}
return true
})
})
}
}
// Fix parent references: if a node's parent is a group end node, redirect to the group head
const endToHead = new Map<string, string>()
for (const group of topLevelGroups) {
endToHead.set(group.endId, group.headId)
}
for (const fn of flatNodes) {
if (!fn.parentIds) continue
// Check original parents before filtering
const originalNode = allNodes.get(fn.id)!
const newParents: string[] = []
for (const pid of originalNode.parentIds ?? []) {
if (!nodeIdSet.has(pid)) continue
if (endToHead.has(pid)) {
const headId = endToHead.get(pid)!
if (!newParents.includes(headId)) newParents.push(headId)
} else if (!groupOwnedIds.has(pid)) {
if (!newParents.includes(pid)) newParents.push(pid)
}
}
fn.parentIds = newParents
}
console.log(
`${prefix}[layoutLevel] flatNodes for sugiyama:`,
flatNodes.map((n) => ({ id: n.id, parentIds: n.parentIds }))
)
console.log(`${prefix}[layoutLevel] wrapperSizes:`, Object.fromEntries(wrapperSizes))
// Step 5: Run sugiyama on flattened nodes
const sugResult = runSugiyama(flatNodes, constants, wrapperSizes)
console.log(
`${prefix}[layoutLevel] sugiyama result: box=${sugResult.width}x${sugResult.height}`,
'positions=',
Object.fromEntries(sugResult.positions)
)
// 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 === 'branchall' || gl.group.type === 'branchone'
// Position the head node at the top-center of the wrapper
positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y })
if (isBranch) {
// Place branches side by side
const branchWidths = gl.branchLayouts.map((bl) =>
Math.max(bl.bbox.width, constants.nodeWidth)
)
const totalWidth =
branchWidths.reduce((s, w) => s + w, 0) +
Math.max(0, branchWidths.length - 1) * constants.gapH
let currentX = wrapperPos.x - totalWidth / 2
for (let bi = 0; bi < gl.branchLayouts.length; bi++) {
const bl = gl.branchLayouts[bi]
const bw = 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 = Math.max(0, ...gl.branchLayouts.map((bl) => bl.bbox.height))
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
})
}
}
// Collect wrappers: group-level + per-branch + recursive child wrappers
const wrappers: WrapperInfo[] = []
for (const [headId, gl] of groupLayouts) {
const wrapperPos = sugResult.positions.get(headId)
if (!wrapperPos) continue
// Overall group wrapper
wrappers.push({
id: `__wrapper__${headId}`,
headId,
type: gl.group.type,
level: 'group',
label: `${gl.group.type} (${headId})`,
x: wrapperPos.x,
y: wrapperPos.y,
width: gl.wrapperWidth,
height: gl.wrapperHeight
})
const rowHeight = constants.nodeHeight + constants.gapV
const isBranch = gl.group.type === 'branchall' || gl.group.type === 'branchone'
if (isBranch) {
const branchWidths = gl.branchLayouts.map((bl) =>
Math.max(bl.bbox.width, constants.nodeWidth)
)
const totalWidth =
branchWidths.reduce((s, w) => s + w, 0) +
Math.max(0, branchWidths.length - 1) * constants.gapH
let cx = wrapperPos.x - totalWidth / 2
for (let bi = 0; bi < gl.branchLayouts.length; bi++) {
const bl = gl.branchLayouts[bi]
const bw = branchWidths[bi]
const branchCenterX = cx + bw / 2
// Per-branch wrapper
wrappers.push({
id: `__wrapper__${headId}__branch_${bi}`,
headId,
type: gl.group.type,
level: 'branch',
label: `branch ${bi} (${bl.labelId})`,
x: branchCenterX,
y: wrapperPos.y + rowHeight,
width: bw,
height: bl.bbox.height
})
// Child wrappers from recursive layout
for (const cw of bl.result.wrappers) {
wrappers.push({ ...cw, x: branchCenterX + cw.x, y: wrapperPos.y + rowHeight + cw.y })
}
cx += bw + constants.gapH
}
} else {
const bl = gl.branchLayouts[0]
if (bl) {
// Loop body wrapper
wrappers.push({
id: `__wrapper__${headId}__body`,
headId,
type: gl.group.type,
level: 'loop-body',
label: `loop body (${bl.labelId})`,
x: wrapperPos.x + LOOP_INDENT,
y: wrapperPos.y + rowHeight,
width: bl.bbox.width,
height: bl.bbox.height
})
// Child wrappers from recursive layout
for (const cw of bl.result.wrappers) {
wrappers.push({
...cw,
x: wrapperPos.x + LOOP_INDENT + cw.x,
y: wrapperPos.y + rowHeight + cw.y
})
}
}
}
}
console.log(`${prefix}[layoutLevel] final positions:`, Object.fromEntries(positions))
console.log(
`${prefix}[layoutLevel] wrappers:`,
wrappers.map((w) => ({ id: w.id, type: w.type, x: w.x, y: w.y, w: w.width, h: w.height }))
)
// Compute overall bbox
let minX = Infinity
let maxX = -Infinity
let maxY = 0
for (const pos of positions.values()) {
minX = Math.min(minX, pos.x - constants.nodeWidth / 2)
maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2)
maxY = Math.max(maxY, pos.y + constants.nodeHeight)
}
const bboxWidth = maxX - minX
const bboxHeight =
maxY - (positions.size > 0 ? Math.min(...Array.from(positions.values()).map((p) => p.y)) : 0)
const finalBbox = {
width: Math.max(bboxWidth, constants.nodeWidth),
height: Math.max(bboxHeight, 0)
}
console.log(
`${prefix}[layoutLevel] returning bbox=`,
finalBbox,
`(minX=${minX} maxX=${maxX} maxY=${maxY} bboxWidth=${bboxWidth} bboxHeight=${bboxHeight})`
)
return { positions, bbox: finalBbox, wrappers }
}
/**
* 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[] }[],
_edges: { source: string; target: 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
}
console.log(
'[compoundLayout] input nodes:',
nodes.map((n) => ({ id: n.id, parentIds: n.parentIds }))
)
// Build node map
const allNodes = new Map<string, LayoutNode>()
for (const n of nodes) {
allNodes.set(n.id, n)
}
const nodeIds = nodes.map((n) => n.id)
const result = layoutLevel(nodeIds, allNodes, c)
console.log('[compoundLayout] FINAL positions:', Object.fromEntries(result.positions))
console.log('[compoundLayout] FINAL bbox:', result.bbox)
// 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
}

View File

@@ -645,13 +645,13 @@ export function graphBuilder(
if (module.value.type === 'branchall') {
// Start
addNode(module, currentOffset)
addNode(module, 0)
// "Collect result of each branch" node
const endNode: NodeLayout = {
id: `${module.id}-end`,
data: {
offset: currentOffset,
offset: 0,
id: module.id,
eventHandlers: eventHandlers,
flowModuleState: extra.flowModuleStates?.[module.id]
@@ -666,7 +666,7 @@ export function graphBuilder(
const startNode: NodeLayout = {
id: `${module.id}-branch-0`,
data: {
offset: currentOffset,
offset: 0,
id: module.id,
branchIndex: -1,
eventHandlers: eventHandlers,
@@ -692,7 +692,7 @@ export function graphBuilder(
const startNode: NodeLayout = {
id: `${module.id}-branch-${branchIndex}`,
data: {
offset: currentOffset,
offset: 0,
label: defaultIfEmptyString(branch.summary, `Branch ${branchIndex + 1}`),
id: module.id,
branchIndex: branchIndex,
@@ -723,7 +723,7 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset,
0,
localDisableMoveIds,
parentIndex ? `${parentIndex}-${index}-${branchIndex}` : `${index}-${branchIndex}`
)
@@ -733,13 +733,13 @@ export function graphBuilder(
previousId = endNode.id
} else if (module.value.type === 'forloopflow') {
if (!simplifiedTriggerView) {
addNode(module, currentOffset)
addNode(module, 0)
}
const startNode: NodeLayout = {
id: `${module.id}-start`,
data: {
offset: currentOffset + 25,
offset: 0,
id: module.id,
module: module,
simplifiedTriggerView,
@@ -764,7 +764,7 @@ export function graphBuilder(
const endNode: NodeLayout = {
id: `${module.id}-end`,
data: {
offset: currentOffset,
offset: 0,
id: module.id,
eventHandlers: eventHandlers,
simplifiedTriggerView,
@@ -785,7 +785,7 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset + 25,
0,
localDisableMoveIds,
parentIndex
? `${parentIndex}-${index}-${selectedIterIndex ?? '?'}`
@@ -794,12 +794,12 @@ export function graphBuilder(
previousId = endNode.id
} else if (module.value.type === 'whileloopflow') {
addNode(module, currentOffset)
addNode(module, 0)
const startNode: NodeLayout = {
id: `${module.id}-start`,
data: {
offset: currentOffset + 25,
offset: 0,
eventHandlers: eventHandlers
},
type: 'whileLoopStart'
@@ -810,7 +810,7 @@ export function graphBuilder(
const endNode: NodeLayout = {
id: `${module.id}-end`,
data: { offset: currentOffset, ...extra },
data: { offset: 0, ...extra },
type: 'whileLoopEnd'
}
@@ -826,7 +826,7 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset + 25,
0,
localDisableMoveIds,
parentIndex
? `${parentIndex}-${index}-${selectedIterIndex ?? '?'}`
@@ -835,12 +835,12 @@ export function graphBuilder(
previousId = endNode.id
} else if (module.value.type === 'branchone') {
addNode(module, currentOffset)
addNode(module, 0)
const endNode: NodeLayout = {
id: `${module.id}-end`,
data: {
offset: currentOffset,
offset: 0,
eventHandlers: eventHandlers,
flowModuleState: extra.flowModuleStates?.[module.id],
id: module.id
@@ -853,7 +853,7 @@ export function graphBuilder(
// const defaultBranch: NodeLayout = {
// id: `${module.id}-default`,
// data: {
// offset: currentOffset,
// offset: 0,
// label: 'Default',
// id: module.id,
// branchIndex: -1,
@@ -867,7 +867,7 @@ export function graphBuilder(
const defaultBranch: NodeLayout = {
id: `${module.id}-branch-default`,
data: {
offset: currentOffset,
offset: 0,
label: 'Default',
id: module.id,
branchIndex: -1,
@@ -894,7 +894,7 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset,
0,
localDisableMoveIds,
parentIndex ? `${parentIndex}-${index}` : index.toString()
)
@@ -905,7 +905,7 @@ export function graphBuilder(
const startNode: NodeLayout = {
id: `${module.id}-branch-${branchIndex}`,
data: {
offset: currentOffset,
offset: 0,
label: defaultIfEmptyString(branch.summary, 'Branch ' + (branchIndex + 1)),
preLabel: branch.summary ? '' : branch.expr,
id: module.id,
@@ -932,7 +932,7 @@ export function graphBuilder(
endNode,
false,
prefix,
currentOffset,
0,
localDisableMoveIds,
parentIndex ? `${parentIndex}-${index}` : index.toString()
)
@@ -950,7 +950,7 @@ export function graphBuilder(
const startNode: NodeLayout = {
id: startId,
data: {
offset: currentOffset,
offset: 0,
label: `Start of subflow ${idWithoutPrefix}`,
id: startId,
subflowId: module.id,
@@ -979,7 +979,7 @@ export function graphBuilder(
const endNode: NodeLayout = {
id: endId,
data: {
offset: currentOffset,
offset: 0,
label: `End of subflow ${idWithoutPrefix}`,
id: endId,
subflowId: module.id,
@@ -999,13 +999,13 @@ export function graphBuilder(
endNode,
false,
buildPrefix(prefix, module['oid'] ?? module.id),
currentOffset,
0,
localDisableMoveIds
)
previousId = endNode.id
} else {
addNode(module, currentOffset)
addNode(module, 0)
previousId = module.id
}
}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
interface Props {
data: {
headId: string
type: string
level: 'group' | 'branch' | 'loop-body'
label: string
wrapperWidth: number
wrapperHeight: number
}
}
let { data }: Props = $props()
// Group wrappers: solid bg, thicker border
// Branch wrappers: lighter bg, thinner border
const groupColors: Record<string, { bg: string; border: string }> = {
branchall: { bg: 'rgba(59, 130, 246, 0.08)', border: 'rgba(59, 130, 246, 0.5)' },
branchone: { bg: 'rgba(168, 85, 247, 0.08)', border: 'rgba(168, 85, 247, 0.5)' },
forloop: { bg: 'rgba(34, 197, 94, 0.08)', border: 'rgba(34, 197, 94, 0.5)' },
whileloop: { bg: 'rgba(234, 179, 8, 0.08)', border: 'rgba(234, 179, 8, 0.5)' }
}
const branchColors: Record<string, { bg: string; border: string }> = {
branchall: { bg: 'rgba(59, 130, 246, 0.12)', border: 'rgba(59, 130, 246, 0.7)' },
branchone: { bg: 'rgba(168, 85, 247, 0.12)', border: 'rgba(168, 85, 247, 0.7)' },
forloop: { bg: 'rgba(34, 197, 94, 0.12)', border: 'rgba(34, 197, 94, 0.7)' },
whileloop: { bg: 'rgba(234, 179, 8, 0.12)', border: 'rgba(234, 179, 8, 0.7)' }
}
const fallback = { bg: 'rgba(100,100,100,0.06)', border: 'rgba(100,100,100,0.4)' }
const colors = data.level === 'group'
? (groupColors[data.type] ?? fallback)
: (branchColors[data.type] ?? fallback)
const borderWidth = data.level === 'group' ? 2 : 1.5
const borderStyle = data.level === 'group' ? 'dashed' : 'dotted'
</script>
<div
style="
width: {data.wrapperWidth}px;
height: {data.wrapperHeight}px;
background: {colors.bg};
border: {borderWidth}px {borderStyle} {colors.border};
border-radius: {data.level === 'group' ? 10 : 6}px;
pointer-events: none;
position: relative;
"
>
<span
style="
position: absolute;
{data.level === 'group' ? 'top: 2px; left: 6px;' : 'bottom: 2px; right: 6px;'}
font-size: 9px;
color: {colors.border};
font-family: monospace;
opacity: 0.85;
"
>
{data.label} {data.wrapperWidth}×{data.wrapperHeight}
</span>
</div>

View File

@@ -19,7 +19,7 @@
let {
enableSourceHandle = true,
enableTargetHandle = true,
offset = 0,
offset: _offset = 0,
wrapperClass = '',
contextMenuItems = undefined,
nodeId = undefined,
@@ -39,14 +39,14 @@
{#if contextMenuItems && contextMenuItems.length > 0}
<ContextMenu items={contextMenuItems}>
<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>
@@ -59,7 +59,6 @@
type="source"
isConnectable={false}
position={Position.Bottom}
style={`margin-left: ${offset / 2}px;`}
/>
{/if}
@@ -68,7 +67,6 @@
type="target"
isConnectable={false}
position={Position.Top}
style={`margin-left: ${offset / 2}px;`}
/>
{/if}
{/snippet}

View File

@@ -240,14 +240,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)
}
},