diff --git a/MEMORY.md b/MEMORY.md index 6de7c7f..44601ec 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -981,12 +981,13 @@ - 模型与 seed:`file_storage_backends` / `file_storage_mounts` / `file_index_entries` 已恢复注册;默认 seed 会创建 `files.vfs.default`、`files.s3.default` 与 `main` 挂载。 - 交互口径:页面保持“单挂载点”模式,不展示左侧挂载点切换面板。 -## ATP 查看器口径(2026-04-26) +## ATP 查看器口径(2026-04-26,2026-05-04 更新) - ATP 文本查看能力已落地在线路模块子路由:`/power-lines/atp-viewer`(内部路由 `web/src/app/admin/power-lines/atp-viewer/page.tsx`)。 - 技术栈固定为“前端本地解析 + 前端只读渲染”: - 解析:`web/src/lib/atp/parse-atp-text.ts` - - 渲染:`@maxgraph/core` + `web/src/components/atp-maxgraph-viewer.tsx` + - 渲染:`@antv/x6` + `web/src/components/atp-x6-viewer.tsx` + - 电气元件符号:组件内置 SVG data-uri 映射(R/L/C/SW/SRC/XFMR/LINE/CTRL/MISC)。 - 当前目标是“查看保真优先”,明确不包含仿真内核调用。 - 解析覆盖常见元件行格式,复杂 ATP 控制卡/模型卡默认容错跳过并输出 warnings,不阻断基础图形查看。 diff --git a/memory/2026-05-04.md b/memory/2026-05-04.md new file mode 100644 index 0000000..595bb88 --- /dev/null +++ b/memory/2026-05-04.md @@ -0,0 +1,26 @@ +## Work Log - ATP 渲染从 maxGraph 切换到 AntV X6(2026-05-04) + +- 背景: + - 用户要求“直接改成 AntV X6 + 一套电气符号 SVG 方案,去掉 maxgraph 方案”。 + +- 本次改动(最小闭环): + - 前端 ATP 渲染组件整体替换: + - 删除:`web/src/components/atp-maxgraph-viewer.tsx` + - 新增:`web/src/components/atp-x6-viewer.tsx` + - 方案:`@antv/x6` 只读渲染 + 自定义电气符号 SVG(按元件类型 R/L/C/SW/SRC/XFMR/LINE/CTRL/MISC 映射) + - 页面引用与文案同步: + - 文件:`web/src/app/admin/power-lines/atp-viewer/page.tsx` + - 组件引用从 `AtpMaxGraphViewer` 改为 `AtpX6Viewer` + - 卡片标题从“maxGraph 渲染结果”改为“X6 渲染结果” + - 依赖替换: + - `web/package.json`:移除 `@maxgraph/core`,新增 `@antv/x6` + - 锁文件同步:`web/package-lock.json`、根 `package-lock.json` + +- 验证: + - `npm run build:web` 通过(含编译、TypeScript、静态页生成)。 + - `npm run lint:web` 当前工程基线仍有大量历史问题(含 Cesium 产物目录与其他页面告警/错误),本次未新增针对性 lint 规则改动。 + +- 风险与影响: + - 影响面限定在 ATP 查看器前端渲染层与前端依赖。 + - ATP 图布局策略沿用原有节点位置推导逻辑;渲染引擎替换后视觉细节(标签锚点/线段路由)会与旧 maxGraph 有差异。 + - `web/.next/lock` 在部分异常终止场景可能残留,已在本次验证过程中手动清理后重跑构建。 diff --git a/package-lock.json b/package-lock.json index e14f639..ad41687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,6 +108,21 @@ "react": ">=16.9.0" } }, + "node_modules/@antv/x6": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@antv/x6/-/x6-3.1.7.tgz", + "integrity": "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw==", + "license": "MIT", + "dependencies": { + "dom-align": "^1.12.4", + "lodash-es": "^4.17.15", + "mousetrap": "^1.6.5", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -180,12 +195,6 @@ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "license": "MIT" }, - "node_modules/@maxgraph/core": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@maxgraph/core/-/core-0.23.0.tgz", - "integrity": "sha512-/ZbFaMKDJHg3352ANVDct09Rr7k5mLOZO+q3i0Hy2ODQiNvEnbooj4GkwB4Xaozyqfj/Q6JRT+zebdu4WzPyJg==", - "license": "Apache-2.0" - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -629,6 +638,12 @@ "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, + "node_modules/dom-align": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", + "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", @@ -719,6 +734,12 @@ "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==", "license": "Apache-2.0" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -752,6 +773,12 @@ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", "license": "MIT" }, + "node_modules/mousetrap": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", + "integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==", + "license": "Apache-2.0 WITH LLVM-exception" + }, "node_modules/nosleep.js": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", @@ -1521,6 +1548,15 @@ "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/web": { "resolved": "web", "link": true @@ -1529,7 +1565,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@maxgraph/core": "^0.23.0", + "@antv/x6": "^3.1.7", "@tanstack/react-query": "^5.90.5", "antd": "^5.29.3", "cesium": "^1.140.0", diff --git a/web/package-lock.json b/web/package-lock.json index 10fa8a1..0ca2cf0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@maxgraph/core": "^0.23.0", + "@antv/x6": "^3.1.7", "@tanstack/react-query": "^5.90.5", "antd": "^5.29.3", "cesium": "^1.140.0", @@ -140,6 +140,21 @@ "react": ">=16.9.0" } }, + "node_modules/@antv/x6": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@antv/x6/-/x6-3.1.7.tgz", + "integrity": "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw==", + "license": "MIT", + "dependencies": { + "dom-align": "^1.12.4", + "lodash-es": "^4.17.15", + "mousetrap": "^1.6.5", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "dev": true, @@ -670,12 +685,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@maxgraph/core": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@maxgraph/core/-/core-0.23.0.tgz", - "integrity": "sha512-/ZbFaMKDJHg3352ANVDct09Rr7k5mLOZO+q3i0Hy2ODQiNvEnbooj4GkwB4Xaozyqfj/Q6JRT+zebdu4WzPyJg==", - "license": "Apache-2.0" - }, "node_modules/@next/env": { "version": "16.2.3", "license": "MIT" @@ -2257,6 +2266,12 @@ "node": ">=0.10.0" } }, + "node_modules/dom-align": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", + "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", @@ -3926,6 +3941,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "dev": true, @@ -4038,6 +4059,12 @@ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", "license": "MIT" }, + "node_modules/mousetrap": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", + "integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==", + "license": "Apache-2.0 WITH LLVM-exception" + }, "node_modules/ms": { "version": "2.1.3", "dev": true, @@ -6090,6 +6117,15 @@ "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/web/package.json b/web/package.json index b1c4369..afc5110 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ "lint": "eslint" }, "dependencies": { - "@maxgraph/core": "^0.23.0", + "@antv/x6": "^3.1.7", "@tanstack/react-query": "^5.90.5", "antd": "^5.29.3", "cesium": "^1.140.0", diff --git a/web/src/app/admin/power-lines/atp-viewer/page.tsx b/web/src/app/admin/power-lines/atp-viewer/page.tsx index aea8e43..59dc108 100644 --- a/web/src/app/admin/power-lines/atp-viewer/page.tsx +++ b/web/src/app/admin/power-lines/atp-viewer/page.tsx @@ -23,7 +23,7 @@ import { import type { ColumnsType } from "antd/es/table"; import { useAuth } from "@/components/auth-provider"; -import { AtpMaxGraphViewer } from "@/components/atp-maxgraph-viewer"; +import { AtpX6Viewer } from "@/components/atp-x6-viewer"; import { Card } from "@/components/ui-antd"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; import { readApiError } from "@/lib/api"; @@ -1223,8 +1223,8 @@ export default function PowerLinesAtpViewerPage() { )} - - + + diff --git a/web/src/components/atp-maxgraph-viewer.tsx b/web/src/components/atp-maxgraph-viewer.tsx deleted file mode 100644 index f480383..0000000 --- a/web/src/components/atp-maxgraph-viewer.tsx +++ /dev/null @@ -1,319 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Button, Empty, Space, Typography } from "antd"; -import type { Graph } from "@maxgraph/core"; - -import type { AtpGraphEdge, AtpGraphJson, AtpGraphNode } from "@/lib/atp/types"; - -type AtpMaxGraphViewerProps = { - graph: AtpGraphJson | null; -}; - -type NodePosition = { - x: number; - y: number; -}; - -const H_SPACING = 220; -const V_SPACING = 128; - -const BUS_STYLE = { - shape: "rectangle", - rounded: true, - strokeColor: "#2f54eb", - fillColor: "#e6f4ff", - fontColor: "#10239e", - strokeWidth: 1.2, - fontSize: 12, - whiteSpace: "wrap", - spacing: 4, -} as const; - -const GROUND_STYLE = { - shape: "triangle", - direction: "north", - strokeColor: "#7f8c8d", - fillColor: "#f5f5f5", - fontColor: "#262626", - strokeWidth: 1, - fontSize: 11, -} as const; - -const EDGE_STYLE = { - shape: "connector", - edgeStyle: "orthogonalEdgeStyle", - orthogonalLoop: true, - rounded: true, - strokeColor: "#434343", - strokeWidth: 1.4, - endArrow: "none", - fontColor: "#262626", - fontSize: 11, - labelBackgroundColor: "#ffffff", - labelBorderColor: "#f0f0f0", -} as const; - -function buildEdgeLabel(edge: AtpGraphEdge): string { - return edge.value ? `${edge.name} (${edge.kind}) ${edge.value}` : `${edge.name} (${edge.kind})`; -} - -function positionKey(position: NodePosition): string { - return `${position.x}:${position.y}`; -} - -function reservePosition(occupied: Set, preferred: NodePosition): NodePosition { - if (!occupied.has(positionKey(preferred))) { - occupied.add(positionKey(preferred)); - return preferred; - } - - for (let offset = 1; offset <= 100; offset += 1) { - const candidateUp = { x: preferred.x, y: preferred.y - offset * V_SPACING }; - if (!occupied.has(positionKey(candidateUp))) { - occupied.add(positionKey(candidateUp)); - return candidateUp; - } - - const candidateDown = { x: preferred.x, y: preferred.y + offset * V_SPACING }; - if (!occupied.has(positionKey(candidateDown))) { - occupied.add(positionKey(candidateDown)); - return candidateDown; - } - } - - const fallback = { - x: preferred.x, - y: preferred.y + (occupied.size + 1) * V_SPACING, - }; - occupied.add(positionKey(fallback)); - return fallback; -} - -function computeNodePositions(nodes: AtpGraphNode[], edges: AtpGraphEdge[]): Map { - const positions = new Map(); - const occupied = new Set(); - - let nextSeedY = 0; - - for (const edge of edges) { - const sourcePosition = positions.get(edge.source); - const targetPosition = positions.get(edge.target); - - if (!sourcePosition && !targetPosition) { - const source = reservePosition(occupied, { x: 0, y: nextSeedY }); - const target = reservePosition(occupied, { x: H_SPACING, y: nextSeedY }); - positions.set(edge.source, source); - positions.set(edge.target, target); - nextSeedY += V_SPACING * 2; - continue; - } - - if (sourcePosition && !targetPosition) { - const target = reservePosition(occupied, { x: sourcePosition.x + H_SPACING, y: sourcePosition.y }); - positions.set(edge.target, target); - continue; - } - - if (!sourcePosition && targetPosition) { - const source = reservePosition(occupied, { x: targetPosition.x - H_SPACING, y: targetPosition.y }); - positions.set(edge.source, source); - } - } - - const unplaced = nodes.filter((node) => !positions.has(node.id)); - const columns = 4; - let col = 0; - let row = 0; - - for (const node of unplaced) { - const preferred = { - x: col * H_SPACING, - y: nextSeedY + row * V_SPACING, - }; - positions.set(node.id, reservePosition(occupied, preferred)); - - col += 1; - if (col >= columns) { - col = 0; - row += 1; - } - } - - return positions; -} - -export function AtpMaxGraphViewer({ graph }: AtpMaxGraphViewerProps) { - const containerRef = useRef(null); - const graphRef = useRef(null); - const [renderError, setRenderError] = useState(""); - - const positions = useMemo(() => { - if (!graph) { - return new Map(); - } - return computeNodePositions(graph.nodes, graph.edges); - }, [graph]); - - useEffect(() => { - const container = containerRef.current; - let disposed = false; - let createdGraph: Graph | null = null; - - async function renderGraph() { - if (!container) { - return; - } - - container.innerHTML = ""; - setRenderError(""); - - if (!graph || graph.nodes.length === 0 || graph.edges.length === 0) { - graphRef.current = null; - return; - } - - try { - const maxgraph = await import("@maxgraph/core"); - if (disposed || !container) { - return; - } - - const instance = new maxgraph.Graph(container); - createdGraph = instance; - graphRef.current = instance; - - instance.setEnabled(false); - instance.setCellsEditable(false); - instance.setCellsMovable(false); - instance.setCellsResizable(false); - instance.setCellsBendable(false); - instance.setCellsSelectable(true); - instance.setConnectable(false); - instance.setPanning(true); - instance.setTooltips(true); - - const parent = instance.getDefaultParent(); - const nodeCells = new Map>(); - - instance.batchUpdate(() => { - for (const node of graph.nodes) { - const position = positions.get(node.id) ?? { x: 0, y: 0 }; - const isGround = node.kind === "ground"; - const vertex = instance.insertVertex({ - parent, - id: `node_${node.id}`, - value: node.label, - position: [position.x, position.y], - size: isGround ? [80, 56] : [140, 46], - style: isGround ? GROUND_STYLE : BUS_STYLE, - }); - nodeCells.set(node.id, vertex); - } - - for (const edge of graph.edges) { - const source = nodeCells.get(edge.source); - const target = nodeCells.get(edge.target); - if (!source || !target) { - continue; - } - - instance.insertEdge({ - parent, - id: edge.id, - value: buildEdgeLabel(edge), - source, - target, - style: EDGE_STYLE, - }); - } - }); - - instance.zoomActual(); - instance.center(true, true); - } catch (error) { - const detail = error instanceof Error ? error.message : "maxGraph 渲染失败"; - setRenderError(detail); - } - } - - void renderGraph(); - - return () => { - disposed = true; - if (createdGraph) { - createdGraph.destroy(); - } - if (graphRef.current === createdGraph) { - graphRef.current = null; - } - if (container) { - container.innerHTML = ""; - } - }; - }, [graph, positions]); - - const handleFit = () => { - const instance = graphRef.current; - if (!instance) { - return; - } - - const bounds = instance.getGraphBounds(); - if (!bounds || bounds.width <= 0 || bounds.height <= 0) { - return; - } - - instance.zoomToRect(bounds); - }; - - const handleZoomIn = () => { - graphRef.current?.zoomIn(); - }; - - const handleZoomOut = () => { - graphRef.current?.zoomOut(); - }; - - const hasRenderableGraph = !!graph && graph.nodes.length > 0 && graph.edges.length > 0; - - return ( - - - - - - {graph && ( - - 节点 {graph.stats.node_count} / 元件 {graph.stats.element_count} - - )} - - - {renderError && } - -
-
- {!hasRenderableGraph && ( -
- -
- )} -
- - ); -} diff --git a/web/src/components/atp-x6-viewer.tsx b/web/src/components/atp-x6-viewer.tsx new file mode 100644 index 0000000..4554259 --- /dev/null +++ b/web/src/components/atp-x6-viewer.tsx @@ -0,0 +1,487 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Button, Empty, Space, Typography } from "antd"; +import type { Graph as X6Graph } from "@antv/x6"; + +import type { + AtpElementKind, + AtpGraphEdge, + AtpGraphJson, + AtpGraphNode, +} from "@/lib/atp/types"; + +type AtpX6ViewerProps = { + graph: AtpGraphJson | null; +}; + +type NodePosition = { + x: number; + y: number; +}; + +const H_SPACING = 220; +const V_SPACING = 128; + +const BUS_NODE_STYLE = { + body: { + fill: "#e6f4ff", + stroke: "#2f54eb", + strokeWidth: 1.2, + rx: 8, + ry: 8, + }, + label: { + fill: "#10239e", + fontSize: 12, + textAnchor: "middle", + textVerticalAnchor: "middle", + pointerEvents: "none", + }, +} as const; + +const GROUND_NODE_STYLE = { + body: { + fill: "#f5f5f5", + stroke: "#7f8c8d", + strokeWidth: 1, + }, + label: { + fill: "#262626", + fontSize: 11, + textAnchor: "middle", + textVerticalAnchor: "middle", + pointerEvents: "none", + }, +} as const; + +const EDGE_STYLE = { + line: { + stroke: "#434343", + strokeWidth: 1.4, + strokeLinecap: "round", + sourceMarker: null, + targetMarker: null, + }, +} as const; + +const EDGE_LABEL_MARKUP = [ + { + tagName: "rect", + selector: "panel", + }, + { + tagName: "image", + selector: "symbol", + }, + { + tagName: "text", + selector: "label", + }, +] as const; + +const EDGE_KIND_COLORS: Record = { + R: "#cf1322", + L: "#0958d9", + C: "#531dab", + SW: "#389e0d", + SRC: "#fa8c16", + XFMR: "#08979c", + LINE: "#595959", + CTRL: "#d46b08", + MISC: "#262626", +}; + +const EDGE_KINDS: AtpElementKind[] = [ + "R", + "L", + "C", + "SW", + "SRC", + "XFMR", + "LINE", + "CTRL", + "MISC", +]; + +const EDGE_SYMBOL_DATA_URI = EDGE_KINDS.reduce( + (acc, kind) => { + acc[kind] = buildEdgeSymbolDataUri(kind); + return acc; + }, + {} as Record, +); + +function buildEdgeLabelText(edge: AtpGraphEdge): string { + return edge.value ? `${edge.name} (${edge.kind}) ${edge.value}` : `${edge.name} (${edge.kind})`; +} + +function edgeSymbolGlyph(kind: AtpElementKind, color: string): string { + switch (kind) { + case "R": + return ``; + case "L": + return ``; + case "C": + return ``; + case "SW": + return ``; + case "SRC": + return ``; + case "XFMR": + return ``; + case "LINE": + return ``; + case "CTRL": + return `C`; + case "MISC": + default: + return ``; + } +} + +function encodeSvgAsDataUri(svg: string): string { + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +function buildEdgeSymbolDataUri(kind: AtpElementKind): string { + const color = EDGE_KIND_COLORS[kind]; + const glyph = edgeSymbolGlyph(kind, color); + + const svg = `${glyph}`; + return encodeSvgAsDataUri(svg); +} + +function buildEdgeLabel(edge: AtpGraphEdge) { + return { + markup: [...EDGE_LABEL_MARKUP], + attrs: { + panel: { + ref: "label", + refWidth: "118%", + refHeight: "190%", + refX: "-9%", + refY: "-75%", + fill: "rgba(255,255,255,0.9)", + stroke: "#e5e7eb", + strokeWidth: 1, + rx: 4, + ry: 4, + }, + symbol: { + xlinkHref: EDGE_SYMBOL_DATA_URI[edge.kind], + width: 48, + height: 24, + x: -24, + y: -30, + preserveAspectRatio: "xMidYMid meet", + pointerEvents: "none", + }, + label: { + text: buildEdgeLabelText(edge), + fill: "#262626", + fontSize: 11, + fontFamily: "JetBrains Mono, Menlo, monospace", + textAnchor: "middle", + textVerticalAnchor: "middle", + y: 4, + pointerEvents: "none", + }, + }, + position: { + distance: 0.5, + offset: -18, + options: { + keepGradient: false, + ensureLegibility: true, + }, + }, + }; +} + +function positionKey(position: NodePosition): string { + return `${position.x}:${position.y}`; +} + +function reservePosition(occupied: Set, preferred: NodePosition): NodePosition { + if (!occupied.has(positionKey(preferred))) { + occupied.add(positionKey(preferred)); + return preferred; + } + + for (let offset = 1; offset <= 100; offset += 1) { + const candidateUp = { x: preferred.x, y: preferred.y - offset * V_SPACING }; + if (!occupied.has(positionKey(candidateUp))) { + occupied.add(positionKey(candidateUp)); + return candidateUp; + } + + const candidateDown = { x: preferred.x, y: preferred.y + offset * V_SPACING }; + if (!occupied.has(positionKey(candidateDown))) { + occupied.add(positionKey(candidateDown)); + return candidateDown; + } + } + + const fallback = { + x: preferred.x, + y: preferred.y + (occupied.size + 1) * V_SPACING, + }; + occupied.add(positionKey(fallback)); + return fallback; +} + +function computeNodePositions(nodes: AtpGraphNode[], edges: AtpGraphEdge[]): Map { + const positions = new Map(); + const occupied = new Set(); + + let nextSeedY = 0; + + for (const edge of edges) { + const sourcePosition = positions.get(edge.source); + const targetPosition = positions.get(edge.target); + + if (!sourcePosition && !targetPosition) { + const source = reservePosition(occupied, { x: 0, y: nextSeedY }); + const target = reservePosition(occupied, { x: H_SPACING, y: nextSeedY }); + positions.set(edge.source, source); + positions.set(edge.target, target); + nextSeedY += V_SPACING * 2; + continue; + } + + if (sourcePosition && !targetPosition) { + const target = reservePosition(occupied, { x: sourcePosition.x + H_SPACING, y: sourcePosition.y }); + positions.set(edge.target, target); + continue; + } + + if (!sourcePosition && targetPosition) { + const source = reservePosition(occupied, { x: targetPosition.x - H_SPACING, y: targetPosition.y }); + positions.set(edge.source, source); + } + } + + const unplaced = nodes.filter((node) => !positions.has(node.id)); + const columns = 4; + let col = 0; + let row = 0; + + for (const node of unplaced) { + const preferred = { + x: col * H_SPACING, + y: nextSeedY + row * V_SPACING, + }; + positions.set(node.id, reservePosition(occupied, preferred)); + + col += 1; + if (col >= columns) { + col = 0; + row += 1; + } + } + + return positions; +} + +export function AtpX6Viewer({ graph }: AtpX6ViewerProps) { + const containerRef = useRef(null); + const graphRef = useRef(null); + const [renderError, setRenderError] = useState(""); + + const positions = useMemo(() => { + if (!graph) { + return new Map(); + } + return computeNodePositions(graph.nodes, graph.edges); + }, [graph]); + + useEffect(() => { + const container = containerRef.current; + let disposed = false; + let createdGraph: X6Graph | null = null; + + async function renderGraph() { + if (!container) { + return; + } + + container.innerHTML = ""; + setRenderError(""); + + if (!graph || graph.nodes.length === 0 || graph.edges.length === 0) { + graphRef.current = null; + return; + } + + try { + const { Graph } = await import("@antv/x6"); + if (disposed || !container) { + return; + } + + const instance = new Graph({ + container, + interacting: false, + panning: true, + mousewheel: { + enabled: true, + modifiers: ["ctrl", "meta"], + minScale: 0.25, + maxScale: 4, + zoomAtMousePosition: true, + }, + background: { + color: "#fcfdff", + }, + grid: false, + }); + + createdGraph = instance; + graphRef.current = instance; + + for (const node of graph.nodes) { + const position = positions.get(node.id) ?? { x: 0, y: 0 }; + const nodeId = `node_${node.id}`; + + if (node.kind === "ground") { + instance.addNode({ + id: nodeId, + shape: "ellipse", + x: position.x, + y: position.y, + width: 86, + height: 44, + label: node.label, + attrs: GROUND_NODE_STYLE, + }); + continue; + } + + instance.addNode({ + id: nodeId, + shape: "rect", + x: position.x, + y: position.y, + width: 140, + height: 46, + label: node.label, + attrs: BUS_NODE_STYLE, + }); + } + + for (const edge of graph.edges) { + instance.addEdge({ + id: edge.id, + source: `node_${edge.source}`, + target: `node_${edge.target}`, + router: { + name: "orth", + }, + connector: { + name: "rounded", + args: { + radius: 8, + }, + }, + attrs: EDGE_STYLE, + labels: [buildEdgeLabel(edge)], + }); + } + + instance.zoomToFit({ padding: 48, maxScale: 1 }); + instance.centerContent(); + } catch (error) { + const detail = error instanceof Error ? error.message : "X6 渲染失败"; + setRenderError(detail); + } + } + + void renderGraph(); + + return () => { + disposed = true; + if (createdGraph) { + createdGraph.dispose(); + } + if (graphRef.current === createdGraph) { + graphRef.current = null; + } + if (container) { + container.innerHTML = ""; + } + }; + }, [graph, positions]); + + const handleFit = () => { + const instance = graphRef.current; + if (!instance) { + return; + } + + instance.zoomToFit({ padding: 48, maxScale: 1 }); + instance.centerContent(); + }; + + const handleZoomIn = () => { + const instance = graphRef.current; + if (!instance) { + return; + } + + const next = Math.min(instance.zoom() + 0.12, 4); + instance.zoom(next, { absolute: true }); + }; + + const handleZoomOut = () => { + const instance = graphRef.current; + if (!instance) { + return; + } + + const next = Math.max(instance.zoom() - 0.12, 0.25); + instance.zoom(next, { absolute: true }); + }; + + const hasRenderableGraph = !!graph && graph.nodes.length > 0 && graph.edges.length > 0; + + return ( + + + + + + {graph && ( + + 节点 {graph.stats.node_count} / 元件 {graph.stats.element_count} + + )} + + + {renderError && } + +
+
+ {!hasRenderableGraph && ( +
+ +
+ )} +
+ + ); +}