替换ATP渲染为AntV X6并移除maxgraph

This commit is contained in:
chengkai3
2026-05-04 01:23:05 +08:00
parent 345860af14
commit cf70068cbb
8 changed files with 606 additions and 339 deletions
+3 -2
View File
@@ -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-262026-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,不阻断基础图形查看。
+26
View File
@@ -0,0 +1,26 @@
## Work Log - ATP 渲染从 maxGraph 切换到 AntV X62026-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` 在部分异常终止场景可能残留,已在本次验证过程中手动清理后重跑构建。
+43 -7
View File
@@ -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",
+43 -7
View File
@@ -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,
+1 -1
View File
@@ -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",
@@ -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() {
)}
</Card>
<Card title="maxGraph 渲染结果">
<AtpMaxGraphViewer graph={graphJson} />
<Card title="X6 渲染结果">
<AtpX6Viewer graph={graphJson} />
</Card>
</div>
</Space>
-319
View File
@@ -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<string>, 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<string, NodePosition> {
const positions = new Map<string, NodePosition>();
const occupied = new Set<string>();
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<HTMLDivElement | null>(null);
const graphRef = useRef<Graph | null>(null);
const [renderError, setRenderError] = useState("");
const positions = useMemo(() => {
if (!graph) {
return new Map<string, NodePosition>();
}
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<string, ReturnType<Graph["insertVertex"]>>();
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 (
<Space direction="vertical" size={12} className="w-full">
<Space size={8} wrap>
<Button size="small" onClick={handleFit} disabled={!hasRenderableGraph}>
</Button>
<Button size="small" onClick={handleZoomIn} disabled={!hasRenderableGraph}>
</Button>
<Button size="small" onClick={handleZoomOut} disabled={!hasRenderableGraph}>
</Button>
{graph && (
<Typography.Text type="secondary">
{graph.stats.node_count} / {graph.stats.element_count}
</Typography.Text>
)}
</Space>
{renderError && <Alert type="error" showIcon message="渲染失败" description={renderError} />}
<div className="relative min-h-[560px] w-full overflow-hidden rounded border border-gray-200 bg-[#fcfdff]">
<div
ref={containerRef}
className="h-[560px] w-full"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, rgba(31, 35, 41, 0.08) 1px, transparent 0)",
backgroundSize: "20px 20px",
}}
/>
{!hasRenderableGraph && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Empty description="暂无可渲染图形,先完成 ATP 转换。" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
)}
</div>
</Space>
);
}
+487
View File
@@ -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<AtpElementKind, string> = {
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<AtpElementKind, string>,
);
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 `<polyline points="18,16 22,11 26,21 30,11 34,21 38,11 42,16" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
case "L":
return `<path d="M 18 16 C 20 8, 24 8, 26 16 C 28 8, 32 8, 34 16 C 36 8, 40 8, 42 16" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
case "C":
return `<line x1="26" y1="9" x2="26" y2="23" stroke="${color}" stroke-width="2"/><line x1="38" y1="9" x2="38" y2="23" stroke="${color}" stroke-width="2"/>`;
case "SW":
return `<circle cx="24" cy="16" r="1.8" fill="${color}"/><circle cx="40" cy="16" r="1.8" fill="${color}"/><line x1="24" y1="16" x2="40" y2="10" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
case "SRC":
return `<circle cx="32" cy="16" r="8" fill="none" stroke="${color}" stroke-width="2"/><path d="M 27 16 C 28.5 12.5, 30.5 12.5, 32 16 C 33.5 19.5, 35.5 19.5, 37 16" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/>`;
case "XFMR":
return `<path d="M 24 10 C 22 12, 22 20, 24 22 C 26 20, 26 12, 24 10" fill="none" stroke="${color}" stroke-width="2"/><path d="M 32 10 C 30 12, 30 20, 32 22 C 34 20, 34 12, 32 10" fill="none" stroke="${color}" stroke-width="2"/><path d="M 40 10 C 38 12, 38 20, 40 22 C 42 20, 42 12, 40 10" fill="none" stroke="${color}" stroke-width="2"/>`;
case "LINE":
return `<line x1="24" y1="9" x2="40" y2="23" stroke="${color}" stroke-width="2"/><line x1="24" y1="23" x2="40" y2="9" stroke="${color}" stroke-width="2"/>`;
case "CTRL":
return `<rect x="23" y="10" width="18" height="12" rx="3" ry="3" fill="none" stroke="${color}" stroke-width="2"/><text x="32" y="19" text-anchor="middle" font-size="8" font-family="Arial, sans-serif" fill="${color}">C</text>`;
case "MISC":
default:
return `<polygon points="32,9 40,16 32,23 24,16" fill="none" stroke="${color}" stroke-width="2"/>`;
}
}
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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"><line x1="2" y1="16" x2="18" y2="16" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/><line x1="46" y1="16" x2="62" y2="16" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/>${glyph}</svg>`;
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<string>, 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<string, NodePosition> {
const positions = new Map<string, NodePosition>();
const occupied = new Set<string>();
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<HTMLDivElement | null>(null);
const graphRef = useRef<X6Graph | null>(null);
const [renderError, setRenderError] = useState("");
const positions = useMemo(() => {
if (!graph) {
return new Map<string, NodePosition>();
}
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 (
<Space direction="vertical" size={12} className="w-full">
<Space size={8} wrap>
<Button size="small" onClick={handleFit} disabled={!hasRenderableGraph}>
</Button>
<Button size="small" onClick={handleZoomIn} disabled={!hasRenderableGraph}>
</Button>
<Button size="small" onClick={handleZoomOut} disabled={!hasRenderableGraph}>
</Button>
{graph && (
<Typography.Text type="secondary">
{graph.stats.node_count} / {graph.stats.element_count}
</Typography.Text>
)}
</Space>
{renderError && <Alert type="error" showIcon message="渲染失败" description={renderError} />}
<div className="relative min-h-[560px] w-full overflow-hidden rounded border border-gray-200 bg-[#fcfdff]">
<div
ref={containerRef}
className="h-[560px] w-full"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, rgba(31, 35, 41, 0.08) 1px, transparent 0)",
backgroundSize: "20px 20px",
}}
/>
{!hasRenderableGraph && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Empty description="暂无可渲染图形,先完成 ATP 转换。" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
)}
</div>
</Space>
);
}