From 22f64d60bbe69be584935cad32509244c1e62905 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Sun, 5 Oct 2025 12:49:41 +0800 Subject: [PATCH] chore: update Dockerfile to use Python 3.12-bookworm and refactor layout logic to utilize ELK for improved node layout (#26522) --- .../hooks/use-workflow-interactions.ts | 133 +++-- .../components/workflow/utils/dagre-layout.ts | 246 -------- web/app/components/workflow/utils/index.ts | 2 +- web/app/components/workflow/utils/layout.ts | 529 ++++++++++++++++++ 4 files changed, 593 insertions(+), 317 deletions(-) delete mode 100644 web/app/components/workflow/utils/dagre-layout.ts create mode 100644 web/app/components/workflow/utils/layout.ts diff --git a/web/app/components/workflow/hooks/use-workflow-interactions.ts b/web/app/components/workflow/hooks/use-workflow-interactions.ts index c508eea0ba..f63250dd42 100644 --- a/web/app/components/workflow/hooks/use-workflow-interactions.ts +++ b/web/app/components/workflow/hooks/use-workflow-interactions.ts @@ -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 = {} - 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) const containerSizeChanges: Record = {} @@ -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 - 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() + 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), } }) }) diff --git a/web/app/components/workflow/utils/dagre-layout.ts b/web/app/components/workflow/utils/dagre-layout.ts deleted file mode 100644 index 5eafe77586..0000000000 --- a/web/app/components/workflow/utils/dagre-layout.ts +++ /dev/null @@ -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 -} diff --git a/web/app/components/workflow/utils/index.ts b/web/app/components/workflow/utils/index.ts index ab59f513bc..e9ae2d1ef0 100644 --- a/web/app/components/workflow/utils/index.ts +++ b/web/app/components/workflow/utils/index.ts @@ -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' diff --git a/web/app/components/workflow/utils/layout.ts b/web/app/components/workflow/utils/layout.ts new file mode 100644 index 0000000000..b3cf3b0d88 --- /dev/null +++ b/web/app/components/workflow/utils/layout.ts @@ -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 + 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() + 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 } | 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() + 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() + 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 => { + 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() + + // 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() + 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 => { + 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) +}