mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
8273913c1a
commit
d52abb2572
1 changed files with 256 additions and 149 deletions
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue