fix(dashboard): dynamic force scaling + zoom/pan on topology graph

- Dynamic force parameters based on node/edge count and density
- Per-node degree-weighted link strength for balanced layouts
- Position clamping keeps nodes within viewport
- d3-zoom for pan/zoom (wheel, drag, double-click reset)
- Zoom control buttons (+, -, reset) in corner
- Drag handlers account for zoom transform
- Legends stay fixed outside transform group
- Scales gracefully from 63 to 200+ nodes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zvx-echo6 2026-05-12 12:27:03 -06:00
commit d52abb2572

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useCallback, useState } from 'react' import { useEffect, useRef, useCallback, useState } from 'react'
import * as d3 from 'd3' import * as d3 from 'd3'
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
import type { NodeInfo, EdgeInfo } from '@/lib/api' import type { NodeInfo, EdgeInfo } from '@/lib/api'
interface TopologyGraphProps { interface TopologyGraphProps {
@ -55,7 +56,10 @@ export default function TopologyGraph({
onSelectNode, onSelectNode,
}: TopologyGraphProps) { }: TopologyGraphProps) {
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const gRef = useRef<SVGGElement>(null)
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null) 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 particlesRef = useRef<Particle[]>([])
const animationRef = useRef<number>(0) const animationRef = useRef<number>(0)
const dragNodeRef = useRef<SimNode | null>(null) const dragNodeRef = useRef<SimNode | null>(null)
@ -63,10 +67,10 @@ export default function TopologyGraph({
const [simNodes, setSimNodes] = useState<SimNode[]>([]) const [simNodes, setSimNodes] = useState<SimNode[]>([])
const [simLinks, setSimLinks] = useState<SimLink[]>([]) const [simLinks, setSimLinks] = useState<SimLink[]>([])
const [dimensions, setDimensions] = useState({ width: 800, height: 540 }) const [dimensions, setDimensions] = useState({ width: 800, height: 540 })
const [transform, setTransform] = useState<d3.ZoomTransform>(d3.zoomIdentity)
// Build region index map // Build region index map
const regionIndexMap = useCallback((lat: number | null) => { const regionIndexMap = useCallback((lat: number | null) => {
// Simple region assignment based on latitude bands for Idaho
if (lat === null) return 0 if (lat === null) return 0
if (lat > 46) return 0 // Northern if (lat > 46) return 0 // Northern
if (lat > 44.5) return 1 // Central if (lat > 44.5) return 1 // Central
@ -74,6 +78,32 @@ export default function TopologyGraph({
return 3 // SC 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 () => {
svg.on('.zoom', null)
}
}, [])
// Initialize simulation // Initialize simulation
useEffect(() => { useEffect(() => {
if (!nodes.length) return if (!nodes.length) return
@ -105,12 +135,25 @@ export default function TopologyGraph({
quality: e.quality, 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<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) const simulation = d3.forceSimulation<SimNode>(simNodesData)
.alphaDecay(0.008) .alphaDecay(0.008)
.velocityDecay(0.35) .velocityDecay(0.35)
.force('charge', d3.forceManyBody<SimNode>() .force('charge', d3.forceManyBody<SimNode>()
.strength((n) => n.isInfra ? -250 : -120)) .strength(-Math.max(150, nodeCount * 5))
.distanceMax(Math.min(800, nodeCount * 10)))
.force('link', d3.forceLink<SimNode, SimLink>(simLinksData) .force('link', d3.forceLink<SimNode, SimLink>(simLinksData)
.id((d) => d.id) .id((d) => d.id)
.distance((d) => { .distance((d) => {
@ -120,9 +163,25 @@ export default function TopologyGraph({
if (snr > 5) return 125 if (snr > 5) return 125
return 155 return 155
}) })
.strength(0.7)) .strength((d) => {
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.05)) const srcId = typeof d.source === 'object' ? d.source.id : d.source
.force('collide', d3.forceCollide<SimNode>((d) => d.isInfra ? 28 : 16).strength(0.5)) 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 simulationRef.current = simulation
setSimNodes(simNodesData) setSimNodes(simNodesData)
@ -146,18 +205,15 @@ export default function TopologyGraph({
// Animation loop // Animation loop
let lastTime = 0 let lastTime = 0
const animate = (time: number) => { 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 lastTime = time
// Update particle positions
particlesRef.current.forEach((p) => { particlesRef.current.forEach((p) => {
p.t += p.speed * dt p.t += p.speed * dt
if (p.t > 1) p.t -= 1 if (p.t > 1) p.t -= 1
}) })
// Read simulation positions
setSimNodes([...simNodesData]) setSimNodes([...simNodesData])
animationRef.current = requestAnimationFrame(animate) animationRef.current = requestAnimationFrame(animate)
} }
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) => { const handlePointerDown = useCallback((e: React.PointerEvent, node: SimNode) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -211,8 +267,13 @@ export default function TopologyGraph({
const svg = svgRef.current const svg = svgRef.current
const rect = svg.getBoundingClientRect() const rect = svg.getBoundingClientRect()
const x = e.clientX - rect.left const screenX = e.clientX - rect.left
const y = e.clientY - rect.top 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.fx = x
dragNodeRef.current.fy = y dragNodeRef.current.fy = y
@ -233,9 +294,31 @@ export default function TopologyGraph({
}, [selectedNodeId, onSelectNode]) }, [selectedNodeId, onSelectNode])
const handleBackgroundClick = useCallback(() => { const handleBackgroundClick = useCallback(() => {
onSelectNode(null) // Only deselect if clicking on background, not during drag
if (!dragNodeRef.current) {
onSelectNode(null)
}
}, [onSelectNode]) }, [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 ( 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 <svg
@ -251,148 +334,147 @@ export default function TopologyGraph({
{/* Background */} {/* Background */}
<rect width="100%" height="100%" fill="#111827" /> <rect width="100%" height="100%" fill="#111827" />
{/* Edges */} {/* Zoomable/pannable content group */}
<g> <g ref={gRef} transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
{simLinks.map((link, i) => { {/* Edges */}
const source = typeof link.source === 'object' ? link.source : simNodes.find(n => n.id === link.source) <g>
const target = typeof link.target === 'object' ? link.target : simNodes.find(n => n.id === link.target) {simLinks.map((link, i) => {
if (!source?.x || !source?.y || !target?.x || !target?.y) 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 || const isRelated = selectedNodeId === null ||
source.id === selectedNodeId || source.id === selectedNodeId ||
target.id === selectedNodeId target.id === selectedNodeId
const opacity = selectedNodeId === null ? 0.4 : (isRelated ? 0.6 : 0.04) const opacity = selectedNodeId === null ? 0.4 : (isRelated ? 0.6 : 0.04)
const color = getQualityColor(link.snr) const color = getQualityColor(link.snr)
const sx = source.x const sx = source.x
const sy = source.y const sy = source.y
const tx = target.x const tx = target.x
const ty = target.y const ty = target.y
return ( return (
<g key={i}> <g key={i}>
<line <line
x1={sx} x1={sx}
y1={sy} y1={sy}
x2={tx} x2={tx}
y2={ty} y2={ty}
stroke={color} stroke={color}
strokeWidth={isRelated && selectedNodeId !== null ? 2 : 1} strokeWidth={isRelated && selectedNodeId !== null ? 2 : 1}
opacity={opacity} opacity={opacity}
/>
{/* SNR label when node selected */}
{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)
}}
>
{/* Selection ring */}
{isSelected && (
<circle
r={radius + 4}
fill="none"
stroke="white"
strokeWidth={2}
strokeDasharray="4 2"
opacity={0.8}
/> />
)} {selectedNodeId !== null && isRelated && (
{/* Node circle */} <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 <circle
r={radius} key={i}
fill={node.isInfra ? color : '#111827'} cx={x}
stroke={color} cy={y}
strokeWidth={node.isInfra ? 0 : 2} r={particle.size}
fill={color}
opacity={selectedNodeId === null ? 0.7 : (isRelated ? 0.8 : 0.05)}
/> />
{/* Label */} )
<text })}
y={radius + 12} </g>
textAnchor="middle"
fill="#94a3b8" {/* Nodes */}
fontSize="10" <g>
fontFamily="JetBrains Mono, monospace" {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)
}}
> >
{node.shortName} {isSelected && (
</text> <circle
</g> 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> </g>
{/* Legend */} {/* Legend - outside transform group so it stays fixed */}
<g transform={`translate(16, ${dimensions.height - 100})`}> <g transform={`translate(16, ${dimensions.height - 100})`}>
<rect x={-8} y={-8} width={140} height={96} fill="#0a0e17" fillOpacity={0.8} rx={4} /> <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> <text fill="#94a3b8" fontSize="10" fontWeight="500" y={4}>Edge Quality</text>
@ -423,6 +505,31 @@ export default function TopologyGraph({
</g> </g>
</g> </g>
</svg> </svg>
{/* 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>
) )
} }