Compare commits

...

18 Commits

Author SHA1 Message Date
Guilhem Lemouel
c3f7b09c33 fix: remove offset field from asset node data in FlowGraphV2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:50:42 +01:00
Guilhem Lemouel
13a064e898 Merge origin/main into glm/fix-flow-layout-v3
Resolve conflict in FlowGraphV2.svelte by keeping the added offset field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:29:22 +01:00
Guilhem
329b4bcdda Merge branch 'main' into glm/fix-flow-layout 2026-03-04 14:39:23 +00:00
Guilhem
038bc6783d formatting 2026-03-04 14:36:33 +00:00
Guilhem
7a2c0a452b 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>
2026-03-04 14:34:05 +00:00
Guilhem
c79863bb46 Merge branch 'main' into glm/fix-flow-layout 2026-03-04 13:13:56 +00:00
Guilhem
802cdc24d1 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>
2026-03-04 13:08:12 +00:00
Guilhem
12d1cdf8f1 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>
2026-03-04 12:55:28 +00:00
Guilhem
784c21ba77 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>
2026-03-04 12:44:45 +00:00
Guilhem
6322988d45 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>
2026-03-04 12:42:14 +00:00
Guilhem
f0ad760589 fix(frontend): remove unused lastXCenter variable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:06:46 +00:00
Guilhem
db696293e1 merge: sync with origin/main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:55:50 +00:00
Guilhem
29624bd1c9 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>
2026-03-04 11:45:16 +00:00
Guilhem
02595b4a30 perf(frontend): optimize compoundLayout recursive algorithm
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:54:48 +00:00
Guilhem
5a3efd7d84 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>
2026-03-04 10:25:57 +00:00
Guilhem
78371e97a2 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>
2026-03-04 10:20:41 +00:00
Guilhem
1d08dda038 fix(frontend): double forloop wrapper padding and include wrappers in bbox
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 20:50:14 +00:00
Guilhem
1aede4a24c feat(frontend): replace flat sugiyama with recursive compound layout for flow graph
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:24:52 +00:00
19 changed files with 623 additions and 158 deletions

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

@@ -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)

View File

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

View File

@@ -16,7 +16,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset} nodeId={id}>
<NodeWrapper nodeId={id}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.label}

View File

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

View File

@@ -13,7 +13,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset} nodeId={id}>
<NodeWrapper nodeId={id}>
{#snippet children({ darkMode })}
{#if data.simplifiedTriggerView}
<VirtualItem

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -17,7 +17,7 @@
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
<NodeWrapper>
{#snippet children({ darkMode })}
<VirtualItem
label={data.label}

View File

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

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>()