diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index cc316630f5..f833c5b9da 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -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 = {} - 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 = { diff --git a/frontend/src/lib/components/graph/compoundLayout.ts b/frontend/src/lib/components/graph/compoundLayout.ts new file mode 100644 index 0000000000..f9b2efc643 --- /dev/null +++ b/frontend/src/lib/components/graph/compoundLayout.ts @@ -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 + 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, allNodes: Map): 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 +): '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, + allNodes: Map +): string[] { + const inner: string[] = [] + const visited = new Set() + + // Build children map (reverse of parentIds) + const children = new Map() + 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 +): { positions: Map; width: number; height: number } { + if (nodes.length === 0) { + return { positions: new Map(), width: 0, height: 0 } + } + + if (nodes.length === 1) { + const pos = new Map() + 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() + 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, + constants: LayoutConstants, + depth = 0 +): LayoutResult { + const prefix = ' '.repeat(depth) + console.log(`${prefix}[layoutLevel] depth=${depth} nodeIds=`, nodeIds) + + const positions = new Map() + 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() + const groupByHeadId = new Map() + 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() + const wrapperSizes = new Map() + + 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() + 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 +): 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() + 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 +} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index e1714f6d33..c0796524c7 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -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 } } diff --git a/frontend/src/lib/components/graph/renderers/nodes/DebugWrapperNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/DebugWrapperNode.svelte new file mode 100644 index 0000000000..8176748ef4 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/DebugWrapperNode.svelte @@ -0,0 +1,60 @@ + + +
+ + {data.label} {data.wrapperWidth}×{data.wrapperHeight} + +
diff --git a/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte b/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte index 10d9475620..b267f13587 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NodeWrapper.svelte @@ -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} -
+
{@render children?.({ darkMode })}
{@render handles()} {:else} -
+
{@render children?.({ darkMode })}
@@ -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} diff --git a/frontend/src/lib/components/graph/util.ts b/frontend/src/lib/components/graph/util.ts index 92735df46c..c5909b625d 100644 --- a/frontend/src/lib/components/graph/util.ts +++ b/frontend/src/lib/components/graph/util.ts @@ -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) } },