diff --git a/dashboard-frontend/src/components/TopologyGraph.tsx b/dashboard-frontend/src/components/TopologyGraph.tsx index 162def7..ed38ff6 100644 --- a/dashboard-frontend/src/components/TopologyGraph.tsx +++ b/dashboard-frontend/src/components/TopologyGraph.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useCallback, useState } from 'react' import * as d3 from 'd3' +import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react' import type { NodeInfo, EdgeInfo } from '@/lib/api' interface TopologyGraphProps { @@ -55,7 +56,10 @@ export default function TopologyGraph({ 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) @@ -63,10 +67,10 @@ export default function TopologyGraph({ 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) => { - // Simple region assignment based on latitude bands for Idaho if (lat === null) return 0 if (lat > 46) return 0 // Northern if (lat > 44.5) return 1 // Central @@ -74,6 +78,32 @@ export default function TopologyGraph({ 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) + }) + + return () => { + svg.on('.zoom', null) + } + }, []) + // Initialize simulation useEffect(() => { if (!nodes.length) return @@ -105,12 +135,25 @@ export default function TopologyGraph({ quality: e.quality, })) - // Create D3 force simulation + // 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((n) => n.isInfra ? -250 : -120)) + .strength(-Math.max(150, nodeCount * 5)) + .distanceMax(Math.min(800, nodeCount * 10))) .force('link', d3.forceLink(simLinksData) .id((d) => d.id) .distance((d) => { @@ -120,9 +163,25 @@ export default function TopologyGraph({ if (snr > 5) return 125 return 155 }) - .strength(0.7)) - .force('center', d3.forceCenter(width / 2, height / 2).strength(0.05)) - .force('collide', d3.forceCollide((d) => d.isInfra ? 28 : 16).strength(0.5)) + .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) @@ -146,18 +205,15 @@ export default function TopologyGraph({ // Animation loop let lastTime = 0 const animate = (time: number) => { - const dt = Math.min((time - lastTime) / 16.67, 2) // Cap at 2x speed + const dt = Math.min((time - lastTime) / 16.67, 2) lastTime = time - // Update particle positions particlesRef.current.forEach((p) => { p.t += p.speed * dt if (p.t > 1) p.t -= 1 }) - // Read simulation positions setSimNodes([...simNodesData]) - animationRef.current = requestAnimationFrame(animate) } animationRef.current = requestAnimationFrame(animate) @@ -192,7 +248,7 @@ export default function TopologyGraph({ }) } - // Drag handlers + // FIX 2: Drag handlers with zoom transform const handlePointerDown = useCallback((e: React.PointerEvent, node: SimNode) => { e.preventDefault() e.stopPropagation() @@ -211,8 +267,13 @@ export default function TopologyGraph({ const svg = svgRef.current const rect = svg.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top + const screenX = e.clientX - rect.left + const screenY = e.clientY - rect.top + + // 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 @@ -233,9 +294,31 @@ export default function TopologyGraph({ }, [selectedNodeId, onSelectNode]) const handleBackgroundClick = useCallback(() => { - onSelectNode(null) + // Only deselect if clicking on background, not during drag + if (!dragNodeRef.current) { + onSelectNode(null) + } }, [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) + }, []) + return (
- {/* 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 + {/* 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 - const isRelated = selectedNodeId === null || - source.id === selectedNodeId || - target.id === selectedNodeId + 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 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 + const sx = source.x + const sy = source.y + const tx = target.x + const ty = target.y - return ( - - - {/* SNR label when node selected */} - {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) - }} - > - {/* Selection ring */} - {isSelected && ( - + - )} - {/* Node circle */} + {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 ( - {/* Label */} - + + {/* 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) + }} > - {node.shortName} - - - ) - })} + {isSelected && ( + + )} + + + {node.shortName} + + + ) + })} + - {/* Legend */} + {/* Legend - outside transform group so it stays fixed */} Edge Quality @@ -423,6 +505,31 @@ export default function TopologyGraph({ + + {/* Zoom controls - outside SVG for easier styling */} +
+ + + +
) }