diff --git a/dashboard-frontend/package.json b/dashboard-frontend/package.json index f155a9f..9bd3910 100644 --- a/dashboard-frontend/package.json +++ b/dashboard-frontend/package.json @@ -9,20 +9,27 @@ "preview": "vite preview" }, "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-dom": "^18.3.0", + "react-leaflet": "^4.2.1", "react-router-dom": "^6.23.0", - "recharts": "^2.12.0", - "lucide-react": "^0.383.0" + "recharts": "^2.12.0" }, "devDependencies": { "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", - "typescript": "^5.4.0", - "vite": "^5.4.0", - "tailwindcss": "^3.4.0", + "autoprefixer": "^10.4.0", "postcss": "^8.4.0", - "autoprefixer": "^10.4.0" + "tailwindcss": "^3.4.0", + "typescript": "^5.4.0", + "vite": "^5.4.0" } } diff --git a/dashboard-frontend/src/components/TopologyGraph.tsx b/dashboard-frontend/src/components/TopologyGraph.tsx index ed38ff6..452d310 100644 --- a/dashboard-frontend/src/components/TopologyGraph.tsx +++ b/dashboard-frontend/src/components/TopologyGraph.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef, useCallback, useState } from 'react' -import * as d3 from 'd3' -import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react' +import { useEffect, useRef, useMemo, useState, useCallback } from 'react' +import ReactECharts from 'echarts-for-react' +import type { EChartsOption } from 'echarts' +import { Filter } from 'lucide-react' import type { NodeInfo, EdgeInfo } from '@/lib/api' interface TopologyGraphProps { @@ -10,44 +11,34 @@ interface TopologyGraphProps { 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 { - 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 INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER'] function getQualityColor(snr: number): string { - if (snr > 12) return '#22c55e' // excellent - green - if (snr > 8) return '#4ade80' // good - light green - if (snr > 5) return '#f59e0b' // fair - amber - if (snr > 3) return '#f97316' // marginal - orange - return '#ef4444' // poor - red + if (snr > 12) return '#22c55e' + if (snr > 8) return '#4ade80' + if (snr > 5) return '#f59e0b' + if (snr > 3) return '#f97316' + 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({ nodes, @@ -55,480 +46,258 @@ export default function TopologyGraph({ selectedNodeId, onSelectNode, }: TopologyGraphProps) { - const svgRef = useRef(null) - const gRef = useRef(null) - const simulationRef = useRef | null>(null) - const zoomRef = useRef | null>(null) - const transformRef = useRef(d3.zoomIdentity) - const particlesRef = useRef([]) - const animationRef = useRef(0) - const dragNodeRef = useRef(null) + const chartRef = useRef(null) + const [filterMode, setFilterMode] = useState('connected') - const [simNodes, setSimNodes] = useState([]) - const [simLinks, setSimLinks] = useState([]) - const [dimensions, setDimensions] = useState({ width: 800, height: 540 }) - const [transform, setTransform] = useState(d3.zoomIdentity) - - // Build region index map - 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() - .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) + // Build set of node IDs that have at least one edge + const connectedNodeIds = useMemo(() => { + const ids = new Set() + edges.forEach((e) => { + ids.add(e.from_node) + ids.add(e.to_node) }) + return ids + }, [edges]) - return () => { - svg.on('.zoom', null) + // Filter nodes based on mode + 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 - useEffect(() => { - if (!nodes.length) return + return result + }, [nodes, filterMode, connectedNodeIds]) - const width = dimensions.width - const height = dimensions.height + // Build node map for quick lookup + const nodeMap = useMemo(() => { + return new Map(filteredNodes.map((n) => [n.node_num, n])) + }, [filteredNodes]) - // Create simulation nodes - const simNodesData: SimNode[] = nodes.map((n) => ({ - id: n.node_num, - shortName: n.short_name, - 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 = {} - 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(simNodesData) - .alphaDecay(0.008) - .velocityDecay(0.35) - .force('charge', d3.forceManyBody() - .strength(-Math.max(150, nodeCount * 5)) - .distanceMax(Math.min(800, nodeCount * 10))) - .force('link', d3.forceLink(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((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) - }, []) + // Filter edges to only include those between filtered nodes + const filteredEdges = useMemo(() => { + return edges.filter((e) => nodeMap.has(e.from_node) && nodeMap.has(e.to_node)) + }, [edges, nodeMap]) // Get neighbors of selected node - const selectedNeighbors = new Set() - if (selectedNodeId !== null) { - edges.forEach((e) => { - if (e.from_node === selectedNodeId) selectedNeighbors.add(e.to_node) - if (e.to_node === selectedNodeId) selectedNeighbors.add(e.from_node) + const selectedNeighbors = useMemo(() => { + const neighbors = new Set() + if (selectedNodeId !== null) { + filteredEdges.forEach((e) => { + 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 handlePointerDown = useCallback((e: React.PointerEvent, node: SimNode) => { - e.preventDefault() - e.stopPropagation() - const svg = svgRef.current - if (!svg || !simulationRef.current) return + const graphLinks = filteredEdges.map((e) => { + const isRelated = selectedNodeId === null || + e.from_node === selectedNodeId || + e.to_node === selectedNodeId - svg.setPointerCapture(e.pointerId) - dragNodeRef.current = node - node.fx = node.x - node.fy = node.y - simulationRef.current.alphaTarget(0.3).restart() - }, []) + return { + source: String(e.from_node), + target: String(e.to_node), + value: e.snr, + 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) => { - if (!dragNodeRef.current || !svgRef.current) return + return { nodes: graphNodes, links: graphLinks } + }, [filteredNodes, filteredEdges, selectedNodeId, selectedNeighbors]) - const svg = svgRef.current - const rect = svg.getBoundingClientRect() - const screenX = e.clientX - rect.left - const screenY = e.clientY - rect.top + // ECharts option + const option: EChartsOption = useMemo(() => ({ + backgroundColor: '#111827', + 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 `${d.name}
${d.longName}
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 - const t = transformRef.current - const x = (screenX - t.x) / t.k - const y = (screenY - t.y) / t.k - - 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) + // Handle chart events + const onChartClick = useCallback((params: { data?: { nodeNum?: number } }) => { + if (params.data && 'nodeNum' in params.data) { + const nodeNum = params.data.nodeNum + onSelectNode(selectedNodeId === nodeNum ? null : nodeNum ?? null) + } }, [selectedNodeId, onSelectNode]) - const handleBackgroundClick = useCallback(() => { - // Only deselect if clicking on background, not during drag - if (!dragNodeRef.current) { - onSelectNode(null) + const onChartEvents = useMemo(() => ({ + click: onChartClick, + }), [onChartClick]) + + // Update chart when selection changes + useEffect(() => { + const chart = chartRef.current?.getEchartsInstance() + if (chart) { + chart.setOption(option, { notMerge: false, lazyUpdate: true }) } - }, [onSelectNode]) - - // 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) - }, []) + }, [option]) return (
- - {/* Background */} - + - {/* Zoomable/pannable content group */} - - {/* Edges */} - - {simLinks.map((link, i) => { - 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 + {/* Filter controls */} +
+ +
+ {([ + { key: 'connected', label: 'Connected' }, + { key: 'infra', label: 'Infra' }, + { key: 'all', label: 'All' }, + ] as { key: FilterMode; label: string }[]).map(({ key, label }) => ( + + ))} +
+ + {filteredNodes.length} nodes • {filteredEdges.length} edges + +
- const isRelated = selectedNodeId === null || - source.id === selectedNodeId || - target.id === selectedNodeId - - 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 ( - - - {selectedNodeId !== null && isRelated && ( - - {link.snr.toFixed(1)} dB - - )} - - ) - })} - - - {/* Particles */} - - {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 ( - - ) - })} - - - {/* Nodes */} - - {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 ( - handlePointerDown(e, node)} - onClick={(e) => { - e.stopPropagation() - handleNodeClick(node.id) - }} - > - {isSelected && ( - - )} - - - {node.shortName} - - - ) - })} - - - - {/* Legend - outside transform group so it stays fixed */} - - - Edge Quality + {/* Legend */} +
+
Edge Quality (SNR)
+
{[ { label: 'Excellent (>12)', color: '#22c55e' }, { label: 'Good (8-12)', color: '#4ade80' }, { label: 'Fair (5-8)', color: '#f59e0b' }, { label: 'Marginal (3-5)', color: '#f97316' }, { label: 'Poor (<3)', color: '#ef4444' }, - ].map((item, i) => ( - - - {item.label} - + ].map((item) => ( +
+
+ {item.label} +
))} - +
+
- {/* Node type legend */} - - - - - Infrastructure - - - - Client - - - - - {/* Zoom controls - outside SVG for easier styling */} -
- - - + {/* Node type legend */} +
+
Node Type
+
+
+
+ Infrastructure +
+
+
+ Client +
+
)