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 * 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<SVGSVGElement>(null)
const gRef = useRef<SVGGElement>(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 animationRef = useRef<number>(0)
const dragNodeRef = useRef<SimNode | null>(null)
@ -63,10 +67,10 @@ export default function TopologyGraph({
const [simNodes, setSimNodes] = useState<SimNode[]>([])
const [simLinks, setSimLinks] = useState<SimLink[]>([])
const [dimensions, setDimensions] = useState({ width: 800, height: 540 })
const [transform, setTransform] = useState<d3.ZoomTransform>(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<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
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<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((n) => n.isInfra ? -250 : -120))
.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) => {
@ -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<SimNode>((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<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)
@ -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 (
<div className="relative bg-bg-card rounded-lg border border-border overflow-hidden">
<svg
@ -251,148 +334,147 @@ export default function TopologyGraph({
{/* Background */}
<rect width="100%" height="100%" fill="#111827" />
{/* Edges */}
<g>
{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 */}
<g ref={gRef} transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
{/* Edges */}
<g>
{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 (
<g key={i}>
<line
x1={sx}
y1={sy}
x2={tx}
y2={ty}
stroke={color}
strokeWidth={isRelated && selectedNodeId !== null ? 2 : 1}
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}
return (
<g key={i}>
<line
x1={sx}
y1={sy}
x2={tx}
y2={ty}
stroke={color}
strokeWidth={isRelated && selectedNodeId !== null ? 2 : 1}
opacity={opacity}
/>
)}
{/* Node circle */}
{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
r={radius}
fill={node.isInfra ? color : '#111827'}
stroke={color}
strokeWidth={node.isInfra ? 0 : 2}
key={i}
cx={x}
cy={y}
r={particle.size}
fill={color}
opacity={selectedNodeId === null ? 0.7 : (isRelated ? 0.8 : 0.05)}
/>
{/* Label */}
<text
y={radius + 12}
textAnchor="middle"
fill="#94a3b8"
fontSize="10"
fontFamily="JetBrains Mono, monospace"
)
})}
</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)
}}
>
{node.shortName}
</text>
</g>
)
})}
{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 */}
{/* 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>
@ -423,6 +505,31 @@ export default function TopologyGraph({
</g>
</g>
</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>
)
}