chore: update Dockerfile to use Python 3.12-bookworm and refactor layout logic to utilize ELK for improved node layout (#26522)

This commit is contained in:
GuanMu 2025-10-05 12:49:41 +08:00 committed by GitHub
parent 7b7d332239
commit 22f64d60bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 593 additions and 317 deletions

View File

@ -10,7 +10,7 @@ import {
NODE_LAYOUT_VERTICAL_PADDING,
WORKFLOW_DATA_UPDATE,
} from '../constants'
import type { Node, WorkflowDataUpdater } from '../types'
import type { WorkflowDataUpdater } from '../types'
import { BlockEnum, ControlMode } from '../types'
import {
getLayoutByDagre,
@ -18,6 +18,7 @@ import {
initialEdges,
initialNodes,
} from '../utils'
import type { LayoutResult } from '../utils'
import {
useNodesReadOnly,
useSelectionInteractions,
@ -102,10 +103,17 @@ export const useWorkflowOrganize = () => {
&& node.type === CUSTOM_NODE,
)
const childLayoutsMap: Record<string, any> = {}
loopAndIterationNodes.forEach((node) => {
childLayoutsMap[node.id] = getLayoutForChildNodes(node.id, nodes, edges)
})
const childLayoutEntries = await Promise.all(
loopAndIterationNodes.map(async node => [
node.id,
await getLayoutForChildNodes(node.id, nodes, edges),
] as const),
)
const childLayoutsMap = childLayoutEntries.reduce((acc, [nodeId, layout]) => {
if (layout)
acc[nodeId] = layout
return acc
}, {} as Record<string, LayoutResult>)
const containerSizeChanges: Record<string, { width: number, height: number }> = {}
@ -113,37 +121,20 @@ export const useWorkflowOrganize = () => {
const childLayout = childLayoutsMap[parentNode.id]
if (!childLayout) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
let hasChildren = false
const {
bounds,
nodes: layoutNodes,
} = childLayout
const childNodes = nodes.filter(node => node.parentId === parentNode.id)
if (!layoutNodes.size)
return
childNodes.forEach((node) => {
if (childLayout.node(node.id)) {
hasChildren = true
const childNodeWithPosition = childLayout.node(node.id)
const requiredWidth = (bounds.maxX - bounds.minX) + NODE_LAYOUT_HORIZONTAL_PADDING * 2
const requiredHeight = (bounds.maxY - bounds.minY) + NODE_LAYOUT_VERTICAL_PADDING * 2
const nodeX = childNodeWithPosition.x - node.width! / 2
const nodeY = childNodeWithPosition.y - node.height! / 2
minX = Math.min(minX, nodeX)
minY = Math.min(minY, nodeY)
maxX = Math.max(maxX, nodeX + node.width!)
maxY = Math.max(maxY, nodeY + node.height!)
}
})
if (hasChildren) {
const requiredWidth = maxX - minX + NODE_LAYOUT_HORIZONTAL_PADDING * 2
const requiredHeight = maxY - minY + NODE_LAYOUT_VERTICAL_PADDING * 2
containerSizeChanges[parentNode.id] = {
width: Math.max(parentNode.width || 0, requiredWidth),
height: Math.max(parentNode.height || 0, requiredHeight),
}
containerSizeChanges[parentNode.id] = {
width: Math.max(parentNode.width || 0, requiredWidth),
height: Math.max(parentNode.height || 0, requiredHeight),
}
})
@ -166,63 +157,65 @@ export const useWorkflowOrganize = () => {
})
})
const layout = getLayoutByDagre(nodesWithUpdatedSizes, edges)
const layout = await getLayoutByDagre(nodesWithUpdatedSizes, edges)
const rankMap = {} as Record<string, Node>
nodesWithUpdatedSizes.forEach((node) => {
if (!node.parentId && node.type === CUSTOM_NODE) {
const rank = layout.node(node.id).rank!
if (!rankMap[rank]) {
rankMap[rank] = node
}
else {
if (rankMap[rank].position.y > node.position.y)
rankMap[rank] = node
// Build layer map for vertical alignment - nodes in the same layer should align
const layerMap = new Map<number, { minY: number; maxHeight: number }>()
layout.nodes.forEach((layoutInfo) => {
if (layoutInfo.layer !== undefined) {
const existing = layerMap.get(layoutInfo.layer)
const newLayerInfo = {
minY: existing ? Math.min(existing.minY, layoutInfo.y) : layoutInfo.y,
maxHeight: existing ? Math.max(existing.maxHeight, layoutInfo.height) : layoutInfo.height,
}
layerMap.set(layoutInfo.layer, newLayerInfo)
}
})
const newNodes = produce(nodesWithUpdatedSizes, (draft) => {
draft.forEach((node) => {
if (!node.parentId && node.type === CUSTOM_NODE) {
const nodeWithPosition = layout.node(node.id)
const layoutInfo = layout.nodes.get(node.id)
if (!layoutInfo)
return
// Calculate vertical position with layer alignment
let yPosition = layoutInfo.y
if (layoutInfo.layer !== undefined) {
const layerInfo = layerMap.get(layoutInfo.layer)
if (layerInfo) {
// Align to the center of the tallest node in this layer
const layerCenterY = layerInfo.minY + layerInfo.maxHeight / 2
yPosition = layerCenterY - layoutInfo.height / 2
}
}
node.position = {
x: nodeWithPosition.x - node.width! / 2,
y: nodeWithPosition.y - node.height! / 2 + rankMap[nodeWithPosition.rank!].height! / 2,
x: layoutInfo.x,
y: yPosition,
}
}
})
loopAndIterationNodes.forEach((parentNode) => {
const childLayout = childLayoutsMap[parentNode.id]
if (!childLayout) return
if (!childLayout)
return
const childNodes = draft.filter(node => node.parentId === parentNode.id)
const {
bounds,
nodes: layoutNodes,
} = childLayout
let minX = Infinity
let minY = Infinity
childNodes.forEach((childNode) => {
const layoutInfo = layoutNodes.get(childNode.id)
if (!layoutInfo)
return
childNodes.forEach((node) => {
if (childLayout.node(node.id)) {
const childNodeWithPosition = childLayout.node(node.id)
const nodeX = childNodeWithPosition.x - node.width! / 2
const nodeY = childNodeWithPosition.y - node.height! / 2
minX = Math.min(minX, nodeX)
minY = Math.min(minY, nodeY)
}
})
childNodes.forEach((node) => {
if (childLayout.node(node.id)) {
const childNodeWithPosition = childLayout.node(node.id)
node.position = {
x: NODE_LAYOUT_HORIZONTAL_PADDING + (childNodeWithPosition.x - node.width! / 2 - minX),
y: NODE_LAYOUT_VERTICAL_PADDING + (childNodeWithPosition.y - node.height! / 2 - minY),
}
childNode.position = {
x: NODE_LAYOUT_HORIZONTAL_PADDING + (layoutInfo.x - bounds.minX),
y: NODE_LAYOUT_VERTICAL_PADDING + (layoutInfo.y - bounds.minY),
}
})
})

View File

@ -1,246 +0,0 @@
import dagre from '@dagrejs/dagre'
import {
cloneDeep,
} from 'lodash-es'
import type {
Edge,
Node,
} from '../types'
import {
BlockEnum,
} from '../types'
import {
CUSTOM_NODE,
NODE_LAYOUT_HORIZONTAL_PADDING,
NODE_LAYOUT_MIN_DISTANCE,
NODE_LAYOUT_VERTICAL_PADDING,
} from '../constants'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
export const getLayoutByDagre = (originNodes: Node[], originEdges: Edge[]) => {
const dagreGraph = new dagre.graphlib.Graph({ compound: true })
dagreGraph.setDefaultEdgeLabel(() => ({}))
const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE)
const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop))
// The default dagre layout algorithm often fails to correctly order the branches
// of an If/Else node, leading to crossed edges.
//
// To solve this, we employ a "virtual container" strategy:
// 1. A virtual, compound parent node (the "container") is created for each If/Else node's branches.
// 2. Each direct child of the If/Else node is preceded by a virtual dummy node. These dummies are placed inside the container.
// 3. A rigid, sequential chain of invisible edges is created between these dummy nodes (e.g., dummy_IF -> dummy_ELIF -> dummy_ELSE).
//
// This forces dagre to treat the ordered branches as an unbreakable, atomic group,
// ensuring their layout respects the intended logical sequence.
const ifElseNodes = nodes.filter(node => node.data.type === BlockEnum.IfElse)
let virtualLogicApplied = false
ifElseNodes.forEach((ifElseNode) => {
const childEdges = edges.filter(e => e.source === ifElseNode.id)
if (childEdges.length <= 1)
return
virtualLogicApplied = true
const sortedChildEdges = childEdges.sort((edgeA, edgeB) => {
const handleA = edgeA.sourceHandle
const handleB = edgeB.sourceHandle
if (handleA && handleB) {
const cases = (ifElseNode.data as any).cases || []
const isAElse = handleA === 'false'
const isBElse = handleB === 'false'
if (isAElse) return 1
if (isBElse) return -1
const indexA = cases.findIndex((c: any) => c.case_id === handleA)
const indexB = cases.findIndex((c: any) => c.case_id === handleB)
if (indexA !== -1 && indexB !== -1)
return indexA - indexB
}
return 0
})
const parentDummyId = `dummy-parent-${ifElseNode.id}`
dagreGraph.setNode(parentDummyId, { width: 1, height: 1 })
const dummyNodes: string[] = []
sortedChildEdges.forEach((edge) => {
const dummyNodeId = `dummy-${edge.source}-${edge.target}`
dummyNodes.push(dummyNodeId)
dagreGraph.setNode(dummyNodeId, { width: 1, height: 1 })
dagreGraph.setParent(dummyNodeId, parentDummyId)
const edgeIndex = edges.findIndex(e => e.id === edge.id)
if (edgeIndex > -1)
edges.splice(edgeIndex, 1)
edges.push({ id: `e-${edge.source}-${dummyNodeId}`, source: edge.source, target: dummyNodeId, sourceHandle: edge.sourceHandle } as Edge)
edges.push({ id: `e-${dummyNodeId}-${edge.target}`, source: dummyNodeId, target: edge.target, targetHandle: edge.targetHandle } as Edge)
})
for (let i = 0; i < dummyNodes.length - 1; i++) {
const sourceDummy = dummyNodes[i]
const targetDummy = dummyNodes[i + 1]
edges.push({ id: `e-dummy-${sourceDummy}-${targetDummy}`, source: sourceDummy, target: targetDummy } as Edge)
}
})
dagreGraph.setGraph({
rankdir: 'LR',
align: 'UL',
nodesep: 40,
ranksep: virtualLogicApplied ? 30 : 60,
ranker: 'tight-tree',
marginx: 30,
marginy: 200,
})
nodes.forEach((node) => {
dagreGraph.setNode(node.id, {
width: node.width!,
height: node.height!,
})
})
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
return dagreGraph
}
export const getLayoutForChildNodes = (parentNodeId: string, originNodes: Node[], originEdges: Edge[]) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
const nodes = cloneDeep(originNodes).filter(node => node.parentId === parentNodeId)
const edges = cloneDeep(originEdges).filter(edge =>
(edge.data?.isInIteration && edge.data?.iteration_id === parentNodeId)
|| (edge.data?.isInLoop && edge.data?.loop_id === parentNodeId),
)
const startNode = nodes.find(node =>
node.type === CUSTOM_ITERATION_START_NODE
|| node.type === CUSTOM_LOOP_START_NODE
|| node.data?.type === BlockEnum.LoopStart
|| node.data?.type === BlockEnum.IterationStart,
)
if (!startNode) {
dagreGraph.setGraph({
rankdir: 'LR',
align: 'UL',
nodesep: 40,
ranksep: 60,
marginx: NODE_LAYOUT_HORIZONTAL_PADDING,
marginy: NODE_LAYOUT_VERTICAL_PADDING,
})
nodes.forEach((node) => {
dagreGraph.setNode(node.id, {
width: node.width || 244,
height: node.height || 100,
})
})
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
return dagreGraph
}
const startNodeOutEdges = edges.filter(edge => edge.source === startNode.id)
const firstConnectedNodes = startNodeOutEdges.map(edge =>
nodes.find(node => node.id === edge.target),
).filter(Boolean) as Node[]
const nonStartNodes = nodes.filter(node => node.id !== startNode.id)
const nonStartEdges = edges.filter(edge => edge.source !== startNode.id && edge.target !== startNode.id)
dagreGraph.setGraph({
rankdir: 'LR',
align: 'UL',
nodesep: 40,
ranksep: 60,
marginx: NODE_LAYOUT_HORIZONTAL_PADDING / 2,
marginy: NODE_LAYOUT_VERTICAL_PADDING / 2,
})
nonStartNodes.forEach((node) => {
dagreGraph.setNode(node.id, {
width: node.width || 244,
height: node.height || 100,
})
})
nonStartEdges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
const startNodeSize = {
width: startNode.width || 44,
height: startNode.height || 48,
}
const startNodeX = NODE_LAYOUT_HORIZONTAL_PADDING / 1.5
let startNodeY = 100
let minFirstLayerX = Infinity
let avgFirstLayerY = 0
let firstLayerCount = 0
if (firstConnectedNodes.length > 0) {
firstConnectedNodes.forEach((node) => {
if (dagreGraph.node(node.id)) {
const nodePos = dagreGraph.node(node.id)
avgFirstLayerY += nodePos.y
firstLayerCount++
minFirstLayerX = Math.min(minFirstLayerX, nodePos.x - nodePos.width / 2)
}
})
if (firstLayerCount > 0) {
avgFirstLayerY /= firstLayerCount
startNodeY = avgFirstLayerY
}
const minRequiredX = startNodeX + startNodeSize.width + NODE_LAYOUT_MIN_DISTANCE
if (minFirstLayerX < minRequiredX) {
const shiftX = minRequiredX - minFirstLayerX
nonStartNodes.forEach((node) => {
if (dagreGraph.node(node.id)) {
const nodePos = dagreGraph.node(node.id)
dagreGraph.setNode(node.id, {
x: nodePos.x + shiftX,
y: nodePos.y,
width: nodePos.width,
height: nodePos.height,
})
}
})
}
}
dagreGraph.setNode(startNode.id, {
x: startNodeX + startNodeSize.width / 2,
y: startNodeY,
width: startNodeSize.width,
height: startNodeSize.height,
})
startNodeOutEdges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target)
})
return dagreGraph
}

View File

@ -1,7 +1,7 @@
export * from './node'
export * from './edge'
export * from './workflow-init'
export * from './dagre-layout'
export * from './layout'
export * from './common'
export * from './tool'
export * from './workflow'

View File

@ -0,0 +1,529 @@
import ELK from 'elkjs/lib/elk.bundled.js'
import type { ElkNode, LayoutOptions } from 'elkjs/lib/elk-api'
import { cloneDeep } from 'lodash-es'
import type {
Edge,
Node,
} from '../types'
import {
BlockEnum,
} from '../types'
import {
CUSTOM_NODE,
NODE_LAYOUT_HORIZONTAL_PADDING,
NODE_LAYOUT_VERTICAL_PADDING,
} from '../constants'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import type { CaseItem, IfElseNodeType } from '../nodes/if-else/types'
// Although the file name refers to Dagre, the implementation now relies on ELK's layered algorithm.
// Keep the export signatures unchanged to minimise the blast radius while we migrate the layout stack.
const elk = new ELK()
const DEFAULT_NODE_WIDTH = 244
const DEFAULT_NODE_HEIGHT = 100
const ROOT_LAYOUT_OPTIONS = {
'elk.algorithm': 'layered',
'elk.direction': 'RIGHT',
// === Spacing - Maximum spacing to prevent any overlap ===
'elk.layered.spacing.nodeNodeBetweenLayers': '100',
'elk.spacing.nodeNode': '80',
'elk.spacing.edgeNode': '50',
'elk.spacing.edgeEdge': '30',
'elk.spacing.edgeLabel': '10',
'elk.spacing.portPort': '20',
// === Port Configuration ===
'elk.portConstraints': 'FIXED_ORDER',
'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES',
'elk.port.side': 'SOUTH',
// === Node Placement - Best quality ===
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
'elk.layered.nodePlacement.favorStraightEdges': 'true',
'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5',
'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE',
// === Edge Routing - Maximum quality ===
'elk.edgeRouting': 'SPLINES',
'elk.layered.edgeRouting.selfLoopPlacement': 'NORTH',
'elk.layered.edgeRouting.sloppySplineRouting': 'false',
'elk.layered.edgeRouting.splines.mode': 'CONSERVATIVE',
'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': '1.2',
// === Crossing Minimization - Most aggressive ===
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
'elk.layered.crossingMinimization.greedySwitchHierarchical.type': 'TWO_SIDED',
'elk.layered.crossingMinimization.semiInteractive': 'true',
'elk.layered.crossingMinimization.hierarchicalSweepiness': '0.9',
// === Layering Strategy - Best quality ===
'elk.layered.layering.strategy': 'NETWORK_SIMPLEX',
'elk.layered.layering.networkSimplex.nodeFlexibility': 'NODE_SIZE',
'elk.layered.layering.layerConstraint': 'NONE',
'elk.layered.layering.minWidth.upperBoundOnWidth': '4',
// === Cycle Breaking ===
'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST',
// === Connected Components ===
'elk.separateConnectedComponents': 'true',
'elk.spacing.componentComponent': '100',
// === Node Size Constraints ===
'elk.nodeSize.constraints': 'NODE_LABELS',
'elk.nodeSize.options': 'DEFAULT_MINIMUM_SIZE MINIMUM_SIZE_ACCOUNTS_FOR_PADDING',
// === Edge Label Placement ===
'elk.edgeLabels.placement': 'CENTER',
'elk.edgeLabels.inline': 'true',
// === Compaction ===
'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
'elk.layered.compaction.postCompaction.constraints': 'EDGE_LENGTH',
// === High-Quality Mode ===
'elk.layered.thoroughness': '10',
'elk.layered.wrapping.strategy': 'OFF',
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
// === Additional Optimizations ===
'elk.layered.feedbackEdges': 'true',
'elk.layered.mergeEdges': 'false',
'elk.layered.mergeHierarchyEdges': 'false',
'elk.layered.allowNonFlowPortsToSwitchSides': 'false',
'elk.layered.northOrSouthPort': 'false',
'elk.partitioning.activate': 'false',
'elk.junctionPoints': 'true',
// === Content Alignment ===
'elk.contentAlignment': 'V_TOP H_LEFT',
'elk.alignment': 'AUTOMATIC',
}
const CHILD_LAYOUT_OPTIONS = {
'elk.algorithm': 'layered',
'elk.direction': 'RIGHT',
// === Spacing - High quality for child nodes ===
'elk.layered.spacing.nodeNodeBetweenLayers': '80',
'elk.spacing.nodeNode': '60',
'elk.spacing.edgeNode': '40',
'elk.spacing.edgeEdge': '25',
'elk.spacing.edgeLabel': '8',
'elk.spacing.portPort': '15',
// === Node Placement - Best quality ===
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
'elk.layered.nodePlacement.favorStraightEdges': 'true',
'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5',
'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE',
// === Edge Routing - Maximum quality ===
'elk.edgeRouting': 'SPLINES',
'elk.layered.edgeRouting.sloppySplineRouting': 'false',
'elk.layered.edgeRouting.splines.mode': 'CONSERVATIVE',
// === Crossing Minimization - Aggressive ===
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
'elk.layered.crossingMinimization.semiInteractive': 'true',
// === Layering Strategy ===
'elk.layered.layering.strategy': 'NETWORK_SIMPLEX',
'elk.layered.layering.networkSimplex.nodeFlexibility': 'NODE_SIZE',
// === Cycle Breaking ===
'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST',
// === Node Size ===
'elk.nodeSize.constraints': 'NODE_LABELS',
// === Compaction ===
'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
// === High-Quality Mode ===
'elk.layered.thoroughness': '10',
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
// === Additional Optimizations ===
'elk.layered.feedbackEdges': 'true',
'elk.layered.mergeEdges': 'false',
'elk.junctionPoints': 'true',
}
type LayoutInfo = {
x: number
y: number
width: number
height: number
layer?: number
}
type LayoutBounds = {
minX: number
minY: number
maxX: number
maxY: number
}
export type LayoutResult = {
nodes: Map<string, LayoutInfo>
bounds: LayoutBounds
}
// ELK Port definition for native port support
type ElkPortShape = {
id: string
layoutOptions?: LayoutOptions
}
type ElkNodeShape = {
id: string
width: number
height: number
ports?: ElkPortShape[]
layoutOptions?: LayoutOptions
children?: ElkNodeShape[]
}
type ElkEdgeShape = {
id: string
sources: string[]
targets: string[]
sourcePort?: string
targetPort?: string
}
const toElkNode = (node: Node): ElkNodeShape => ({
id: node.id,
width: node.width ?? DEFAULT_NODE_WIDTH,
height: node.height ?? DEFAULT_NODE_HEIGHT,
})
let edgeCounter = 0
const nextEdgeId = () => `elk-edge-${edgeCounter++}`
const createEdge = (
source: string,
target: string,
sourcePort?: string,
targetPort?: string,
): ElkEdgeShape => ({
id: nextEdgeId(),
sources: [source],
targets: [target],
sourcePort,
targetPort,
})
const collectLayout = (graph: ElkNode, predicate: (id: string) => boolean): LayoutResult => {
const result = new Map<string, LayoutInfo>()
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
const visit = (node: ElkNode) => {
node.children?.forEach((child: ElkNode) => {
if (predicate(child.id)) {
const x = child.x ?? 0
const y = child.y ?? 0
const width = child.width ?? DEFAULT_NODE_WIDTH
const height = child.height ?? DEFAULT_NODE_HEIGHT
const layer = child?.layoutOptions?.['org.eclipse.elk.layered.layerIndex']
result.set(child.id, {
x,
y,
width,
height,
layer: layer ? Number.parseInt(layer) : undefined,
})
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x + width)
maxY = Math.max(maxY, y + height)
}
if (child.children?.length)
visit(child)
})
}
visit(graph)
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
minX = 0
minY = 0
maxX = 0
maxY = 0
}
return {
nodes: result,
bounds: {
minX,
minY,
maxX,
maxY,
},
}
}
/**
* Build If/Else node with ELK native Ports instead of dummy nodes
* This is the recommended approach for handling multiple branches
*/
const buildIfElseWithPorts = (
ifElseNode: Node,
edges: Edge[],
): { node: ElkNodeShape; portMap: Map<string, string> } | null => {
const childEdges = edges.filter(edge => edge.source === ifElseNode.id)
if (childEdges.length <= 1)
return null
// Sort child edges according to case order
const sortedChildEdges = [...childEdges].sort((edgeA, edgeB) => {
const handleA = edgeA.sourceHandle
const handleB = edgeB.sourceHandle
if (handleA && handleB) {
const cases = (ifElseNode.data as IfElseNodeType).cases || []
const isAElse = handleA === 'false'
const isBElse = handleB === 'false'
if (isAElse)
return 1
if (isBElse)
return -1
const indexA = cases.findIndex((c: CaseItem) => c.case_id === handleA)
const indexB = cases.findIndex((c: CaseItem) => c.case_id === handleB)
if (indexA !== -1 && indexB !== -1)
return indexA - indexB
}
return 0
})
// Create ELK ports for each branch
const ports: ElkPortShape[] = sortedChildEdges.map((edge, index) => ({
id: `${ifElseNode.id}-port-${edge.sourceHandle || index}`,
layoutOptions: {
'port.side': 'EAST', // Ports on the right side (matching 'RIGHT' direction)
'port.index': String(index),
},
}))
// Build port mapping: sourceHandle -> portId
const portMap = new Map<string, string>()
sortedChildEdges.forEach((edge, index) => {
const portId = `${ifElseNode.id}-port-${edge.sourceHandle || index}`
portMap.set(edge.id, portId)
})
return {
node: {
id: ifElseNode.id,
width: ifElseNode.width ?? DEFAULT_NODE_WIDTH,
height: ifElseNode.height ?? DEFAULT_NODE_HEIGHT,
ports,
layoutOptions: {
'elk.portConstraints': 'FIXED_ORDER',
},
},
portMap,
}
}
const normaliseBounds = (layout: LayoutResult): LayoutResult => {
const {
nodes,
bounds,
} = layout
if (nodes.size === 0)
return layout
const offsetX = bounds.minX
const offsetY = bounds.minY
const adjustedNodes = new Map<string, LayoutInfo>()
nodes.forEach((info, id) => {
adjustedNodes.set(id, {
...info,
x: info.x - offsetX,
y: info.y - offsetY,
})
})
return {
nodes: adjustedNodes,
bounds: {
minX: 0,
minY: 0,
maxX: bounds.maxX - offsetX,
maxY: bounds.maxY - offsetY,
},
}
}
export const getLayoutByDagre = async (originNodes: Node[], originEdges: Edge[]): Promise<LayoutResult> => {
edgeCounter = 0
const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE)
const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop))
const elkNodes: ElkNodeShape[] = []
const elkEdges: ElkEdgeShape[] = []
// Track which edges have been processed for If/Else nodes with ports
const edgeToPortMap = new Map<string, string>()
// Build nodes with ports for If/Else nodes
nodes.forEach((node) => {
if (node.data.type === BlockEnum.IfElse) {
const portsResult = buildIfElseWithPorts(node, edges)
if (portsResult) {
// Use node with ports
elkNodes.push(portsResult.node)
// Store port mappings for edges
portsResult.portMap.forEach((portId, edgeId) => {
edgeToPortMap.set(edgeId, portId)
})
}
else {
// No multiple branches, use normal node
elkNodes.push(toElkNode(node))
}
}
else {
elkNodes.push(toElkNode(node))
}
})
// Build edges with port connections
edges.forEach((edge) => {
const sourcePort = edgeToPortMap.get(edge.id)
elkEdges.push(createEdge(edge.source, edge.target, sourcePort))
})
const graph = {
id: 'workflow-root',
layoutOptions: ROOT_LAYOUT_OPTIONS,
children: elkNodes,
edges: elkEdges,
}
const layoutedGraph = await elk.layout(graph)
// No need to filter dummy nodes anymore, as we're using ports
const layout = collectLayout(layoutedGraph, () => true)
return normaliseBounds(layout)
}
const normaliseChildLayout = (
layout: LayoutResult,
nodes: Node[],
): LayoutResult => {
const result = new Map<string, LayoutInfo>()
layout.nodes.forEach((info, id) => {
result.set(id, info)
})
// Ensure iteration / loop start nodes do not collapse into the children.
const startNode = nodes.find(node =>
node.type === CUSTOM_ITERATION_START_NODE
|| node.type === CUSTOM_LOOP_START_NODE
|| node.data?.type === BlockEnum.LoopStart
|| node.data?.type === BlockEnum.IterationStart,
)
if (startNode) {
const startLayout = result.get(startNode.id)
if (startLayout) {
const desiredMinX = NODE_LAYOUT_HORIZONTAL_PADDING / 1.5
if (startLayout.x > desiredMinX) {
const shiftX = startLayout.x - desiredMinX
result.forEach((value, key) => {
result.set(key, {
...value,
x: value.x - shiftX,
})
})
}
const desiredMinY = startLayout.y
const deltaY = NODE_LAYOUT_VERTICAL_PADDING / 2
result.forEach((value, key) => {
result.set(key, {
...value,
y: value.y - desiredMinY + deltaY,
})
})
}
}
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
result.forEach((value) => {
minX = Math.min(minX, value.x)
minY = Math.min(minY, value.y)
maxX = Math.max(maxX, value.x + value.width)
maxY = Math.max(maxY, value.y + value.height)
})
if (!Number.isFinite(minX) || !Number.isFinite(minY))
return layout
return normaliseBounds({
nodes: result,
bounds: {
minX,
minY,
maxX,
maxY,
},
})
}
export const getLayoutForChildNodes = async (
parentNodeId: string,
originNodes: Node[],
originEdges: Edge[],
): Promise<LayoutResult | null> => {
edgeCounter = 0
const nodes = cloneDeep(originNodes).filter(node => node.parentId === parentNodeId)
if (!nodes.length)
return null
const edges = cloneDeep(originEdges).filter(edge =>
(edge.data?.isInIteration && edge.data?.iteration_id === parentNodeId)
|| (edge.data?.isInLoop && edge.data?.loop_id === parentNodeId),
)
const elkNodes: ElkNodeShape[] = nodes.map(toElkNode)
const elkEdges: ElkEdgeShape[] = edges.map(edge => createEdge(edge.source, edge.target))
const graph = {
id: parentNodeId,
layoutOptions: CHILD_LAYOUT_OPTIONS,
children: elkNodes,
edges: elkEdges,
}
const layoutedGraph = await elk.layout(graph)
const layout = collectLayout(layoutedGraph, () => true)
return normaliseChildLayout(layout, nodes)
}