231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
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<string, { x: number; y: number }> {
|
|
const positions = new Map<string, { x: number; y: number }>()
|
|
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<LayoutNode[]>([])
|
|
const [edges, setEdges] = useState<LayoutEdge[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const lastSignatureRef = useRef<string>('')
|
|
const savedPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
|
|
const layoutPositionsRef = useRef<Map<string, { x: number; y: number }>>(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<TopologyData>
|
|
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<string, TopologyEdge>()
|
|
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<string, { x: number; y: number }>
|
|
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 }
|
|
}
|