mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-26 08:02:31 +00:00
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:
co-authored by
Claude Opus 4.6
parent
aca8fad94d
commit
8446beec88
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user