refactor: integrate vertical offsets into compound layout bounding boxes

Pre-compute per-node extra space (assets, AI tools, group notes, group
headers) and pass to compoundLayout so sugiyama naturally allocates the
right vertical space. Remove cascading cumulative y-offset shifts from
computeAssetNodes, computeAIToolNodes, computeNoteNodes, and
computeGroupSpacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-03-11 20:10:45 +01:00
co-authored by Claude Opus 4.6
parent aca8fad94d
commit 8446beec88
5 changed files with 245 additions and 210 deletions
@@ -63,7 +63,7 @@
import NoteTool from './NoteTool.svelte'
import SelectionBoundingBox from './SelectionBoundingBox.svelte'
import GroupOverlay from './GroupOverlay.svelte'
import { getGroupEditorContext, computeGroupSpacing, GROUP_HEADER_HEIGHT, GROUP_TOP_MARGIN } from './groupEditor.svelte'
import { getGroupEditorContext, GROUP_HEADER_HEIGHT, GROUP_TOP_MARGIN } from './groupEditor.svelte'
import SelectionTool from './SelectionTool.svelte'
import PaneContextMenu from './PaneContextMenu.svelte'
import { SelectionManager } from './selectionUtils.svelte'
@@ -75,6 +75,9 @@
import { compoundLayout } from './compoundLayout'
import { deepEqual } from 'fast-equals'
import type { AssetWithAltAccessType } from '../assets/lib'
import { assetDisplaysAsInputInFlowGraph, assetDisplaysAsOutputInFlowGraph, NODE_WITH_READ_ASSET_Y_OFFSET, NODE_WITH_WRITE_ASSET_Y_OFFSET } from './renderers/nodes/AssetNode.svelte'
import { AI_TOOL_BASE_OFFSET, AI_TOOL_ROW_OFFSET, BELOW_ADDITIONAL_OFFSET } from './renderers/nodes/AIToolNode.svelte'
import { topologicalSort } from './graphBuilder.svelte'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
import { setGraphContext } from './graphContext'
import { computeNoteNodes } from './noteUtils.svelte'
@@ -345,15 +348,129 @@
type NodeDep = {
id: string
parentIds?: string[]
data?: { assets?: AssetWithAltAccessType[] }
data?: { assets?: AssetWithAltAccessType[]; module?: any }
}
type NodePos = { position: { x: number; y: number } }
let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined
let lastNodes: [NodeDep[], Map<string, { top: number; bottom: number }> | undefined, (NodeDep & NodePos)[]] | undefined = undefined
let currentContainerDescendants: Map<string, string[]> = new Map()
function layoutNodes(nodes: NodeDep[]): (NodeDep & NodePos)[] {
let lastResult = lastNodes?.[1]
if (lastResult && deepEqual(nodes, lastNodes?.[0])) {
const MAX_TOOLS_PER_ROW = 2
/**
* Pre-compute extra top/bottom space each node needs for decorations
* (assets, AI tools, group headers, group notes).
*/
function computeNodeExtraSpace(
graphNodes: NodeDep[]
): Map<string, { top: number; bottom: number }> | undefined {
const extraSpace = new Map<string, { top: number; bottom: number }>()
// 1. Assets
if ($showAssets) {
for (const node of graphNodes) {
const assets = node.data?.assets ?? []
if (!assets.length) continue
const hasRead = assets.some(assetDisplaysAsInputInFlowGraph)
const hasWrite = assets.some(assetDisplaysAsOutputInFlowGraph)
if (hasRead || hasWrite) {
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0 }
extraSpace.set(node.id, {
top: prev.top + (hasRead ? NODE_WITH_READ_ASSET_Y_OFFSET : 0),
bottom: prev.bottom + (hasWrite ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)
})
}
}
}
// 2. AI tools
for (const node of graphNodes) {
const mod = node.data?.module
if (!mod || mod.value?.type !== 'aiagent') continue
const agentActions = !insertable && flowModuleStates?.[node.id]?.agent_actions
if (agentActions) {
// Execution mode: tools below
const totalRows = Math.ceil(agentActions.length / MAX_TOOLS_PER_ROW)
const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + BELOW_ADDITIONAL_OFFSET
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0 }
extraSpace.set(node.id, { top: prev.top, bottom: prev.bottom + space })
} else {
// Edit mode: tools above
const tools = mod.value.tools ?? []
const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (insertable ? 1 : 0)
const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0 }
extraSpace.set(node.id, { top: prev.top + space, bottom: prev.bottom })
}
}
// 3. Group notes (text above topmost node in each group note)
if (showNotes) {
const groupNotes = (notes ?? []).filter((n) => n.type === 'group')
if (groupNotes.length > 0) {
const sortedNodes = topologicalSort(graphNodes).reverse()
for (const groupNote of groupNotes) {
if (!groupNote.contained_node_ids?.length) continue
const topmostNodeId = sortedNodes.find((node) =>
groupNote.contained_node_ids?.includes(node.id)
)?.id
if (topmostNodeId) {
const textHeight = noteTextHeights[groupNote.id] || 60
const spacing = textHeight + 16 // padding
const prev = extraSpace.get(topmostNodeId) ?? { top: 0, bottom: 0 }
extraSpace.set(topmostNodeId, {
top: Math.max(prev.top, spacing + prev.top),
bottom: prev.bottom
})
}
}
}
}
// 4. Group headers (header + note + margin above topmost node in each group)
const groups = groupEditorContext?.groupEditor.getGroups() ?? []
if (groups.length > 0) {
const groupNoteHeights = showNotes ? (groupEditorContext?.groupEditor.getNoteHeights() ?? {}) : {}
// Need to find topmost node per group - use topological sort
const sortedNodes = topologicalSort(graphNodes).reverse()
for (const group of groups) {
if (group.module_ids.length === 0) continue
// Check if it's collapsed
const collapsedNodeId = `collapsed-group:${group.id}`
const hasCollapsedNode = graphNodes.some((n) => n.id === collapsedNodeId)
let topmostNodeId: string | undefined
let isCollapsed = false
if (hasCollapsedNode) {
topmostNodeId = collapsedNodeId
isCollapsed = true
} else {
topmostNodeId = sortedNodes.find((node) =>
group.module_ids.includes(node.id)
)?.id
}
if (topmostNodeId) {
const noteHeight = groupNoteHeights[group.id] ?? 0
const spacing = isCollapsed
? GROUP_HEADER_HEIGHT + noteHeight
: GROUP_HEADER_HEIGHT + noteHeight + GROUP_TOP_MARGIN
const prev = extraSpace.get(topmostNodeId) ?? { top: 0, bottom: 0 }
extraSpace.set(topmostNodeId, {
top: prev.top + spacing,
bottom: prev.bottom
})
}
}
}
return extraSpace.size > 0 ? extraSpace : undefined
}
function layoutNodes(nodes: NodeDep[], nodeExtraSpace?: Map<string, { top: number; bottom: number }>): (NodeDep & NodePos)[] {
let lastResult = lastNodes?.[2]
if (lastResult && deepEqual(nodes, lastNodes?.[0]) && deepEqual(nodeExtraSpace, lastNodes?.[1])) {
console.debug('layoutNodes', 'same nodes')
return lastResult
}
@@ -366,13 +483,13 @@
seenId.push(n.id)
}
// Run recursive compound layout
// Run recursive compound layout with pre-computed extra space
const { positions, bbox, containerDescendants: layoutContainerDescendants } = compoundLayout(nodes, {
nodeWidth: NODE.width,
nodeHeight: NODE.height,
gapH: NODE.gap.horizontal,
gapV: NODE.gap.vertical
})
}, nodeExtraSpace)
// Center horizontally
const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
@@ -385,7 +502,7 @@
}))
currentContainerDescendants = layoutContainerDescendants ?? new Map()
lastNodes = [nodes, newNodes]
lastNodes = [nodes, nodeExtraSpace, newNodes]
return newNodes
}
@@ -609,17 +726,20 @@
return
}
// console.log('compute')
const graphNodeDeps = Object.values(graph.nodes).map((n) => ({
id: n.id,
parentIds: n.parentIds,
data: { assets: (n.data as any).assets, module: (n.data as any).module }
}))
let layoutedNodes = layoutNodes(
Object.values(graph.nodes).map((n) => ({
id: n.id,
parentIds: n.parentIds,
data: { assets: (n.data as any).assets }
}))
)
// Pre-compute extra space per node for assets, AI tools, group notes, group headers
const nodeExtraSpace = computeNodeExtraSpace(graphNodeDeps)
// Layout with extra space baked into sugiyama
let layoutedNodes = layoutNodes(graphNodeDeps, nodeExtraSpace)
let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => ({ ...n, ...graph.nodes[n.id] }))
// Compute asset visual nodes (no position remapping)
let assetNodesResult = $showAssets
? computeAssetNodes(
newNodes.map((n) => ({
@@ -629,25 +749,17 @@
}))
)
: undefined
if (assetNodesResult) {
newNodes = newNodes.map((n) => ({
...n,
position: assetNodesResult.newNodePositions[n.id]
}))
}
let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates)
let nodesAfterAITools = newNodes.map((n) => ({
...n,
position: aiToolNodesResult.newNodePositions[n.id]
}))
let finalNodes = [
...nodesAfterAITools,
// Compute AI tool visual nodes (no position remapping)
let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates)
let finalNodes: (Node & NodeLayout)[] = [
...newNodes,
...(assetNodesResult?.newAssetNodes ?? []),
...aiToolNodesResult.toolNodes
]
// Compute note nodes and positions
// Compute note nodes (no position remapping)
let noteNodesResult = showNotes
? computeNoteNodes(
finalNodes.map((n) => ({
@@ -668,56 +780,35 @@
)
: undefined
// Apply note positioning to nodes if notes are enabled
if (noteNodesResult) {
finalNodes = finalNodes.map((n) => ({
...n,
position: noteNodesResult.newNodePositions[n.id] || n.position
}))
}
// Apply group label spacing (pushes nodes down like group notes do)
const groups = groupEditorContext?.groupEditor.getGroups() ?? []
if (groups.length > 0) {
const noteHeights = showNotes ? (groupEditorContext?.groupEditor.getNoteHeights() ?? {}) : {}
const groupPositions = computeGroupSpacing(
groups,
finalNodes.map((n) => ({ id: n.id, position: n.position })),
noteHeights
)
finalNodes = finalNodes.map((n) => ({
...n,
position: groupPositions[n.id] || n.position
}))
}
// Compute group header offsets for edges targeting topmost nodes in groups
const groups = groupEditorContext?.groupEditor.getGroups() ?? []
const groupHeaderOffsets: Record<string, number> = {}
if (groups.length > 0) {
const noteHeightsForEdges = showNotes ? (groupEditorContext?.groupEditor.getNoteHeights() ?? {}) : {}
if (groups.length > 0 && nodeExtraSpace) {
// Use nodeExtraSpace directly - nodes with group headers already have the space
// The offset for edges equals the group header + note portion of the topPadding
const groupNoteHeights = showNotes ? (groupEditorContext?.groupEditor.getNoteHeights() ?? {}) : {}
const sortedNodes = topologicalSort(graphNodeDeps).reverse()
for (const group of groups) {
if (group.module_ids.length === 0) continue
const collapsedNodeId = `collapsed-group:${group.id}`
let topY = Infinity
const hasCollapsedNode = graphNodeDeps.some((n) => n.id === collapsedNodeId)
let topNodeId: string | undefined
let isCollapsed = false
for (const node of finalNodes) {
if (node.id === collapsedNodeId && node.position.y < topY) {
topY = node.position.y
topNodeId = node.id
isCollapsed = true
} else if (group.module_ids.includes(node.id) && node.position.y < topY) {
topY = node.position.y
topNodeId = node.id
isCollapsed = false
}
if (hasCollapsedNode) {
topNodeId = collapsedNodeId
isCollapsed = true
} else {
topNodeId = sortedNodes.find((node) =>
group.module_ids.includes(node.id)
)?.id
}
if (topNodeId) {
const noteHeight = noteHeightsForEdges[group.id] ?? 0
const noteHeight = groupNoteHeights[group.id] ?? 0
const offset = isCollapsed
? GROUP_HEADER_HEIGHT + noteHeight
: GROUP_HEADER_HEIGHT + noteHeight + GROUP_TOP_MARGIN
// Only set if larger (a node could be topmost in multiple groups theoretically)
groupHeaderOffsets[topNodeId] = Math.max(groupHeaderOffsets[topNodeId] ?? 0, offset)
}
}
@@ -231,6 +231,29 @@ function runSugiyama(
* 5. Run sugiyama on the simplified graph
* 6. Expand wrapper positions back to absolute positions
*/
/**
* Build nodeSizes map for sugiyama from nodeExtraSpace.
* Each node's effective height = top + NODE.height + bottom.
*/
function buildNodeSizes(
nodeIds: string[],
constants: LayoutConstants,
nodeExtraSpace?: Map<string, { top: number; bottom: number }>
): Map<string, { width: number; height: number }> | undefined {
if (!nodeExtraSpace || nodeExtraSpace.size === 0) return undefined
const sizes = new Map<string, { width: number; height: number }>()
for (const id of nodeIds) {
const extra = nodeExtraSpace.get(id)
if (extra && (extra.top > 0 || extra.bottom > 0)) {
sizes.set(id, {
width: constants.nodeWidth,
height: constants.nodeHeight + extra.top + extra.bottom
})
}
}
return sizes.size > 0 ? sizes : undefined
}
const MAX_RECURSION_DEPTH = 50
function layoutLevel(
@@ -239,7 +262,8 @@ function layoutLevel(
constants: LayoutConstants,
childrenMap: Map<string, string[]>,
containerDescendants: Map<string, string[]>,
depth: number = 0
depth: number = 0,
nodeExtraSpace?: Map<string, { top: number; bottom: number }>
): LayoutResult {
const positions = new Map<string, { x: number; y: number }>()
const nodeIdSet = new Set(nodeIds)
@@ -258,8 +282,15 @@ function layoutLevel(
const n = allNodes.get(id)!
return { id, parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid)) }
})
const result = runSugiyama(flatNodes, constants)
const extraSizes = buildNodeSizes(
flatNodes.map((n) => n.id),
constants,
nodeExtraSpace
)
const result = runSugiyama(flatNodes, constants, extraSizes)
for (const [id, pos] of result.positions) {
const extra = nodeExtraSpace?.get(id)
if (extra) pos.y += extra.top
positions.set(id, pos)
}
return { positions, bbox: { width: result.width, height: result.height }, contentMinX: 0 }
@@ -339,7 +370,8 @@ function layoutLevel(
constants,
childrenMap,
containerDescendants,
depth + 1
depth + 1,
nodeExtraSpace
)
branchLayouts.push({
@@ -413,13 +445,30 @@ function layoutLevel(
}
// Step 5: Run sugiyama on flattened nodes
const sugResult = runSugiyama(flatNodes, constants, wrapperSizes)
// Merge wrapperSizes with nodeExtraSpace-derived sizes for non-group nodes
const extraSizes = buildNodeSizes(
flatNodes.map((n) => n.id),
constants,
nodeExtraSpace
)
const mergedSizes = new Map<string, { width: number; height: number }>()
if (extraSizes) {
for (const [id, size] of extraSizes) mergedSizes.set(id, size)
}
for (const [id, size] of wrapperSizes) mergedSizes.set(id, size)
const sugResult = runSugiyama(
flatNodes,
constants,
mergedSizes.size > 0 ? mergedSizes : undefined
)
// Step 6: Resolve absolute positions
// First, set positions for regular (non-group) nodes
// Apply per-node y-offset from nodeExtraSpace so decorations above have room
for (const [nid, pos] of sugResult.positions) {
if (groupByHeadId.has(nid)) continue // Handle groups separately
positions.set(nid, { x: pos.x, y: pos.y })
const extra = nodeExtraSpace?.get(nid)
positions.set(nid, { x: pos.x, y: pos.y + (extra?.top ?? 0) })
}
// Now expand group wrappers into absolute positions
@@ -431,7 +480,9 @@ function layoutLevel(
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 })
// Apply extra top padding so decorations above the head node have room
const headExtra = nodeExtraSpace?.get(headId)
positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y + (headExtra?.top ?? 0) })
if (isBranch) {
// Reuse cached branchWidths and totalWidth
@@ -486,11 +537,13 @@ function layoutLevel(
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
for (const pos of positions.values()) {
for (const [nid, pos] of positions) {
const extra = nodeExtraSpace?.get(nid)
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 top decoration space above the node
minY = Math.min(minY, pos.y - (extra?.top ?? 0))
maxY = Math.max(maxY, pos.y + constants.nodeHeight + (extra?.bottom ?? 0))
}
// Account for group wrapper extents in bbox (e.g. LOOP_INDENT makes wrappers wider than nodes)
for (const [headId, gl] of groupLayouts) {
@@ -518,10 +571,15 @@ function layoutLevel(
*
* Takes the flat list of nodes and edges from graphBuilder and produces
* absolute positions that account for compound structure (branches, loops).
*
* nodeExtraSpace: per-node top/bottom padding that should be allocated in layout.
* After layout, each node's y is shifted down by its top padding so decorations
* (assets, AI tools, group headers) have room above.
*/
export function compoundLayout(
nodes: { id: string; parentIds?: string[] }[],
constants?: Partial<LayoutConstants>
constants?: Partial<LayoutConstants>,
nodeExtraSpace?: Map<string, { top: number; bottom: number }>
): LayoutResult {
const c: LayoutConstants = {
nodeWidth: constants?.nodeWidth ?? NODE.width,
@@ -547,7 +605,15 @@ export function compoundLayout(
const nodeIds = nodes.map((n) => n.id)
const containerDescendants = new Map<string, string[]>()
const result = layoutLevel(nodeIds, allNodes, c, childrenMap, containerDescendants)
const result = layoutLevel(
nodeIds,
allNodes,
c,
childrenMap,
containerDescendants,
0,
nodeExtraSpace
)
// Shift positions so minX=0 (left-aligned).
// FlowGraphV2 centers with: xCenter = viewport/2 - bbox.width/2
@@ -19,13 +19,11 @@ export type NodeDep = {
export type NoteComputeResult = {
noteNodes: (Node & NodeLayout)[]
newNodePositions: Record<string, { x: number; y: number }>
}
export type AIToolSpacingInfo = {
toolNodes: (Node & NodeLayout)[]
toolEdges: any[]
newNodePositions: Record<string, { x: number; y: number }>
}
export interface GroupNoteBounds {
@@ -283,14 +281,9 @@ export function computeNoteNodes(
const allNoteNodes: (Node & NodeLayout)[] = []
// Build a map of Y positions that need extra spacing for group notes
const yPosMap: Record<number, number> = {} // Y position -> spacing needed
// Group notes that need spacing
// Find topmost node per group note for layout calculation
const groupNotes = notes.filter((n) => n.type === 'group')
const topMostNodesMap: Record<string, string> = {}
const sortedNodes = topologicalSort(nodes).reverse()
for (const groupNote of groupNotes) {
@@ -298,47 +291,12 @@ export function computeNoteNodes(
const topmostNodeId = sortedNodes.find((node) =>
groupNote.contained_node_ids?.includes(node.id)
)?.id
const topmostNode = nodes.find((node) => node.id === topmostNodeId)
if (topmostNode) {
const textHeight = noteTextHeights[groupNote.id] || 60
const spacing = textHeight + 16 // padding
// Mark this Y position as needing spacing
yPosMap[topmostNode.position.y] = Math.max(yPosMap[topmostNode.position.y] || 0, spacing)
topMostNodesMap[groupNote.id] = topmostNode.id
if (topmostNodeId) {
topMostNodesMap[groupNote.id] = topmostNodeId
}
}
}
// Calculate new positions for nodes (offset by group notes)
const sortedNewNodes = nodes
.map((n) => ({ position: { ...n.position }, id: n.id }))
.sort((a, b) => a.position.y - b.position.y)
let currentYOffset = 0
let prevYPos = NaN
for (const node of sortedNewNodes) {
if (node.position.y !== prevYPos) {
// Add spacing for group notes at this Y level
if (yPosMap[node.position.y]) {
currentYOffset += yPosMap[node.position.y]
}
prevYPos = node.position.y
}
node.position.y += currentYOffset
}
// Create note nodes AFTER calculating adjusted node positions
// For group notes, we need to use the adjusted node positions
const adjustedNodes = sortedNewNodes.map((n) => {
const origNode = nodes.find((orig) => orig.id === n.id)
return {
...n,
data: origNode?.data,
type: origNode?.type
}
})
// Calculate all z-indexes at once using hierarchy information
const noteZIndexes = calculateAllNoteZIndexes(notes, nodes)
@@ -346,11 +304,11 @@ export function computeNoteNodes(
const isGroupNote = note.type === 'group'
const zIndex = noteZIndexes[note.id]
// Calculate position and size using adjusted node positions for group notes
// Calculate position and size using node positions for group notes
const { position, size } = isGroupNote
? calculateGroupNoteLayout(
note,
adjustedNodes,
nodes,
noteTextHeights[note.id] || 60,
topMostNodesMap[note.id]
)
@@ -375,13 +333,8 @@ export function computeNoteNodes(
allNoteNodes.push(noteNode)
}
const newNodePositions: Record<string, { x: number; y: number }> = Object.fromEntries(
sortedNewNodes.map((n) => [n.id, n.position])
)
const result: NoteComputeResult = {
noteNodes: allNoteNodes,
newNodePositions
noteNodes: allNoteNodes
}
// Cache the result
@@ -56,7 +56,6 @@
): {
toolNodes: (Node & NodeLayout)[]
toolEdges: Edge[]
newNodePositions: Record<string, { x: number; y: number }>
} {
if (
computeAIToolNodesCache &&
@@ -69,14 +68,6 @@
const allToolNodes: (Node & NodeLayout)[] = []
const allToolEdges: Edge[] = []
const yPosMap: Record<
number,
{
rows: number
placement: 'above' | 'below'
}
> = {}
for (const node of nodes) {
if (node.type !== 'module' || node.data.module.value.type !== 'aiagent') continue
// by default we assume we will show tools above
@@ -135,17 +126,6 @@
}
const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (insertable ? 1 : 0) // + 1 for add tool node when insertable
if (agentActions) {
yPosMap[node.position.y] = {
rows: totalRows,
placement: 'below'
}
} else {
yPosMap[node.position.y] = {
rows: totalRows,
placement: 'above'
}
}
const toolNodes: (Node & AiToolN)[] = tools.map((tool, i) => {
let inputToolXGap = 12
@@ -215,36 +195,9 @@
}
}
const sortedNewNodes = nodes
.filter((n) => n.type !== 'asset')
.map((n) => ({ id: n.id, position: $state.snapshot(n.position) }))
.sort((a, b) => a.position.y - b.position.y)
let currentYOffset = 0
let prevYPos = NaN
for (const node of sortedNewNodes) {
if (node.position.y !== prevYPos) {
// if agent actions, we need to shift the node above
if (yPosMap[prevYPos]?.placement === 'below') {
currentYOffset += AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * yPosMap[prevYPos].rows
}
if (yPosMap[node.position.y]?.placement === 'above') {
currentYOffset += AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * yPosMap[node.position.y].rows
}
prevYPos = node.position.y
}
node.position.y += currentYOffset
}
let ret: ReturnType<typeof computeAIToolNodes> = {
toolNodes: allToolNodes,
toolEdges: allToolEdges,
newNodePositions: Object.fromEntries(
sortedNewNodes.map((n) => {
return [n.id, n.position]
})
)
toolEdges: allToolEdges
}
computeAIToolNodesCache = {
@@ -19,8 +19,6 @@
export function computeAssetNodes(nodes: NodeDep[]): {
newAssetNodes: (Node & NodeLayout)[]
newAssetEdges: Edge[]
// Nodes need to be offset on the y axis to make space for the asset nodes
newNodePositions: Record<string, { x: number; y: number }>
} {
if (computeAssetNodesCache && deepEqual(nodes, computeAssetNodesCache[0])) {
return computeAssetNodesCache[1]
@@ -30,8 +28,6 @@
const allAssetNodes: (Node & NodeLayout)[] = []
const allAssetEdges: Edge[] = []
const yPosMap: Record<number, { r?: true; w?: true }> = {}
for (const node of nodes) {
const assets = node.data.assets ?? []
if (!assets.length) continue
@@ -47,13 +43,6 @@
const overflowedInputAssets = inputAssets.slice(3)
const overflowedOutputAssets = outputAssets.slice(3)
// This allows calculating which nodes to offset on the y axis to
// make space for the asset nodes
if (inputAssets.length || outputAssets.length)
yPosMap[node.position.y] = yPosMap[node.position.y] ?? {}
if (inputAssets.length) yPosMap[node.position.y].r = true
if (outputAssets.length) yPosMap[node.position.y].w = true
// All asset nodes displayed on top
const inputAssetNodes: (Node & AssetN)[] = displayedInputAssets.map((asset, i) => {
let inputAssetXGap = 12
@@ -187,26 +176,9 @@
})
}
// Shift all nodes to make space for the new asset nodes
const sortedNewNodes = nodes
.map((n) => ({ position: { ...n.position }, id: n.id }))
.sort((a, b) => a.position.y - b.position.y)
let currentYOffset = 0
let prevYPos = NaN
for (const node of sortedNewNodes) {
if (node.position.y !== prevYPos) {
if (yPosMap[prevYPos]?.w) currentYOffset += NODE_WITH_WRITE_ASSET_Y_OFFSET
if (yPosMap[node.position.y]?.r) currentYOffset += NODE_WITH_READ_ASSET_Y_OFFSET
prevYPos = node.position.y
}
node.position.y += currentYOffset
}
let ret: ReturnType<typeof computeAssetNodes> = {
newAssetNodes: allAssetNodes,
newAssetEdges: allAssetEdges,
newNodePositions: Object.fromEntries(sortedNewNodes.map((n) => [n.id, n.position]))
newAssetEdges: allAssetEdges
}
computeAssetNodesCache = [clone(nodes), ret]
return ret