From 51f6ca2bed96cbc736371fbce22786ddf773ad65 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:07:20 +0800 Subject: [PATCH] fix(workflow): improve node organization (#34276) --- .../use-workflow-interactions.spec.tsx | 6 +- .../workflow/hooks/use-workflow-organize.ts | 4 +- .../utils/__tests__/elk-layout.spec.ts | 68 ++--- .../components/workflow/utils/elk-layout.ts | 233 +++++++----------- 4 files changed, 123 insertions(+), 188 deletions(-) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx index 457b54e763..95dc3dff00 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx @@ -28,7 +28,7 @@ const mockHandleEdgeCancelRunningStatus = vi.hoisted(() => vi.fn()) const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn()) const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn()) -const mockGetLayoutByDagre = vi.hoisted(() => vi.fn()) +const mockGetLayoutByELK = vi.hoisted(() => vi.fn()) const mockInitialNodes = vi.hoisted(() => vi.fn((nodes: unknown[], _edges: unknown[]) => nodes)) const mockInitialEdges = vi.hoisted(() => vi.fn((edges: unknown[], _nodes: unknown[]) => edges)) @@ -112,7 +112,7 @@ vi.mock('../use-workflow-history', () => ({ vi.mock('../../utils', async importOriginal => ({ ...(await importOriginal()), getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args), - getLayoutByDagre: (...args: unknown[]) => mockGetLayoutByDagre(...args), + getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args), initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges), initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes), })) @@ -203,7 +203,7 @@ describe('use-workflow-interactions exports', () => { ['loop-child', { x: 40, y: 60, width: 100, height: 60 }], ]), }) - mockGetLayoutByDagre.mockResolvedValue({ + mockGetLayoutByELK.mockResolvedValue({ nodes: new Map([ ['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }], ['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }], diff --git a/web/app/components/workflow/hooks/use-workflow-organize.ts b/web/app/components/workflow/hooks/use-workflow-organize.ts index 284fb2261c..da158a4214 100644 --- a/web/app/components/workflow/hooks/use-workflow-organize.ts +++ b/web/app/components/workflow/hooks/use-workflow-organize.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { useReactFlow, useStoreApi } from 'reactflow' import { useWorkflowStore } from '../store' import { - getLayoutByDagre, + getLayoutByELK, getLayoutForChildNodes, } from '../utils' import { useNodesSyncDraft } from './use-nodes-sync-draft' @@ -49,7 +49,7 @@ export const useWorkflowOrganize = () => { nodes, getContainerSizeChanges(parentNodes, childLayoutsMap), ) - const layout = await getLayoutByDagre(nodesWithUpdatedSizes, edges) + const layout = await getLayoutByELK(nodesWithUpdatedSizes, edges) const nextNodes = applyLayoutToNodes({ nodes: nodesWithUpdatedSizes, layout, diff --git a/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts index 662b380f5d..1a3c52ec2d 100644 --- a/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts +++ b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts @@ -5,7 +5,7 @@ import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constan import { CUSTOM_LOOP_START_NODE } from '../../nodes/loop-start/constants' import { BlockEnum } from '../../types' -type ElkChild = Record & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string }>, layoutOptions?: Record } +type ElkChild = Record & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string, layoutOptions?: Record }>, layoutOptions?: Record } type ElkGraph = Record & { id: string, children?: ElkChild[], edges?: Array> } let layoutCallArgs: ElkGraph | null = null @@ -32,7 +32,7 @@ vi.mock('elkjs/lib/elk.bundled.js', () => { } }) -const { getLayoutByDagre, getLayoutForChildNodes } = await import('../elk-layout') +const { getLayoutByELK, getLayoutForChildNodes } = await import('../elk-layout') function makeWorkflowNode(overrides: Omit, 'data'> & { data?: Partial & Record } = {}): Node { return createNode({ @@ -51,7 +51,7 @@ beforeEach(() => { mockReturnOverride = null }) -describe('getLayoutByDagre', () => { +describe('getLayoutByELK', () => { it('should return layout for simple linear graph', async () => { const nodes = [ makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), @@ -59,7 +59,7 @@ describe('getLayoutByDagre', () => { ] const edges = [makeWorkflowEdge({ source: 'a', target: 'b' })] - const result = await getLayoutByDagre(nodes, edges) + const result = await getLayoutByELK(nodes, edges) expect(result.nodes.size).toBe(2) expect(result.nodes.has('a')).toBe(true) @@ -74,7 +74,7 @@ describe('getLayoutByDagre', () => { makeWorkflowNode({ id: 'child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'a' }), ] - const result = await getLayoutByDagre(nodes, []) + const result = await getLayoutByELK(nodes, []) expect(result.nodes.size).toBe(1) expect(result.nodes.has('child')).toBe(false) }) @@ -85,7 +85,7 @@ describe('getLayoutByDagre', () => { makeWorkflowNode({ id: 'iter-start', type: CUSTOM_ITERATION_START_NODE, data: { type: BlockEnum.IterationStart, title: '', desc: '' } }), ] - const result = await getLayoutByDagre(nodes, []) + const result = await getLayoutByELK(nodes, []) expect(result.nodes.size).toBe(1) }) @@ -98,7 +98,7 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ source: 'a', target: 'b', data: { isInIteration: true, iteration_id: 'iter-1' } }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) expect(layoutCallArgs!.edges).toHaveLength(0) }) @@ -107,7 +107,7 @@ describe('getLayoutByDagre', () => { Reflect.deleteProperty(node, 'width') Reflect.deleteProperty(node, 'height') - const result = await getLayoutByDagre([node], []) + const result = await getLayoutByELK([node], []) expect(result.nodes.size).toBe(1) const info = result.nodes.get('a')! expect(info.width).toBe(244) @@ -133,13 +133,13 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! expect(ifElkNode.ports).toHaveLength(2) expect(ifElkNode.layoutOptions!['elk.portConstraints']).toBe('FIXED_ORDER') }) - it('should use normal node for IfElse with single branch', async () => { + it('should build ports for IfElse even with single branch', async () => { const nodes = [ makeWorkflowNode({ id: 'if-1', @@ -149,9 +149,10 @@ describe('getLayoutByDagre', () => { ] const edges = [makeWorkflowEdge({ source: 'if-1', target: 'b', sourceHandle: 'case-1' })] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! - expect(ifElkNode.ports).toBeUndefined() + expect(ifElkNode.ports).toHaveLength(1) + expect(ifElkNode.ports![0].layoutOptions!['elk.port.side']).toBe('EAST') }) it('should build ports for HumanInput nodes with multiple branches', async () => { @@ -168,12 +169,12 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! expect(hiElkNode.ports).toHaveLength(2) }) - it('should use normal node for HumanInput with single branch', async () => { + it('should build ports for HumanInput even with single branch', async () => { const nodes = [ makeWorkflowNode({ id: 'hi-1', @@ -183,20 +184,21 @@ describe('getLayoutByDagre', () => { ] const edges = [makeWorkflowEdge({ source: 'hi-1', target: 'b', sourceHandle: 'action-1' })] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! - expect(hiElkNode.ports).toBeUndefined() + expect(hiElkNode.ports).toHaveLength(1) + expect(hiElkNode.ports![0].layoutOptions!['elk.port.side']).toBe('EAST') }) it('should normalise bounds so minX and minY start at 0', async () => { const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })] - const result = await getLayoutByDagre(nodes, []) + const result = await getLayoutByELK(nodes, []) expect(result.bounds.minX).toBe(0) expect(result.bounds.minY).toBe(0) }) it('should return empty layout when no nodes match filter', async () => { - const result = await getLayoutByDagre([], []) + const result = await getLayoutByELK([], []) expect(result.nodes.size).toBe(0) expect(result.bounds).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 }) }) @@ -225,7 +227,7 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ id: 'e-b', source: 'if-1', target: 'y', sourceHandle: 'case-b' }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! const portIds = ifNode.ports!.map((p: { id: string }) => p.id) expect(portIds[portIds.length - 1]).toContain('false') @@ -247,7 +249,7 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ id: 'e-a2', source: 'hi-1', target: 'y', sourceHandle: 'a2' }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! const portIds = hiNode.ports!.map((p: { id: string }) => p.id) expect(portIds[portIds.length - 1]).toContain('__timeout') @@ -267,7 +269,7 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const portEdges = layoutCallArgs!.edges!.filter((e: Record) => e.sourcePort) expect(portEdges.length).toBeGreaterThan(0) }) @@ -286,7 +288,7 @@ describe('getLayoutByDagre', () => { Reflect.deleteProperty(e1, 'sourceHandle') Reflect.deleteProperty(e2, 'sourceHandle') - const result = await getLayoutByDagre(nodes, [e1, e2]) + const result = await getLayoutByELK(nodes, [e1, e2]) expect(result.nodes.size).toBeGreaterThan(0) }) @@ -299,7 +301,7 @@ describe('getLayoutByDagre', () => { }) const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })] - const result = await getLayoutByDagre(nodes, []) + const result = await getLayoutByELK(nodes, []) const info = result.nodes.get('a')! expect(info.x).toBe(0) expect(info.y).toBe(0) @@ -326,7 +328,7 @@ describe('getLayoutByDagre', () => { makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] - const result = await getLayoutByDagre(nodes, []) + const result = await getLayoutByELK(nodes, []) expect(result.nodes.get('a')!.layer).toBe(0) expect(result.nodes.get('b')!.layer).toBe(1) }) @@ -354,7 +356,7 @@ describe('getLayoutByDagre', () => { makeWorkflowNode({ id: 'nested-1', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'nested-2', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] - const result = await getLayoutByDagre(nodes, []) + const result = await getLayoutByELK(nodes, []) expect(result.nodes.has('nested-1')).toBe(true) expect(result.nodes.has('nested-2')).toBe(true) }) @@ -372,7 +374,7 @@ describe('getLayoutByDagre', () => { makeWorkflowNode({ id: 'visible', data: { type: BlockEnum.Start, title: '', desc: '' } }), makeWorkflowNode({ id: 'also-visible', data: { type: BlockEnum.Code, title: '', desc: '' } }), ] - const result = await getLayoutByDagre(nodes, []) + const result = await getLayoutByELK(nodes, []) expect(result.nodes.size).toBe(2) }) @@ -390,7 +392,7 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'other-unknown' }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! expect(ifNode.ports).toHaveLength(2) }) @@ -409,7 +411,7 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: 'another-unknown' }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! expect(hiNode.ports).toHaveLength(2) }) @@ -428,7 +430,7 @@ describe('getLayoutByDagre', () => { Reflect.deleteProperty(e1, 'sourceHandle') Reflect.deleteProperty(e2, 'sourceHandle') - await getLayoutByDagre(nodes, [e1, e2]) + await getLayoutByELK(nodes, [e1, e2]) const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! expect(ifNode.ports).toHaveLength(2) }) @@ -447,7 +449,7 @@ describe('getLayoutByDagre', () => { Reflect.deleteProperty(e1, 'sourceHandle') Reflect.deleteProperty(e2, 'sourceHandle') - await getLayoutByDagre(nodes, [e1, e2]) + await getLayoutByELK(nodes, [e1, e2]) const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! expect(hiNode.ports).toHaveLength(2) }) @@ -463,7 +465,7 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! expect(ifNode.ports).toHaveLength(2) }) @@ -479,7 +481,7 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! expect(hiNode.ports).toHaveLength(2) }) @@ -492,7 +494,7 @@ describe('getLayoutByDagre', () => { makeWorkflowEdge({ source: 'x', target: 'y', data: { isInLoop: true, loop_id: 'loop-1' } }), ] - await getLayoutByDagre(nodes, edges) + await getLayoutByELK(nodes, edges) expect(layoutCallArgs!.edges).toHaveLength(0) }) }) diff --git a/web/app/components/workflow/utils/elk-layout.ts b/web/app/components/workflow/utils/elk-layout.ts index 280d0f7b1d..9860bbc770 100644 --- a/web/app/components/workflow/utils/elk-layout.ts +++ b/web/app/components/workflow/utils/elk-layout.ts @@ -18,9 +18,6 @@ import { BlockEnum, } from '@/app/components/workflow/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 @@ -41,7 +38,6 @@ const ROOT_LAYOUT_OPTIONS = { // === 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', @@ -278,32 +274,16 @@ const collectLayout = (graph: ElkNode, predicate: (id: string) => boolean): Layo } } -/** - * 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 sortIfElseOutEdges = (ifElseNode: Node, outEdges: Edge[]): Edge[] => { + return [...outEdges].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) + if (handleA === 'false') return 1 - if (isBElse) + if (handleB === 'false') return -1 const indexA = cases.findIndex((c: CaseItem) => c.case_id === handleA) @@ -315,67 +295,20 @@ const buildIfElseWithPorts = ( 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, - } } -/** - * Build Human Input node with ELK native Ports for multiple branches - * Handles user actions as branches with __timeout as the last fixed branch - */ -const buildHumanInputWithPorts = ( - humanInputNode: Node, - edges: Edge[], -): { node: ElkNodeShape, portMap: Map } | null => { - const childEdges = edges.filter(edge => edge.source === humanInputNode.id) - - if (childEdges.length <= 1) - return null - - // Sort child edges: user actions first (by order), then __timeout last - const sortedChildEdges = [...childEdges].sort((edgeA, edgeB) => { +const sortHumanInputOutEdges = (humanInputNode: Node, outEdges: Edge[]): Edge[] => { + return [...outEdges].sort((edgeA, edgeB) => { const handleA = edgeA.sourceHandle const handleB = edgeB.sourceHandle if (handleA && handleB) { const userActions = (humanInputNode.data as HumanInputNodeType).user_actions || [] - const isATimeout = handleA === '__timeout' - const isBTimeout = handleB === '__timeout' - - // __timeout should always be last - if (isATimeout) + if (handleA === '__timeout') return 1 - if (isBTimeout) + if (handleB === '__timeout') return -1 - // Sort by user_actions order const indexA = userActions.findIndex(action => action.id === handleA) const indexB = userActions.findIndex(action => action.id === handleB) @@ -385,35 +318,6 @@ const buildHumanInputWithPorts = ( return 0 }) - - // Create ELK ports for each branch - const ports: ElkPortShape[] = sortedChildEdges.map((edge, index) => ({ - id: `${humanInputNode.id}-port-${edge.sourceHandle || index}`, - layoutOptions: { - 'port.side': 'EAST', - 'port.index': String(index), - }, - })) - - // Build port mapping: edge.id -> portId - const portMap = new Map() - sortedChildEdges.forEach((edge, index) => { - const portId = `${humanInputNode.id}-port-${edge.sourceHandle || index}` - portMap.set(edge.id, portId) - }) - - return { - node: { - id: humanInputNode.id, - width: humanInputNode.width ?? DEFAULT_NODE_WIDTH, - height: humanInputNode.height ?? DEFAULT_NODE_HEIGHT, - ports, - layoutOptions: { - 'elk.portConstraints': 'FIXED_ORDER', - }, - }, - portMap, - } } const normaliseBounds = (layout: LayoutResult): LayoutResult => { @@ -448,58 +352,87 @@ const normaliseBounds = (layout: LayoutResult): LayoutResult => { } } -export const getLayoutByDagre = async (originNodes: Node[], originEdges: Edge[]): Promise => { +export const getLayoutByELK = 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 and Human Input 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 if (node.data.type === BlockEnum.HumanInput) { - const portsResult = buildHumanInputWithPorts(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)) - } + const outEdgesByNode = new Map() + const inEdgesByNode = new Map() + edges.forEach((edge) => { + if (!outEdgesByNode.has(edge.source)) + outEdgesByNode.set(edge.source, []) + outEdgesByNode.get(edge.source)!.push(edge) + if (!inEdgesByNode.has(edge.target)) + inEdgesByNode.set(edge.target, []) + inEdgesByNode.get(edge.target)!.push(edge) }) - // Build edges with port connections - edges.forEach((edge) => { - const sourcePort = edgeToPortMap.get(edge.id) - elkEdges.push(createEdge(edge.source, edge.target, sourcePort)) + const elkNodes: ElkNodeShape[] = [] + const elkEdges: ElkEdgeShape[] = [] + const sourcePortMap = new Map() + const targetPortMap = new Map() + const sortedOutEdgesByNode = new Map() + + nodes.forEach((node) => { + const inEdges = inEdgesByNode.get(node.id) || [] + let outEdges = outEdgesByNode.get(node.id) || [] + + if (node.data.type === BlockEnum.IfElse) + outEdges = sortIfElseOutEdges(node, outEdges) + else if (node.data.type === BlockEnum.HumanInput) + outEdges = sortHumanInputOutEdges(node, outEdges) + + sortedOutEdgesByNode.set(node.id, outEdges) + + const ports: ElkPortShape[] = [] + + inEdges.forEach((edge, index) => { + const portId = `${node.id}-in-${index}` + ports.push({ + id: portId, + layoutOptions: { + 'elk.port.side': 'WEST', + 'elk.port.index': String(index), + }, + }) + targetPortMap.set(edge.id, portId) + }) + + outEdges.forEach((edge, index) => { + const portId = `${node.id}-out-${edge.sourceHandle || index}` + ports.push({ + id: portId, + layoutOptions: { + 'elk.port.side': 'EAST', + 'elk.port.index': String(index), + }, + }) + sourcePortMap.set(edge.id, portId) + }) + + elkNodes.push({ + id: node.id, + width: node.width ?? DEFAULT_NODE_WIDTH, + height: node.height ?? DEFAULT_NODE_HEIGHT, + ...(ports.length > 0 && { + ports, + layoutOptions: { 'elk.portConstraints': 'FIXED_ORDER' }, + }), + }) + }) + + // Build edges in sorted per-node order so PREFER_EDGES aligns with port order + nodes.forEach((node) => { + const outEdges = sortedOutEdgesByNode.get(node.id) || [] + outEdges.forEach((edge) => { + elkEdges.push(createEdge( + edge.source, + edge.target, + sourcePortMap.get(edge.id), + targetPortMap.get(edge.id), + )) + }) }) const graph = {