Files
fquiz/web/src/components/atp-x6-viewer.tsx
T
2026-05-04 07:14:28 +08:00

488 lines
13 KiB
TypeScript

"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>
);
}