mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 00:04:10 +00:00
feat(frontend): replace flat sugiyama with recursive compound layout for flow graph (#8204)
* feat(frontend): replace flat sugiyama with recursive compound layout for flow graph Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): double forloop wrapper padding and include wrappers in bbox Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * 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> * 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> * perf(frontend): optimize compoundLayout recursive algorithm Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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> * fix(frontend): remove unused lastXCenter variable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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> * 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> * 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> * 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> * 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> * formatting * fix: remove offset field from asset node data in FlowGraphV2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 4.6
parent
bc9f235e1e
commit
0fcb29cfad
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
<NodeWrapper nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.label}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
<NodeWrapper nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
{#if data.simplifiedTriggerView}
|
||||
<VirtualItem
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset}>
|
||||
<NodeWrapper>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.label}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user