mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
fix(dashboard): rewrite topology graph for performance at scale
Replaced React state-driven animation with ECharts graph rendering. Previous approach re-rendered 263+ SVG nodes via setState in rAF loop, causing browser lockup. New approach uses ECharts which handles force layout and rendering natively at scale. Changes: - Switch from D3 force sim + React state to ECharts graph - Remove particle animation (was fake random noise, not real packets) - Filter out nodes with zero edges by default - Add filter controls: Connected, Infra, All - ECharts handles zoom/pan/drag natively (roam: true) - Node selection dims unrelated nodes - Force config matches Meshview: repulsion 200, edgeLength [80,120] - Animation disabled for performance (animation: false) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
30dc0b75df
commit
4625740057
2 changed files with 260 additions and 484 deletions
|
|
@ -9,20 +9,27 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"d3": "^7.9.0",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"lucide-react": "^0.383.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.23.0",
|
"react-router-dom": "^6.23.0",
|
||||||
"recharts": "^2.12.0",
|
"recharts": "^2.12.0"
|
||||||
"lucide-react": "^0.383.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"typescript": "^5.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"vite": "^5.4.0",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"autoprefixer": "^10.4.0"
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
|
||||||
import * as d3 from 'd3'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
|
import type { EChartsOption } from 'echarts'
|
||||||
|
import { Filter } from 'lucide-react'
|
||||||
import type { NodeInfo, EdgeInfo } from '@/lib/api'
|
import type { NodeInfo, EdgeInfo } from '@/lib/api'
|
||||||
|
|
||||||
interface TopologyGraphProps {
|
interface TopologyGraphProps {
|
||||||
|
|
@ -10,44 +11,34 @@ interface TopologyGraphProps {
|
||||||
onSelectNode: (nodeId: number | null) => void
|
onSelectNode: (nodeId: number | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SimNode extends d3.SimulationNodeDatum {
|
|
||||||
id: number
|
|
||||||
shortName: string
|
|
||||||
longName: string
|
|
||||||
role: string
|
|
||||||
isInfra: boolean
|
|
||||||
regionIndex: number
|
|
||||||
x?: number
|
|
||||||
y?: number
|
|
||||||
fx?: number | null
|
|
||||||
fy?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SimLink extends d3.SimulationLinkDatum<SimNode> {
|
|
||||||
source: SimNode | number
|
|
||||||
target: SimNode | number
|
|
||||||
snr: number
|
|
||||||
quality: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Particle {
|
|
||||||
edgeIndex: number
|
|
||||||
t: number
|
|
||||||
speed: number
|
|
||||||
size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const REGION_COLORS = ['#3b82f6', '#a78bfa', '#06b6d4', '#f59e0b', '#22c55e', '#ec4899', '#8b5cf6', '#14b8a6']
|
const REGION_COLORS = ['#3b82f6', '#a78bfa', '#06b6d4', '#f59e0b', '#22c55e', '#ec4899', '#8b5cf6', '#14b8a6']
|
||||||
|
const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
|
||||||
|
|
||||||
function getQualityColor(snr: number): string {
|
function getQualityColor(snr: number): string {
|
||||||
if (snr > 12) return '#22c55e' // excellent - green
|
if (snr > 12) return '#22c55e'
|
||||||
if (snr > 8) return '#4ade80' // good - light green
|
if (snr > 8) return '#4ade80'
|
||||||
if (snr > 5) return '#f59e0b' // fair - amber
|
if (snr > 5) return '#f59e0b'
|
||||||
if (snr > 3) return '#f97316' // marginal - orange
|
if (snr > 3) return '#f97316'
|
||||||
return '#ef4444' // poor - red
|
return '#ef4444'
|
||||||
}
|
}
|
||||||
|
|
||||||
const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
|
function getRegionIndex(lat: number | null): number {
|
||||||
|
if (lat === null) return 0
|
||||||
|
if (lat > 46) return 0
|
||||||
|
if (lat > 44.5) return 1
|
||||||
|
if (lat > 43) return 2
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeSize(role: string): number {
|
||||||
|
if (role === 'ROUTER' || role === 'ROUTER_LATE') return 30
|
||||||
|
if (role === 'REPEATER' || role === 'TRACKER') return 25
|
||||||
|
if (role === 'CLIENT_MUTE') return 7
|
||||||
|
if (role === 'CLIENT_BASE') return 12
|
||||||
|
return 15 // CLIENT and others
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterMode = 'all' | 'infra' | 'connected'
|
||||||
|
|
||||||
export default function TopologyGraph({
|
export default function TopologyGraph({
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -55,480 +46,258 @@ export default function TopologyGraph({
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
}: TopologyGraphProps) {
|
}: TopologyGraphProps) {
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const chartRef = useRef<ReactECharts>(null)
|
||||||
const gRef = useRef<SVGGElement>(null)
|
const [filterMode, setFilterMode] = useState<FilterMode>('connected')
|
||||||
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
|
|
||||||
const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null)
|
|
||||||
const transformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity)
|
|
||||||
const particlesRef = useRef<Particle[]>([])
|
|
||||||
const animationRef = useRef<number>(0)
|
|
||||||
const dragNodeRef = useRef<SimNode | null>(null)
|
|
||||||
|
|
||||||
const [simNodes, setSimNodes] = useState<SimNode[]>([])
|
// Build set of node IDs that have at least one edge
|
||||||
const [simLinks, setSimLinks] = useState<SimLink[]>([])
|
const connectedNodeIds = useMemo(() => {
|
||||||
const [dimensions, setDimensions] = useState({ width: 800, height: 540 })
|
const ids = new Set<number>()
|
||||||
const [transform, setTransform] = useState<d3.ZoomTransform>(d3.zoomIdentity)
|
edges.forEach((e) => {
|
||||||
|
ids.add(e.from_node)
|
||||||
// Build region index map
|
ids.add(e.to_node)
|
||||||
const regionIndexMap = useCallback((lat: number | null) => {
|
|
||||||
if (lat === null) return 0
|
|
||||||
if (lat > 46) return 0 // Northern
|
|
||||||
if (lat > 44.5) return 1 // Central
|
|
||||||
if (lat > 43) return 2 // SW
|
|
||||||
return 3 // SC
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Initialize zoom behavior
|
|
||||||
useEffect(() => {
|
|
||||||
if (!svgRef.current) return
|
|
||||||
|
|
||||||
const svg = d3.select(svgRef.current)
|
|
||||||
|
|
||||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
|
||||||
.scaleExtent([0.3, 4])
|
|
||||||
.on('zoom', (event) => {
|
|
||||||
transformRef.current = event.transform
|
|
||||||
setTransform(event.transform)
|
|
||||||
})
|
|
||||||
|
|
||||||
svg.call(zoom)
|
|
||||||
zoomRef.current = zoom
|
|
||||||
|
|
||||||
// Double-click to reset zoom
|
|
||||||
svg.on('dblclick.zoom', () => {
|
|
||||||
svg.transition().duration(300).call(zoom.transform, d3.zoomIdentity)
|
|
||||||
})
|
})
|
||||||
|
return ids
|
||||||
|
}, [edges])
|
||||||
|
|
||||||
return () => {
|
// Filter nodes based on mode
|
||||||
svg.on('.zoom', null)
|
const filteredNodes = useMemo(() => {
|
||||||
|
let result = nodes
|
||||||
|
|
||||||
|
if (filterMode === 'connected') {
|
||||||
|
// Only nodes with edges (like Meshview)
|
||||||
|
result = result.filter((n) => connectedNodeIds.has(n.node_num))
|
||||||
|
} else if (filterMode === 'infra') {
|
||||||
|
// Only infrastructure nodes
|
||||||
|
result = result.filter((n) => INFRA_ROLES.includes(n.role))
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Initialize simulation
|
return result
|
||||||
useEffect(() => {
|
}, [nodes, filterMode, connectedNodeIds])
|
||||||
if (!nodes.length) return
|
|
||||||
|
|
||||||
const width = dimensions.width
|
// Build node map for quick lookup
|
||||||
const height = dimensions.height
|
const nodeMap = useMemo(() => {
|
||||||
|
return new Map(filteredNodes.map((n) => [n.node_num, n]))
|
||||||
|
}, [filteredNodes])
|
||||||
|
|
||||||
// Create simulation nodes
|
// Filter edges to only include those between filtered nodes
|
||||||
const simNodesData: SimNode[] = nodes.map((n) => ({
|
const filteredEdges = useMemo(() => {
|
||||||
id: n.node_num,
|
return edges.filter((e) => nodeMap.has(e.from_node) && nodeMap.has(e.to_node))
|
||||||
shortName: n.short_name,
|
}, [edges, nodeMap])
|
||||||
longName: n.long_name,
|
|
||||||
role: n.role,
|
|
||||||
isInfra: INFRA_ROLES.includes(n.role),
|
|
||||||
regionIndex: regionIndexMap(n.latitude),
|
|
||||||
x: width / 2 + (Math.random() - 0.5) * 200,
|
|
||||||
y: height / 2 + (Math.random() - 0.5) * 200,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const nodeMap = new Map(simNodesData.map((n) => [n.id, n]))
|
|
||||||
|
|
||||||
// Create simulation links
|
|
||||||
const simLinksData: SimLink[] = edges
|
|
||||||
.filter((e) => nodeMap.has(e.from_node) && nodeMap.has(e.to_node))
|
|
||||||
.map((e) => ({
|
|
||||||
source: e.from_node,
|
|
||||||
target: e.to_node,
|
|
||||||
snr: e.snr,
|
|
||||||
quality: e.quality,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// FIX 1: Dynamic force scaling - compute metrics from data
|
|
||||||
const nodeCount = simNodesData.length
|
|
||||||
const edgeCount = simLinksData.length
|
|
||||||
const density = edgeCount / Math.max(nodeCount, 1)
|
|
||||||
const linkCount: Record<number, number> = {}
|
|
||||||
simLinksData.forEach((l) => {
|
|
||||||
const srcId = typeof l.source === 'object' ? l.source.id : l.source
|
|
||||||
const tgtId = typeof l.target === 'object' ? l.target.id : l.target
|
|
||||||
linkCount[srcId] = (linkCount[srcId] || 0) + 1
|
|
||||||
linkCount[tgtId] = (linkCount[tgtId] || 0) + 1
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create D3 force simulation with dynamic parameters
|
|
||||||
const simulation = d3.forceSimulation<SimNode>(simNodesData)
|
|
||||||
.alphaDecay(0.008)
|
|
||||||
.velocityDecay(0.35)
|
|
||||||
.force('charge', d3.forceManyBody<SimNode>()
|
|
||||||
.strength(-Math.max(150, nodeCount * 5))
|
|
||||||
.distanceMax(Math.min(800, nodeCount * 10)))
|
|
||||||
.force('link', d3.forceLink<SimNode, SimLink>(simLinksData)
|
|
||||||
.id((d) => d.id)
|
|
||||||
.distance((d) => {
|
|
||||||
const snr = d.snr || 0
|
|
||||||
if (snr > 12) return 80
|
|
||||||
if (snr > 8) return 100
|
|
||||||
if (snr > 5) return 125
|
|
||||||
return 155
|
|
||||||
})
|
|
||||||
.strength((d) => {
|
|
||||||
const srcId = typeof d.source === 'object' ? d.source.id : d.source
|
|
||||||
const tgtId = typeof d.target === 'object' ? d.target.id : d.target
|
|
||||||
const srcDeg = linkCount[srcId] || 1
|
|
||||||
const tgtDeg = linkCount[tgtId] || 1
|
|
||||||
return 1 / Math.sqrt(Math.max(srcDeg, tgtDeg))
|
|
||||||
}))
|
|
||||||
.force('center', d3.forceCenter(width / 2, height / 2)
|
|
||||||
.strength(Math.min(0.15, 5 / Math.max(nodeCount, 1))))
|
|
||||||
.force('collide', d3.forceCollide<SimNode>((d) => d.isInfra ? 30 : 16)
|
|
||||||
.strength(Math.min(1, 20 / density)))
|
|
||||||
|
|
||||||
// Clamp positions on each tick to keep nodes in view
|
|
||||||
simulation.on('tick', () => {
|
|
||||||
simNodesData.forEach((n) => {
|
|
||||||
if (n.x !== undefined) n.x = Math.max(40, Math.min(width - 40, n.x))
|
|
||||||
if (n.y !== undefined) n.y = Math.max(40, Math.min(height - 40, n.y))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
simulationRef.current = simulation
|
|
||||||
setSimNodes(simNodesData)
|
|
||||||
setSimLinks(simLinksData as SimLink[])
|
|
||||||
|
|
||||||
// Initialize particles
|
|
||||||
const particles: Particle[] = []
|
|
||||||
simLinksData.forEach((_, i) => {
|
|
||||||
const numParticles = 2 + Math.floor(Math.random() * 2)
|
|
||||||
for (let p = 0; p < numParticles; p++) {
|
|
||||||
particles.push({
|
|
||||||
edgeIndex: i,
|
|
||||||
t: Math.random(),
|
|
||||||
speed: 0.002 + Math.random() * 0.003,
|
|
||||||
size: 1.5 + Math.random() * 1.5,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
particlesRef.current = particles
|
|
||||||
|
|
||||||
// Animation loop
|
|
||||||
let lastTime = 0
|
|
||||||
const animate = (time: number) => {
|
|
||||||
const dt = Math.min((time - lastTime) / 16.67, 2)
|
|
||||||
lastTime = time
|
|
||||||
|
|
||||||
particlesRef.current.forEach((p) => {
|
|
||||||
p.t += p.speed * dt
|
|
||||||
if (p.t > 1) p.t -= 1
|
|
||||||
})
|
|
||||||
|
|
||||||
setSimNodes([...simNodesData])
|
|
||||||
animationRef.current = requestAnimationFrame(animate)
|
|
||||||
}
|
|
||||||
animationRef.current = requestAnimationFrame(animate)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationRef.current)
|
|
||||||
simulation.stop()
|
|
||||||
}
|
|
||||||
}, [nodes, edges, dimensions, regionIndexMap])
|
|
||||||
|
|
||||||
// Handle resize
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (svgRef.current) {
|
|
||||||
const rect = svgRef.current.parentElement?.getBoundingClientRect()
|
|
||||||
if (rect) {
|
|
||||||
setDimensions({ width: rect.width, height: 540 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleResize()
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Get neighbors of selected node
|
// Get neighbors of selected node
|
||||||
const selectedNeighbors = new Set<number>()
|
const selectedNeighbors = useMemo(() => {
|
||||||
if (selectedNodeId !== null) {
|
const neighbors = new Set<number>()
|
||||||
edges.forEach((e) => {
|
if (selectedNodeId !== null) {
|
||||||
if (e.from_node === selectedNodeId) selectedNeighbors.add(e.to_node)
|
filteredEdges.forEach((e) => {
|
||||||
if (e.to_node === selectedNodeId) selectedNeighbors.add(e.from_node)
|
if (e.from_node === selectedNodeId) neighbors.add(e.to_node)
|
||||||
|
if (e.to_node === selectedNodeId) neighbors.add(e.from_node)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return neighbors
|
||||||
|
}, [selectedNodeId, filteredEdges])
|
||||||
|
|
||||||
|
// Build ECharts data
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const graphNodes = filteredNodes.map((n) => {
|
||||||
|
const regionIndex = getRegionIndex(n.latitude)
|
||||||
|
const color = REGION_COLORS[regionIndex % REGION_COLORS.length]
|
||||||
|
const isInfra = INFRA_ROLES.includes(n.role)
|
||||||
|
const isSelected = n.node_num === selectedNodeId
|
||||||
|
const isNeighbor = selectedNeighbors.has(n.node_num)
|
||||||
|
const isRelated = selectedNodeId === null || isSelected || isNeighbor
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(n.node_num),
|
||||||
|
name: n.short_name,
|
||||||
|
value: n.node_num,
|
||||||
|
symbolSize: getNodeSize(n.role),
|
||||||
|
itemStyle: {
|
||||||
|
color: isInfra ? color : '#111827',
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth: isInfra ? 0 : 2,
|
||||||
|
opacity: isRelated ? 1 : 0.15,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'bottom' as const,
|
||||||
|
distance: 5,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
color: isRelated ? '#94a3b8' : '#94a3b820',
|
||||||
|
},
|
||||||
|
// Store extra data for click handler
|
||||||
|
nodeNum: n.node_num,
|
||||||
|
longName: n.long_name,
|
||||||
|
role: n.role,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// FIX 2: Drag handlers with zoom transform
|
const graphLinks = filteredEdges.map((e) => {
|
||||||
const handlePointerDown = useCallback((e: React.PointerEvent, node: SimNode) => {
|
const isRelated = selectedNodeId === null ||
|
||||||
e.preventDefault()
|
e.from_node === selectedNodeId ||
|
||||||
e.stopPropagation()
|
e.to_node === selectedNodeId
|
||||||
const svg = svgRef.current
|
|
||||||
if (!svg || !simulationRef.current) return
|
|
||||||
|
|
||||||
svg.setPointerCapture(e.pointerId)
|
return {
|
||||||
dragNodeRef.current = node
|
source: String(e.from_node),
|
||||||
node.fx = node.x
|
target: String(e.to_node),
|
||||||
node.fy = node.y
|
value: e.snr,
|
||||||
simulationRef.current.alphaTarget(0.3).restart()
|
lineStyle: {
|
||||||
}, [])
|
color: getQualityColor(e.snr),
|
||||||
|
width: isRelated && selectedNodeId !== null ? 2 : 1,
|
||||||
|
opacity: selectedNodeId === null ? 0.4 : (isRelated ? 0.6 : 0.04),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
return { nodes: graphNodes, links: graphLinks }
|
||||||
if (!dragNodeRef.current || !svgRef.current) return
|
}, [filteredNodes, filteredEdges, selectedNodeId, selectedNeighbors])
|
||||||
|
|
||||||
const svg = svgRef.current
|
// ECharts option
|
||||||
const rect = svg.getBoundingClientRect()
|
const option: EChartsOption = useMemo(() => ({
|
||||||
const screenX = e.clientX - rect.left
|
backgroundColor: '#111827',
|
||||||
const screenY = e.clientY - rect.top
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
borderColor: '#334155',
|
||||||
|
textStyle: {
|
||||||
|
color: '#e2e8f0',
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter: (params: any) => {
|
||||||
|
if (params.data && params.data.longName) {
|
||||||
|
const d = params.data
|
||||||
|
return `<strong>${d.name}</strong><br/>${d.longName}<br/>Role: ${d.role}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'graph',
|
||||||
|
layout: 'force',
|
||||||
|
roam: true,
|
||||||
|
draggable: true,
|
||||||
|
animation: false,
|
||||||
|
data: chartData.nodes,
|
||||||
|
links: chartData.links,
|
||||||
|
force: {
|
||||||
|
repulsion: 200,
|
||||||
|
edgeLength: [80, 120],
|
||||||
|
gravity: 0.1,
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
focus: 'adjacency',
|
||||||
|
blurScope: 'coordinateSystem',
|
||||||
|
lineStyle: {
|
||||||
|
width: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'bottom',
|
||||||
|
distance: 5,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
},
|
||||||
|
edgeLabel: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
// Selection styling handled via data opacity
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), [chartData])
|
||||||
|
|
||||||
// Convert screen coords to graph coords using current transform
|
// Handle chart events
|
||||||
const t = transformRef.current
|
const onChartClick = useCallback((params: { data?: { nodeNum?: number } }) => {
|
||||||
const x = (screenX - t.x) / t.k
|
if (params.data && 'nodeNum' in params.data) {
|
||||||
const y = (screenY - t.y) / t.k
|
const nodeNum = params.data.nodeNum
|
||||||
|
onSelectNode(selectedNodeId === nodeNum ? null : nodeNum ?? null)
|
||||||
dragNodeRef.current.fx = x
|
}
|
||||||
dragNodeRef.current.fy = y
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
|
||||||
if (!dragNodeRef.current || !simulationRef.current) return
|
|
||||||
|
|
||||||
svgRef.current?.releasePointerCapture(e.pointerId)
|
|
||||||
dragNodeRef.current.fx = null
|
|
||||||
dragNodeRef.current.fy = null
|
|
||||||
dragNodeRef.current = null
|
|
||||||
simulationRef.current.alphaTarget(0)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleNodeClick = useCallback((nodeId: number) => {
|
|
||||||
onSelectNode(selectedNodeId === nodeId ? null : nodeId)
|
|
||||||
}, [selectedNodeId, onSelectNode])
|
}, [selectedNodeId, onSelectNode])
|
||||||
|
|
||||||
const handleBackgroundClick = useCallback(() => {
|
const onChartEvents = useMemo(() => ({
|
||||||
// Only deselect if clicking on background, not during drag
|
click: onChartClick,
|
||||||
if (!dragNodeRef.current) {
|
}), [onChartClick])
|
||||||
onSelectNode(null)
|
|
||||||
|
// Update chart when selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
const chart = chartRef.current?.getEchartsInstance()
|
||||||
|
if (chart) {
|
||||||
|
chart.setOption(option, { notMerge: false, lazyUpdate: true })
|
||||||
}
|
}
|
||||||
}, [onSelectNode])
|
}, [option])
|
||||||
|
|
||||||
// Zoom control handlers
|
|
||||||
const handleZoomIn = useCallback(() => {
|
|
||||||
if (!svgRef.current || !zoomRef.current) return
|
|
||||||
const svg = d3.select(svgRef.current)
|
|
||||||
svg.transition().duration(200).call(zoomRef.current.scaleBy, 1.3)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleZoomOut = useCallback(() => {
|
|
||||||
if (!svgRef.current || !zoomRef.current) return
|
|
||||||
const svg = d3.select(svgRef.current)
|
|
||||||
svg.transition().duration(200).call(zoomRef.current.scaleBy, 0.7)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleZoomReset = useCallback(() => {
|
|
||||||
if (!svgRef.current || !zoomRef.current) return
|
|
||||||
const svg = d3.select(svgRef.current)
|
|
||||||
svg.transition().duration(300).call(zoomRef.current.transform, d3.zoomIdentity)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative bg-bg-card rounded-lg border border-border overflow-hidden">
|
<div className="relative bg-bg-card rounded-lg border border-border overflow-hidden">
|
||||||
<svg
|
<ReactECharts
|
||||||
ref={svgRef}
|
ref={chartRef}
|
||||||
width={dimensions.width}
|
option={option}
|
||||||
height={dimensions.height}
|
style={{ height: '540px', width: '100%' }}
|
||||||
className="cursor-grab active:cursor-grabbing"
|
onEvents={onChartEvents}
|
||||||
onPointerMove={handlePointerMove}
|
opts={{ renderer: 'canvas' }}
|
||||||
onPointerUp={handlePointerUp}
|
/>
|
||||||
onPointerLeave={handlePointerUp}
|
|
||||||
onClick={handleBackgroundClick}
|
|
||||||
>
|
|
||||||
{/* Background */}
|
|
||||||
<rect width="100%" height="100%" fill="#111827" />
|
|
||||||
|
|
||||||
{/* Zoomable/pannable content group */}
|
{/* Filter controls */}
|
||||||
<g ref={gRef} transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
|
<div className="absolute top-4 left-4 flex items-center gap-2 bg-bg-card/90 backdrop-blur-sm border border-border rounded px-3 py-2">
|
||||||
{/* Edges */}
|
<Filter size={14} className="text-slate-500" />
|
||||||
<g>
|
<div className="flex gap-1">
|
||||||
{simLinks.map((link, i) => {
|
{([
|
||||||
const source = typeof link.source === 'object' ? link.source : simNodes.find(n => n.id === link.source)
|
{ key: 'connected', label: 'Connected' },
|
||||||
const target = typeof link.target === 'object' ? link.target : simNodes.find(n => n.id === link.target)
|
{ key: 'infra', label: 'Infra' },
|
||||||
if (!source?.x || !source?.y || !target?.x || !target?.y) return null
|
{ key: 'all', label: 'All' },
|
||||||
|
] as { key: FilterMode; label: string }[]).map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilterMode(key)}
|
||||||
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||||
|
filterMode === key
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'bg-bg-hover text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-500 ml-2">
|
||||||
|
{filteredNodes.length} nodes • {filteredEdges.length} edges
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
const isRelated = selectedNodeId === null ||
|
{/* Legend */}
|
||||||
source.id === selectedNodeId ||
|
<div className="absolute bottom-4 left-4 bg-bg-card/90 backdrop-blur-sm border border-border rounded p-3">
|
||||||
target.id === selectedNodeId
|
<div className="text-xs text-slate-400 font-medium mb-2">Edge Quality (SNR)</div>
|
||||||
|
<div className="space-y-1">
|
||||||
const opacity = selectedNodeId === null ? 0.4 : (isRelated ? 0.6 : 0.04)
|
|
||||||
const color = getQualityColor(link.snr)
|
|
||||||
|
|
||||||
const sx = source.x
|
|
||||||
const sy = source.y
|
|
||||||
const tx = target.x
|
|
||||||
const ty = target.y
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={i}>
|
|
||||||
<line
|
|
||||||
x1={sx}
|
|
||||||
y1={sy}
|
|
||||||
x2={tx}
|
|
||||||
y2={ty}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={isRelated && selectedNodeId !== null ? 2 : 1}
|
|
||||||
opacity={opacity}
|
|
||||||
/>
|
|
||||||
{selectedNodeId !== null && isRelated && (
|
|
||||||
<text
|
|
||||||
x={(sx + tx) / 2}
|
|
||||||
y={(sy + ty) / 2 - 4}
|
|
||||||
fill={color}
|
|
||||||
fontSize="9"
|
|
||||||
fontFamily="JetBrains Mono, monospace"
|
|
||||||
textAnchor="middle"
|
|
||||||
opacity={0.9}
|
|
||||||
>
|
|
||||||
{link.snr.toFixed(1)} dB
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Particles */}
|
|
||||||
<g>
|
|
||||||
{particlesRef.current.map((particle, i) => {
|
|
||||||
const link = simLinks[particle.edgeIndex]
|
|
||||||
if (!link) return null
|
|
||||||
|
|
||||||
const source = typeof link.source === 'object' ? link.source : simNodes.find(n => n.id === link.source)
|
|
||||||
const target = typeof link.target === 'object' ? link.target : simNodes.find(n => n.id === link.target)
|
|
||||||
if (!source?.x || !source?.y || !target?.x || !target?.y) return null
|
|
||||||
|
|
||||||
const isRelated = selectedNodeId === null ||
|
|
||||||
source.id === selectedNodeId ||
|
|
||||||
target.id === selectedNodeId
|
|
||||||
|
|
||||||
const sx = source.x
|
|
||||||
const sy = source.y
|
|
||||||
const tx = target.x
|
|
||||||
const ty = target.y
|
|
||||||
const x = sx + (tx - sx) * particle.t
|
|
||||||
const y = sy + (ty - sy) * particle.t
|
|
||||||
const color = getQualityColor(link.snr)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<circle
|
|
||||||
key={i}
|
|
||||||
cx={x}
|
|
||||||
cy={y}
|
|
||||||
r={particle.size}
|
|
||||||
fill={color}
|
|
||||||
opacity={selectedNodeId === null ? 0.7 : (isRelated ? 0.8 : 0.05)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Nodes */}
|
|
||||||
<g>
|
|
||||||
{simNodes.map((node) => {
|
|
||||||
if (node.x === undefined || node.y === undefined) return null
|
|
||||||
|
|
||||||
const isSelected = node.id === selectedNodeId
|
|
||||||
const isNeighbor = selectedNeighbors.has(node.id)
|
|
||||||
const isRelated = selectedNodeId === null || isSelected || isNeighbor
|
|
||||||
const opacity = isRelated ? 1 : 0.1
|
|
||||||
const color = REGION_COLORS[node.regionIndex % REGION_COLORS.length]
|
|
||||||
const radius = node.isInfra ? 14 : 8
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g
|
|
||||||
key={node.id}
|
|
||||||
transform={`translate(${node.x}, ${node.y})`}
|
|
||||||
opacity={opacity}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onPointerDown={(e) => handlePointerDown(e, node)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleNodeClick(node.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSelected && (
|
|
||||||
<circle
|
|
||||||
r={radius + 4}
|
|
||||||
fill="none"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="4 2"
|
|
||||||
opacity={0.8}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<circle
|
|
||||||
r={radius}
|
|
||||||
fill={node.isInfra ? color : '#111827'}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={node.isInfra ? 0 : 2}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
y={radius + 12}
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="#94a3b8"
|
|
||||||
fontSize="10"
|
|
||||||
fontFamily="JetBrains Mono, monospace"
|
|
||||||
>
|
|
||||||
{node.shortName}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Legend - outside transform group so it stays fixed */}
|
|
||||||
<g transform={`translate(16, ${dimensions.height - 100})`}>
|
|
||||||
<rect x={-8} y={-8} width={140} height={96} fill="#0a0e17" fillOpacity={0.8} rx={4} />
|
|
||||||
<text fill="#94a3b8" fontSize="10" fontWeight="500" y={4}>Edge Quality</text>
|
|
||||||
{[
|
{[
|
||||||
{ label: 'Excellent (>12)', color: '#22c55e' },
|
{ label: 'Excellent (>12)', color: '#22c55e' },
|
||||||
{ label: 'Good (8-12)', color: '#4ade80' },
|
{ label: 'Good (8-12)', color: '#4ade80' },
|
||||||
{ label: 'Fair (5-8)', color: '#f59e0b' },
|
{ label: 'Fair (5-8)', color: '#f59e0b' },
|
||||||
{ label: 'Marginal (3-5)', color: '#f97316' },
|
{ label: 'Marginal (3-5)', color: '#f97316' },
|
||||||
{ label: 'Poor (<3)', color: '#ef4444' },
|
{ label: 'Poor (<3)', color: '#ef4444' },
|
||||||
].map((item, i) => (
|
].map((item) => (
|
||||||
<g key={item.label} transform={`translate(0, ${16 + i * 14})`}>
|
<div key={item.label} className="flex items-center gap-2">
|
||||||
<line x1={0} y1={0} x2={16} y2={0} stroke={item.color} strokeWidth={2} />
|
<div className="w-4 h-0.5" style={{ backgroundColor: item.color }} />
|
||||||
<text x={22} y={3} fill="#64748b" fontSize="9">{item.label}</text>
|
<span className="text-xs text-slate-500">{item.label}</span>
|
||||||
</g>
|
</div>
|
||||||
))}
|
))}
|
||||||
</g>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Node type legend */}
|
{/* Node type legend */}
|
||||||
<g transform={`translate(${dimensions.width - 130}, ${dimensions.height - 50})`}>
|
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur-sm border border-border rounded p-3">
|
||||||
<rect x={-8} y={-8} width={120} height={44} fill="#0a0e17" fillOpacity={0.8} rx={4} />
|
<div className="text-xs text-slate-400 font-medium mb-2">Node Type</div>
|
||||||
<g>
|
<div className="space-y-2">
|
||||||
<circle cx={8} cy={6} r={6} fill="#3b82f6" />
|
<div className="flex items-center gap-2">
|
||||||
<text x={20} y={9} fill="#64748b" fontSize="9">Infrastructure</text>
|
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||||
</g>
|
<span className="text-xs text-slate-500">Infrastructure</span>
|
||||||
<g transform="translate(0, 18)">
|
</div>
|
||||||
<circle cx={8} cy={6} r={5} fill="#111827" stroke="#3b82f6" strokeWidth={1.5} />
|
<div className="flex items-center gap-2">
|
||||||
<text x={20} y={9} fill="#64748b" fontSize="9">Client</text>
|
<div className="w-3 h-3 rounded-full bg-gray-900 border-2 border-blue-500" />
|
||||||
</g>
|
<span className="text-xs text-slate-500">Client</span>
|
||||||
</g>
|
</div>
|
||||||
</svg>
|
</div>
|
||||||
|
|
||||||
{/* Zoom controls - outside SVG for easier styling */}
|
|
||||||
<div className="absolute bottom-4 right-4 flex flex-col gap-1">
|
|
||||||
<button
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
className="w-8 h-8 bg-bg-hover/90 backdrop-blur-sm border border-border rounded flex items-center justify-center text-slate-400 hover:text-slate-200 hover:bg-bg-hover transition-colors"
|
|
||||||
title="Zoom in"
|
|
||||||
>
|
|
||||||
<ZoomIn size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
className="w-8 h-8 bg-bg-hover/90 backdrop-blur-sm border border-border rounded flex items-center justify-center text-slate-400 hover:text-slate-200 hover:bg-bg-hover transition-colors"
|
|
||||||
title="Zoom out"
|
|
||||||
>
|
|
||||||
<ZoomOut size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleZoomReset}
|
|
||||||
className="w-8 h-8 bg-bg-hover/90 backdrop-blur-sm border border-border rounded flex items-center justify-center text-slate-400 hover:text-slate-200 hover:bg-bg-hover transition-colors"
|
|
||||||
title="Reset zoom"
|
|
||||||
>
|
|
||||||
<Maximize2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue