import { useState, useEffect, useRef, useCallback } from 'react' import { forceSimulation, forceLink, forceManyBody, forceCenter, type SimulationNodeDatum, } from 'd3-force' import type { TopologyNode, TopologyEdge, TopologyData } from '@/types/api' export interface LayoutNode extends SimulationNodeDatum { id: string address: string compressedAddress?: string isSelf: boolean deviceName?: string nodeInfoFullRevision?: number nodeInfoFullRevisionEpoch?: string x: number y: number } export interface LayoutEdge { id: string source: string target: string delay: number cost: number } interface UseTopologyResult { nodes: LayoutNode[] edges: LayoutEdge[] isLoading: boolean error: string | null relayout: () => void } const POLL_INTERVAL_MS = 1000 function structureSignature(nodes: TopologyNode[], edges: TopologyEdge[]): string { const n = nodes.map((x) => x.id).sort().join(',') const e = edges.map((x) => `${x.source}->${x.target}`).sort().join(',') return `${n}|${e}` } function computeForceLayout( topoNodes: TopologyNode[], topoEdges: TopologyEdge[] ): Map { const positions = new Map() if (topoNodes.length === 0) return positions // 节点卡片较宽(~300px),初始环形半径与斥力需足够大以避免重叠 const simNodes: LayoutNode[] = topoNodes.map((n, i) => ({ ...n, x: Math.cos((2 * Math.PI * i) / Math.max(topoNodes.length, 1)) * 450, y: Math.sin((2 * Math.PI * i) / Math.max(topoNodes.length, 1)) * 450, })) const nodeIds = new Set(simNodes.map((n) => n.id)) const simLinks = topoEdges .filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target)) .map((e) => { const cost = e.cost > 0 ? e.cost : e.delay // 延迟越高距离越远;基准距离大于卡片宽度,保证相邻节点不挤压 const distance = Math.max(280, Math.min(650, 300 + Math.log10(Math.max(cost, 1)) * 35)) return { source: e.source, target: e.target, distance } }) const simulation = forceSimulation(simNodes) .force('charge', forceManyBody().strength(-2200)) .force( 'link', forceLink(simLinks) .id((d: SimulationNodeDatum) => (d as LayoutNode).id) .distance((l: any) => l.distance ?? 300) .strength(0.5) ) .force('center', forceCenter(0, 0)) .stop() // 同步运行固定迭代数,提取最终坐标 const ticks = 300 for (let i = 0; i < ticks; i++) simulation.tick() for (const n of simNodes) { positions.set(n.id, { x: n.x ?? 0, y: n.y ?? 0 }) } return positions } function makeEdgeId(source: string, target: string): string { return source < target ? `${source}__${target}` : `${target}__${source}` } export function useTopology(): UseTopologyResult { const [nodes, setNodes] = useState([]) const [edges, setEdges] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const lastSignatureRef = useRef('') const savedPositionsRef = useRef>(new Map()) const layoutPositionsRef = useRef>(new Map()) const relayoutRequestedRef = useRef(false) const mountedRef = useRef(true) const fetchTopology = useCallback(async () => { try { const res = await fetch('/api/nodes') if (!res.ok) { setError(`拓扑接口异常 (${res.status})`) return } const payload = (await res.json()) as Partial if (!mountedRef.current) return const topoNodes: TopologyNode[] = Array.isArray(payload.nodes) ? payload.nodes.filter( (n) => n && typeof n.id === 'string' && n.id !== '' ).map((n) => ({ ...n, nodeInfoFullRevision: n.nodeInfoFullRevision ?? 0, nodeInfoFullRevisionEpoch: n.nodeInfoFullRevisionEpoch ?? '', })) : [] const topoEdgesRaw: TopologyEdge[] = Array.isArray(payload.edges) ? payload.edges : [] // 边去重(后端已做一次,这里再保险)并生成稳定 id const edgeMap = new Map() for (const e of topoEdgesRaw) { if (!e.source || !e.target || e.source === e.target) continue const id = makeEdgeId(e.source, e.target) edgeMap.set(id, { ...e }) } const topoEdges = Array.from(edgeMap.values()) const signature = structureSignature(topoNodes, topoEdges) const structureChanged = signature !== lastSignatureRef.current || relayoutRequestedRef.current if (structureChanged) { let positions: Map if ( !relayoutRequestedRef.current && layoutPositionsRef.current.size > 0 && lastSignatureRef.current === '' ) { positions = layoutPositionsRef.current } else { positions = computeForceLayout(topoNodes, topoEdges) } // 合并用户拖拽过的位置 for (const [id, pos] of savedPositionsRef.current) { if (positions.has(id)) positions.set(id, pos) } layoutPositionsRef.current = positions lastSignatureRef.current = signature relayoutRequestedRef.current = false setNodes( topoNodes.map((n) => ({ ...n, ...(positions.get(n.id) ?? { x: 0, y: 0 }), })) ) } else { // 结构未变:仅原地刷新延迟等元数据,位置保持不变 setNodes((prev) => prev.length === topoNodes.length ? prev.map((n) => { const fresh = topoNodes.find((t) => t.id === n.id) return fresh ? { ...n, deviceName: fresh.deviceName, nodeInfoFullRevision: fresh.nodeInfoFullRevision, nodeInfoFullRevisionEpoch: fresh.nodeInfoFullRevisionEpoch, } : n }) : prev ) } setEdges( topoEdges.map((e) => ({ id: makeEdgeId(e.source, e.target), source: e.source, target: e.target, delay: e.delay, cost: e.cost, })) ) setError(null) } catch (err: unknown) { if (mountedRef.current) { const msg = err instanceof Error ? err.message : String(err) setError(msg) } } finally { if (mountedRef.current) setIsLoading(false) } }, []) const relayout = useCallback(() => { relayoutRequestedRef.current = true savedPositionsRef.current.clear() fetchTopology() }, [fetchTopology]) // 轮询 useEffect(() => { mountedRef.current = true fetchTopology() const timer = window.setInterval(fetchTopology, POLL_INTERVAL_MS) return () => { mountedRef.current = false window.clearInterval(timer) } }, [fetchTopology]) return { nodes, edges, isLoading, error, relayout } }