feat: add horizontal and bottom padding to nodeExtraSpace for groups

Extend nodeExtraSpace from {top, bottom} to {top, bottom, left, right}
so the layout engine allocates wider columns for nodes with group
decorations. Groups get 16px side and bottom padding matching
GroupOverlay. Bottom handle of the bottommost group node is offset
down via groupBottomOffset on edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-03-11 20:43:00 +01:00
co-authored by Claude Opus 4.6
parent 8446beec88
commit 4c111f80ff
3 changed files with 73 additions and 32 deletions
@@ -362,8 +362,8 @@
*/
function computeNodeExtraSpace(
graphNodes: NodeDep[]
): Map<string, { top: number; bottom: number }> | undefined {
const extraSpace = new Map<string, { top: number; bottom: number }>()
): Map<string, { top: number; bottom: number; left: number; right: number }> | undefined {
const extraSpace = new Map<string, { top: number; bottom: number; left: number; right: number }>()
// 1. Assets
if ($showAssets) {
@@ -373,8 +373,9 @@
const hasRead = assets.some(assetDisplaysAsInputInFlowGraph)
const hasWrite = assets.some(assetDisplaysAsOutputInFlowGraph)
if (hasRead || hasWrite) {
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0 }
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(node.id, {
...prev,
top: prev.top + (hasRead ? NODE_WITH_READ_ASSET_Y_OFFSET : 0),
bottom: prev.bottom + (hasWrite ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)
})
@@ -393,15 +394,15 @@
// 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 })
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(node.id, { ...prev, 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 })
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(node.id, { ...prev, top: prev.top + space })
}
}
@@ -418,10 +419,10 @@
if (topmostNodeId) {
const textHeight = noteTextHeights[groupNote.id] || 60
const spacing = textHeight + 16 // padding
const prev = extraSpace.get(topmostNodeId) ?? { top: 0, bottom: 0 }
const prev = extraSpace.get(topmostNodeId) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(topmostNodeId, {
top: Math.max(prev.top, spacing + prev.top),
bottom: prev.bottom
...prev,
top: Math.max(prev.top, spacing + prev.top)
})
}
}
@@ -456,11 +457,28 @@
const spacing = isCollapsed
? GROUP_HEADER_HEIGHT + noteHeight
: GROUP_HEADER_HEIGHT + noteHeight + GROUP_TOP_MARGIN
const prev = extraSpace.get(topmostNodeId) ?? { top: 0, bottom: 0 }
const groupPadding = 16
const prev = extraSpace.get(topmostNodeId) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(topmostNodeId, {
top: prev.top + spacing,
bottom: prev.bottom
bottom: prev.bottom,
left: Math.max(prev.left, groupPadding),
right: Math.max(prev.right, groupPadding)
})
// Bottom padding for uncollapsed groups on the bottommost node
if (!isCollapsed) {
const bottommostNodeId = [...sortedNodes].reverse().find((node) =>
group.module_ids.includes(node.id)
)?.id
if (bottommostNodeId) {
const prevBottom = extraSpace.get(bottommostNodeId) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(bottommostNodeId, {
...prevBottom,
bottom: Math.max(prevBottom.bottom, groupPadding)
})
}
}
}
}
}
@@ -468,7 +486,7 @@
return extraSpace.size > 0 ? extraSpace : undefined
}
function layoutNodes(nodes: NodeDep[], nodeExtraSpace?: Map<string, { top: number; bottom: number }>): (NodeDep & NodePos)[] {
function layoutNodes(nodes: NodeDep[], nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>): (NodeDep & NodePos)[] {
let lastResult = lastNodes?.[2]
if (lastResult && deepEqual(nodes, lastNodes?.[0]) && deepEqual(nodeExtraSpace, lastNodes?.[1])) {
console.debug('layoutNodes', 'same nodes')
@@ -780,9 +798,10 @@
)
: undefined
// Compute group header offsets for edges targeting topmost nodes in groups
// Compute group header/bottom offsets for edges targeting/sourcing group boundary nodes
const groups = groupEditorContext?.groupEditor.getGroups() ?? []
const groupHeaderOffsets: Record<string, number> = {}
const groupBottomOffsets: Record<string, number> = {}
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
@@ -811,17 +830,33 @@
: GROUP_HEADER_HEIGHT + noteHeight + GROUP_TOP_MARGIN
groupHeaderOffsets[topNodeId] = Math.max(groupHeaderOffsets[topNodeId] ?? 0, offset)
}
// Bottom offset for uncollapsed groups
if (!isCollapsed) {
const bottomNodeId = [...sortedNodes].reverse().find((node) =>
group.module_ids.includes(node.id)
)?.id
if (bottomNodeId) {
const groupPadding = 16
groupBottomOffsets[bottomNodeId] = Math.max(groupBottomOffsets[bottomNodeId] ?? 0, groupPadding)
}
}
}
}
// update nodes
nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])]
// Patch edges with group header offsets
// Patch edges with group header/bottom offsets
const graphEdges = graph.edges.map((e) => {
const offset = groupHeaderOffsets[e.target]
if (offset) {
return { ...e, data: { ...e.data, groupHeaderOffset: offset } }
const headerOffset = groupHeaderOffsets[e.target]
const bottomOffset = groupBottomOffsets[e.source]
if (headerOffset || bottomOffset) {
return { ...e, data: {
...e.data,
...(headerOffset ? { groupHeaderOffset: headerOffset } : {}),
...(bottomOffset ? { groupBottomOffset: bottomOffset } : {})
} }
}
return e
})
@@ -238,15 +238,15 @@ function runSugiyama(
function buildNodeSizes(
nodeIds: string[],
constants: LayoutConstants,
nodeExtraSpace?: Map<string, { top: number; bottom: number }>
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: 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)) {
if (extra && (extra.top > 0 || extra.bottom > 0 || extra.left > 0 || extra.right > 0)) {
sizes.set(id, {
width: constants.nodeWidth,
width: constants.nodeWidth + extra.left + extra.right,
height: constants.nodeHeight + extra.top + extra.bottom
})
}
@@ -263,7 +263,7 @@ function layoutLevel(
childrenMap: Map<string, string[]>,
containerDescendants: Map<string, string[]>,
depth: number = 0,
nodeExtraSpace?: Map<string, { top: number; bottom: number }>
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
): LayoutResult {
const positions = new Map<string, { x: number; y: number }>()
const nodeIdSet = new Set(nodeIds)
@@ -482,7 +482,10 @@ function layoutLevel(
// Position the head node at the top-center of the wrapper
// 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) })
positions.set(headId, {
x: wrapperPos.x,
y: wrapperPos.y + (headExtra?.top ?? 0)
})
if (isBranch) {
// Reuse cached branchWidths and totalWidth
@@ -539,8 +542,8 @@ function layoutLevel(
let maxY = -Infinity
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)
minX = Math.min(minX, pos.x - constants.nodeWidth / 2 - (extra?.left ?? 0))
maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2 + (extra?.right ?? 0))
// 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))
@@ -572,14 +575,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.
* nodeExtraSpace: per-node top/bottom/left/right 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.
* (assets, AI tools, group headers) have room above. Left/right padding widens the
* column allocated to the node so neighbors are pushed further away.
*/
export function compoundLayout(
nodes: { id: string; parentIds?: string[] }[],
constants?: Partial<LayoutConstants>,
nodeExtraSpace?: Map<string, { top: number; bottom: number }>
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
): LayoutResult {
const c: LayoutConstants = {
nodeWidth: constants?.nodeWidth ?? NODE.width,
@@ -43,15 +43,17 @@
suspendStatus?: Record<string, { job: Job; nb: number }>
shouldOffsetInsertBtnDueToAssetNode?: boolean
groupHeaderOffset?: number
groupBottomOffset?: number
}
} = $props()
let adjustedSourceY = $derived(sourceY + (data?.groupBottomOffset ?? 0))
let adjustedTargetY = $derived(targetY - (data?.groupHeaderOffset ?? 0))
let [edgePath] = $derived(
getBezierPath({
sourceX,
sourceY: adjustedTargetY - sourceY > 100 ? adjustedTargetY - 100 : sourceY,
sourceY: adjustedTargetY - adjustedSourceY > 100 ? adjustedTargetY - 100 : adjustedSourceY,
sourcePosition,
targetX,
targetY: adjustedTargetY,
@@ -61,8 +63,8 @@
)
let completeEdge = $derived(
adjustedTargetY - sourceY > 100
? `${edgePath} ${getStraightLinePath({ sourceX, sourceY, targetY: adjustedTargetY })}`
adjustedTargetY - adjustedSourceY > 100
? `${edgePath} ${getStraightLinePath({ sourceX, sourceY: adjustedSourceY, targetY: adjustedTargetY })}`
: edgePath
)
@@ -78,7 +80,7 @@
)
let centerY = $derived(
sourceY +
adjustedSourceY +
32 +
(data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)
)