From dd252eddc0bd7a857e2555cca0f7c73ea72eab69 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 14 Mar 2026 18:35:52 +0800 Subject: [PATCH] feat(dashboard): add dagre auto-layout for hierarchical graph visualization Replace grid-based node positioning with dagre-powered hierarchical layout that computes positions based on edge direction (top-to-bottom). This produces clean, readable graphs instead of the previous chaotic grid pattern. Co-Authored-By: Claude Opus 4.6 --- packages/dashboard/package.json | 3 +- .../dashboard/src/components/GraphView.tsx | 26 ++++++----- packages/dashboard/src/utils/layout.ts | 44 +++++++++++++++++++ pnpm-lock.yaml | 15 +++++++ 4 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 packages/dashboard/src/utils/layout.ts diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 66a1f64..b5b5358 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -9,9 +9,10 @@ "preview": "vite preview" }, "dependencies": { + "@dagrejs/dagre": "^2.0.4", + "@monaco-editor/react": "^4.7.0", "@understand-anything/core": "workspace:*", "@xyflow/react": "^12.0.0", - "@monaco-editor/react": "^4.7.0", "react": "^19.0.0", "react-dom": "^19.0.0", "zustand": "^5.0.0" diff --git a/packages/dashboard/src/components/GraphView.tsx b/packages/dashboard/src/components/GraphView.tsx index 0a8a78f..9c389eb 100644 --- a/packages/dashboard/src/components/GraphView.tsx +++ b/packages/dashboard/src/components/GraphView.tsx @@ -13,6 +13,7 @@ import "@xyflow/react/dist/style.css"; import CustomNode from "./CustomNode"; import type { CustomFlowNode } from "./CustomNode"; import { useDashboardStore } from "../store"; +import { applyDagreLayout } from "../utils/layout.ts"; const nodeTypes = { custom: CustomNode }; @@ -22,15 +23,13 @@ export default function GraphView() { const searchResults = useDashboardStore((s) => s.searchResults); const selectNode = useDashboardStore((s) => s.selectNode); - const initialNodes = useMemo(() => { - if (!graph) return []; - return graph.nodes.map((node, i) => ({ + const { initialNodes, initialEdges } = useMemo(() => { + if (!graph) return { initialNodes: [] as CustomFlowNode[], initialEdges: [] as Edge[] }; + + const flowNodes: CustomFlowNode[] = graph.nodes.map((node) => ({ id: node.id, type: "custom" as const, - position: { - x: (i % 3) * 300 + 50, - y: Math.floor(i / 3) * 200 + 50, - }, + position: { x: 0, y: 0 }, data: { label: node.name, nodeType: node.type, @@ -40,11 +39,8 @@ export default function GraphView() { isSelected: selectedNodeId === node.id, }, })); - }, [graph, searchResults, selectedNodeId]); - const initialEdges = useMemo(() => { - if (!graph) return []; - return graph.edges.map((edge, i) => ({ + const flowEdges: Edge[] = graph.edges.map((edge, i) => ({ id: `e-${i}`, source: edge.source, target: edge.target, @@ -53,7 +49,13 @@ export default function GraphView() { style: { stroke: "#6b7280", strokeWidth: 1.5 }, labelStyle: { fill: "#9ca3af", fontSize: 10 }, })); - }, [graph]); + + const laid = applyDagreLayout(flowNodes, flowEdges); + return { + initialNodes: laid.nodes as CustomFlowNode[], + initialEdges: laid.edges, + }; + }, [graph, searchResults, selectedNodeId]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); diff --git a/packages/dashboard/src/utils/layout.ts b/packages/dashboard/src/utils/layout.ts new file mode 100644 index 0000000..3f8b9c2 --- /dev/null +++ b/packages/dashboard/src/utils/layout.ts @@ -0,0 +1,44 @@ +import dagre from "@dagrejs/dagre"; +import type { Node, Edge } from "@xyflow/react"; + +const NODE_WIDTH = 280; +const NODE_HEIGHT = 120; + +export function applyDagreLayout( + nodes: Node[], + edges: Edge[], + direction: "TB" | "LR" = "TB", +): { nodes: Node[]; edges: Edge[] } { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ + rankdir: direction, + nodesep: 60, + ranksep: 80, + marginx: 20, + marginy: 20, + }); + + nodes.forEach((node) => { + g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + }); + + edges.forEach((edge) => { + g.setEdge(edge.source, edge.target); + }); + + dagre.layout(g); + + const layoutedNodes = nodes.map((node) => { + const pos = g.node(node.id); + return { + ...node, + position: { + x: pos.x - NODE_WIDTH / 2, + y: pos.y - NODE_HEIGHT / 2, + }, + }; + }); + + return { nodes: layoutedNodes, edges }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ff7fda..6cf90c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: packages/dashboard: dependencies: + '@dagrejs/dagre': + specifier: ^2.0.4 + version: 2.0.4 '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -171,6 +174,12 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@dagrejs/dagre@2.0.4': + resolution: {integrity: sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==} + + '@dagrejs/graphlib@3.0.4': + resolution: {integrity: sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1510,6 +1519,12 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@dagrejs/dagre@2.0.4': + dependencies: + '@dagrejs/graphlib': 3.0.4 + + '@dagrejs/graphlib@3.0.4': {} + '@esbuild/aix-ppc64@0.25.12': optional: true