From 484ed520cd0c00eb73ba2fad798b65d1b0bd47f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Wed, 13 May 2026 16:26:03 +0800 Subject: [PATCH] feat(dashboard): switch graph layout from Dagre to ELK What: Replace Dagre layout engine with ELK (Eclipse Layout Kernel) for workflow graph visualization in the dashboard. Why: Dagre lacks support for edge label placement and orthogonal edge routing, causing condition labels to overlap with nodes. ELK provides proper label positioning, better edge routing, and more compact layouts. Changes: - packages/workflow-dashboard/package.json: add elkjs dependency - packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts: rewrite layout from Dagre to async ELK with layered algorithm, orthogonal routing, reduced spacing for compactness - packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx: use ELK-computed label positions, show all labels including FALLBACK, switch to getSmoothStepPath for all edges - packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx: wrap in ReactFlowProvider, add fitView on async layout change, key-based remount for layout stability - packages/workflow-dashboard/src/components/workflow-list.tsx: left-right layout (info left, graph right), fix toggleExpanded React 18 batching bug, increase graph container height --- packages/workflow-dashboard/package.json | 1 + .../workflow-graph/condition-edge.tsx | 50 +++--- .../components/workflow-graph/use-layout.ts | 162 +++++++++++++----- .../workflow-graph/workflow-graph.tsx | 44 ++++- .../src/components/workflow-list.tsx | 74 ++++---- 5 files changed, 227 insertions(+), 104 deletions(-) diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json index 75cba22..a7faa75 100644 --- a/packages/workflow-dashboard/package.json +++ b/packages/workflow-dashboard/package.json @@ -11,6 +11,7 @@ "dependencies": { "@dagrejs/dagre": "^3.0.0", "@xyflow/react": "^12.10.2", + "elkjs": "^0.11.1", "react": "^19.2.6", "react-dom": "^19.2.6", "react-markdown": "^10.1.0", diff --git a/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx index 28fb962..fffc9d4 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx @@ -2,7 +2,6 @@ import { BaseEdge, EdgeLabelRenderer, type EdgeProps, - getBezierPath, getSmoothStepPath, } from "@xyflow/react"; import type { ConditionEdgeData } from "./types.ts"; @@ -21,31 +20,28 @@ export function ConditionEdge(props: EdgeProps) { data, markerEnd, } = props; - const edgeData = data as ConditionEdgeData | undefined; + const edgeData = data as (ConditionEdgeData & { elkLabelX?: number | null; elkLabelY?: number | null }) | undefined; const isFallback = edgeData?.isFallback ?? false; const isSelfLoop = source === target; - const [path, labelX, labelY] = isSelfLoop - ? getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - borderRadius: 20, - }) - : getBezierPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - }); + const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: isSelfLoop ? 20 : 8, + offset: isSelfLoop ? 50 : undefined, + }); - const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)"; + const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)"; const strokeDasharray = isFallback ? "5 4" : undefined; + const label = edgeData?.condition ?? ""; + + // Use ELK-computed label position if available, otherwise fall back to ReactFlow default + const labelX = edgeData?.elkLabelX ?? defaultLabelX; + const labelY = edgeData?.elkLabelY ?? defaultLabelY; return ( <> @@ -55,19 +51,21 @@ export function ConditionEdge(props: EdgeProps) { markerEnd={markerEnd} style={{ stroke, strokeWidth: 1.5, strokeDasharray }} /> - {edgeData && !isFallback && edgeData.condition !== "" && ( + {label !== "" && (
- {edgeData.condition} + {label}
)} diff --git a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts index 5dd64fd..ac022ad 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts +++ b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts @@ -1,6 +1,6 @@ -import Dagre from "@dagrejs/dagre"; import type { Edge, Node } from "@xyflow/react"; -import { useMemo } from "react"; +import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js"; +import { useEffect, useState } from "react"; import type { WorkflowGraphEdge } from "../../api.ts"; import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts"; @@ -37,6 +37,10 @@ function nodeSize(id: string): { width: number; height: number } { return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT }; } +function edgeKey(e: WorkflowGraphEdge): string { + return `${e.from}->${e.to}::${e.condition}`; +} + function buildRoleNode( id: string, pos: { x: number; y: number }, @@ -68,14 +72,24 @@ function buildTerminalNode( }; } -function edgeKey(e: WorkflowGraphEdge): string { - return `${e.from}->${e.to}::${e.condition}`; -} - -function buildEdge(e: WorkflowGraphEdge): Edge { +function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map): Edge { const isFallback = e.condition === "FALLBACK"; + const key = edgeKey(e); + const elkEdge = elkEdgeMap.get(key); + + // Extract ELK's computed label position + let labelX: number | null = null; + let labelY: number | null = null; + if (elkEdge?.labels && elkEdge.labels.length > 0) { + const label = elkEdge.labels[0]; + if (label.x !== undefined && label.y !== undefined) { + labelX = label.x + (label.width ?? 0) / 2; + labelY = label.y + (label.height ?? 0) / 2; + } + } + return { - id: edgeKey(e), + id: key, source: e.from, target: e.to, type: "condition", @@ -83,45 +97,111 @@ function buildEdge(e: WorkflowGraphEdge): Edge { condition: e.condition, conditionDescription: e.conditionDescription, isFallback, - }, + elkLabelX: labelX, + elkLabelY: labelY, + } as ConditionEdgeData, }; } -export function useLayout(input: LayoutInput): LayoutResult { - return useMemo(() => { - const ids = collectNodeIds(input.edges); +const elk = new ELK(); - const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 }); +async function computeLayout(input: LayoutInput): Promise { + const ids = collectNodeIds(input.edges); - for (const id of ids) { - const size = nodeSize(id); - g.setNode(id, { width: size.width, height: size.height }); - } - for (const e of input.edges) { - if (e.from === e.to) { - continue; - } - g.setEdge(e.from, e.to, {}, edgeKey(e)); + const elkNodes: ElkNode[] = []; + for (const id of ids) { + const size = nodeSize(id); + elkNodes.push({ id, width: size.width, height: size.height }); + } + + const elkEdges: ElkExtendedEdge[] = input.edges + .filter((e) => e.from !== e.to) + .map((e) => ({ + id: edgeKey(e), + sources: [e.from], + targets: [e.to], + labels: e.condition !== "" + ? [{ text: e.condition, width: Math.max(e.condition.length * 7 + 16, 60), height: 22 }] + : [], + })); + + const graph: ElkNode = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + // Node spacing + "elk.spacing.nodeNode": "30", + "elk.layered.spacing.nodeNodeBetweenLayers": "50", + // Edge spacing — keep edges apart from each other and from nodes + "elk.spacing.edgeNode": "25", + "elk.spacing.edgeEdge": "15", + "elk.layered.spacing.edgeNodeBetweenLayers": "25", + "elk.layered.spacing.edgeEdgeBetweenLayers": "15", + // Edge routing + "elk.edgeRouting": "ORTHOGONAL", + "elk.layered.mergeEdges": "false", + // Node placement + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + // Edge label placement + "elk.edgeLabels.placement": "CENTER", + // Crossing minimization + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + // Compaction + "elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH", + // Cycle breaking — keep main flow top-to-bottom + "elk.layered.cycleBreaking.strategy": "DEPTH_FIRST", + }, + children: elkNodes, + edges: elkEdges, + }; + + const laid = await elk.layout(graph); + + // Build map of ELK edge results for label positions + const elkEdgeMap = new Map(); + for (const e of laid.edges ?? []) { + elkEdgeMap.set(e.id, e); + } + + const nodes: Node[] = []; + for (const child of laid.children ?? []) { + const pos = { x: child.x ?? 0, y: child.y ?? 0 }; + const state = input.nodeStates.get(child.id) ?? "default"; + if (child.id === START_ID || child.id === END_ID) { + nodes.push(buildTerminalNode(child.id, pos, state)); + } else { + nodes.push(buildRoleNode(child.id, pos, input.roles, state)); } + } - Dagre.layout(g); + const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap)); - const nodes: Node[] = []; - for (const id of ids) { - const dagNode = g.node(id); - const size = nodeSize(id); - const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 }; - const state = input.nodeStates.get(id) ?? "default"; - if (id === START_ID || id === END_ID) { - nodes.push(buildTerminalNode(id, pos, state)); - } else { - nodes.push(buildRoleNode(id, pos, input.roles, state)); - } - } - - const edges: Edge[] = input.edges.map(buildEdge); - - return { nodes, edges }; - }, [input.edges, input.roles, input.nodeStates]); + return { nodes, edges }; +} + +const EMPTY_LAYOUT: LayoutResult = { nodes: [], edges: [] }; + +export function useLayout(input: LayoutInput): LayoutResult { + const [layout, setLayout] = useState(EMPTY_LAYOUT); + + const edgeJson = JSON.stringify(input.edges); + const roleJson = JSON.stringify(input.roles); + + useEffect(() => { + let cancelled = false; + const parsed = { + edges: JSON.parse(edgeJson) as readonly WorkflowGraphEdge[], + roles: JSON.parse(roleJson) as Record, + nodeStates: input.nodeStates, + }; + computeLayout(parsed).then((result) => { + if (!cancelled) setLayout(result); + }); + return () => { + cancelled = true; + }; + }, [edgeJson, roleJson, input.nodeStates]); + + return layout; } diff --git a/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx index f0e6871..1fb3889 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx @@ -6,9 +6,11 @@ import { type NodeTypes, type OnNodeClick, ReactFlow, + ReactFlowProvider, + useReactFlow, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts"; import { ConditionEdge } from "./condition-edge.tsx"; import { RoleNode } from "./role-node.tsx"; @@ -37,12 +39,30 @@ function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node onRoleClick(node.id); } -export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) { +function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) { const layout = useLayout({ edges: graph.edges, roles, nodeStates }); + const { fitView } = useReactFlow(); const onNodeClickHandler: OnNodeClick | undefined = onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined; + // Re-fit when layout changes (ELK is async) + // Use requestAnimationFrame + setTimeout to ensure ReactFlow has processed nodes + useEffect(() => { + if (layout.nodes.length > 0) { + let cancelled = false; + requestAnimationFrame(() => { + if (cancelled) return; + setTimeout(() => { + if (!cancelled) fitView({ padding: 0.1, duration: 300 }); + }, 300); + }); + return () => { + cancelled = true; + }; + } + }, [layout.nodes, layout.edges, fitView]); + const styledEdges = useMemo( () => layout.edges.map((e) => ({ @@ -57,15 +77,25 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) [layout.edges], ); + // Generate a stable key that changes when layout changes, to force ReactFlow remount + fitView + const layoutKey = useMemo( + () => layout.nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","), + [layout.nodes], + ); + return ( ); } + +export function WorkflowGraph(props: Props) { + return ( + + + + ); +} diff --git a/packages/workflow-dashboard/src/components/workflow-list.tsx b/packages/workflow-dashboard/src/components/workflow-list.tsx index 0a49de3..7cbe27f 100644 --- a/packages/workflow-dashboard/src/components/workflow-list.tsx +++ b/packages/workflow-dashboard/src/components/workflow-list.tsx @@ -45,38 +45,45 @@ function ExpandedWorkflowBody({ const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0; const vc = versionCount(detail); + const hasGraph = descriptor !== null && edgeCount > 0; + return ( -
-
-

- {detail.name} +

+
+
+

+ {detail.name} +

+

+ Hash +

+ + {detail.hash} + +
+

+ {vc} version{vc !== 1 ? "s" : ""}

-

- Hash -

- - {detail.hash} - +
+

+ Description +

+

+ {descriptor !== null && descriptor.description !== "" + ? descriptor.description + : descriptor !== null + ? "—" + : "No descriptor available for this workflow version."} +

+
-

- {vc} version{vc !== 1 ? "s" : ""} -

-
-

- Description -

-

- {descriptor !== null && descriptor.description !== "" - ? descriptor.description - : descriptor !== null - ? "—" - : "No descriptor available for this workflow version."} -

-
- {descriptor !== null && edgeCount > 0 ? ( + {hasGraph ? (
-
+
{ const next = new Set(prev); if (next.has(name)) { next.delete(name); - return next; + } else { + next.add(name); } - next.add(name); - shouldLoad = true; return next; }); - if (shouldLoad) { + if (!wasExpanded) { ensureDetailLoaded(name); } }