Files
bootnode/console/components/node-globe.tsx
T
zeekayandHanzo Dev 7d293212cb refactor: web/ is now console/ — bootnode/bootnode is the backend, bootnode/console the frontend
Names the two halves for what they are. The repo already holds both (12407e9 merged
bootnode/bootnode into this history); this makes the frontend directory say so, so the layout
reads the way the owner describes the system: backend and frontend separate, one repo.

git mv throughout — 187 renames, history preserved. Every path reference follows: hanzo.yml
build context and dockerfile, compose.yml, compose.prod.yml, start.sh, LLM.md,
infra/k8s/web-deployments.yaml, infra/cloud/deploy.sh, and the Dockerfile's own COPY paths.
Image and container names (bootnode-web, the -web tag suffix) are deliberately NOT renamed here:
those are a deploy contract the App CR and ingress bind to on port 3001, and breaking them is a
separate, staged change.

Console builds green after the move; the Go tree builds.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 17:47:51 -07:00

412 lines
15 KiB
TypeScript

"use client"
import { useCallback, useMemo, useRef, useState } from "react"
import { cn } from "@/lib/utils"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface NodeLocation {
id: string
name: string
chain: string
network: string
region: string
status: "running" | "syncing" | "provisioning" | "stopped" | "error"
latitude?: number
longitude?: number
nodeType: string
peerCount?: number
syncProgress?: number
}
export interface NodeGlobeProps {
nodes: NodeLocation[]
onNodeClick?: (nodeId: string) => void
selectedNetwork?: string
className?: string
}
// ---------------------------------------------------------------------------
// Region coordinate mapping
// ---------------------------------------------------------------------------
const REGION_COORDS: Record<string, { lat: number; lng: number; label: string }> = {
// DigitalOcean regions
"nyc1": { lat: 40.7128, lng: -74.0060, label: "New York 1" },
"nyc3": { lat: 40.7128, lng: -74.0060, label: "New York 3" },
"sfo1": { lat: 37.7749, lng: -122.4194, label: "San Francisco 1" },
"sfo2": { lat: 37.7749, lng: -122.4194, label: "San Francisco 2" },
"sfo3": { lat: 37.7749, lng: -122.4194, label: "San Francisco 3" },
"ams3": { lat: 52.3676, lng: 4.9041, label: "Amsterdam" },
"sgp1": { lat: 1.3521, lng: 103.8198, label: "Singapore" },
"lon1": { lat: 51.5074, lng: -0.1278, label: "London" },
"fra1": { lat: 50.1109, lng: 8.6821, label: "Frankfurt" },
"tor1": { lat: 43.6532, lng: -79.3832, label: "Toronto" },
"blr1": { lat: 12.9716, lng: 77.5946, label: "Bangalore" },
"syd1": { lat: -33.8688, lng: 151.2093, label: "Sydney" },
// AWS-style
"us-east-1": { lat: 39.0438, lng: -77.4874, label: "N. Virginia" },
"us-west-2": { lat: 45.5152, lng: -122.6784, label: "Oregon" },
"eu-west-1": { lat: 53.3498, lng: -6.2603, label: "Ireland" },
"ap-southeast-1": { lat: 1.3521, lng: 103.8198, label: "Singapore" },
"ap-northeast-1": { lat: 35.6762, lng: 139.6503, label: "Tokyo" },
}
// ---------------------------------------------------------------------------
// Status colour palette
// ---------------------------------------------------------------------------
const STATUS_COLORS: Record<NodeLocation["status"], string> = {
running: "#22c55e",
syncing: "#eab308",
provisioning: "#f97316",
stopped: "#6b7280",
error: "#ef4444",
}
const STATUS_LABELS: Record<NodeLocation["status"], string> = {
running: "Running",
syncing: "Syncing",
provisioning: "Provisioning",
stopped: "Stopped",
error: "Error",
}
// ---------------------------------------------------------------------------
// Mercator projection helper
// ---------------------------------------------------------------------------
/** Convert lat/lng to x/y in 0..1 normalised Mercator space. */
function toMercator(lat: number, lng: number): { x: number; y: number } {
const x = (lng + 180) / 360
const latRad = (lat * Math.PI) / 180
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
const y = 0.5 - mercN / (2 * Math.PI)
return { x, y }
}
// ---------------------------------------------------------------------------
// Simplified world-outline SVG path data
//
// Hand-reduced continent outlines in Mercator projection coordinates, fitting
// a viewBox of 0 0 1000 500. Dark-themed: dark ocean, lighter landmasses.
// ---------------------------------------------------------------------------
const WORLD_PATH = [
// --- North America ---
"M 130,105 L 122,100 118,92 120,84 126,78 135,74 148,72 162,70",
"L 175,74 185,72 195,76 205,82 215,90 222,98 228,106 232,115",
"L 230,124 224,132 218,140 212,148 205,156 198,162 192,168",
"L 186,174 180,178 174,180 168,182 163,186 158,190 154,186",
"L 150,180 146,175 142,168 138,160 135,152 132,144 130,136",
"L 128,128 126,120 128,112 Z",
// --- Mexico / Central America ---
"M 154,186 L 158,192 162,198 165,205 168,212 172,218 175,224",
"L 178,228 176,232 172,230 168,226 164,222 160,218 156,214",
"L 153,208 152,200 152,194 Z",
// --- South America ---
"M 178,228 L 186,236 194,245 202,255 208,265 214,278 218,292",
"L 222,306 225,320 226,335 224,348 220,360 215,370 208,378",
"L 200,384 192,386 186,380 182,372 180,360 178,346 176,332",
"L 175,318 176,304 178,290 180,276 182,262 183,248 180,238 Z",
// --- Europe ---
"M 448,82 L 455,76 465,72 476,70 486,72 495,76 502,82 506,90",
"L 504,96 498,102 490,106 482,108 474,106 467,102 460,96",
"L 454,90 450,86 Z",
// --- British Isles ---
"M 432,82 L 437,76 443,78 444,84 440,88 434,86 Z",
// --- Scandinavia ---
"M 476,50 L 484,44 492,42 498,46 502,54 500,62 496,68 490,66",
"L 484,60 480,54 Z",
// --- Africa ---
"M 448,148 L 458,142 470,138 484,136 496,140 508,146 516,156",
"L 522,168 526,182 528,196 526,212 522,228 518,244 512,258",
"L 504,268 496,274 486,276 478,272 470,264 464,254 458,242",
"L 454,228 452,214 450,200 448,186 446,172 446,158 Z",
// --- Arabian Peninsula ---
"M 530,142 L 540,138 548,142 552,150 548,158 540,162 534,158",
"L 530,150 Z",
// --- India subcontinent ---
"M 582,132 L 594,128 604,132 612,142 616,156 612,170 606,180",
"L 598,186 590,184 584,176 580,164 578,152 580,140 Z",
// --- Southeast Asia peninsula ---
"M 622,156 L 630,150 638,154 640,162 636,170 628,172 624,164 Z",
// --- East Asia / China ---
"M 618,80 L 630,74 644,70 658,68 672,72 682,80 690,90 692,102",
"L 688,114 680,122 670,128 660,130 650,128 640,122 632,114",
"L 626,104 622,94 620,86 Z",
// --- Japan ---
"M 698,86 L 704,80 710,84 712,92 710,102 704,108 698,104 696,96 Z",
// --- Maritime Southeast Asia ---
"M 632,212 L 644,206 658,204 672,208 684,214 694,220 690,228",
"L 678,230 666,226 654,222 642,218 Z",
// --- Australia ---
"M 678,288 L 694,278 712,274 730,276 746,282 756,292 762,304",
"L 760,318 752,330 740,336 726,338 712,334 700,326 690,316",
"L 684,304 680,296 Z",
// --- New Zealand ---
"M 786,332 L 792,326 798,330 800,338 796,344 790,342 788,336 Z",
// --- Northern Asia / Russia ---
"M 508,52 L 530,46 558,42 588,38 620,36 654,34 686,38 712,44",
"L 732,52 742,60 736,66 722,64 706,60 686,56 664,54 642,52",
"L 620,50 598,48 576,48 556,50 536,52 520,54 512,54 Z",
].join(" ")
// ---------------------------------------------------------------------------
// Resolved node with pixel coordinates
// ---------------------------------------------------------------------------
type ResolvedNode = NodeLocation & {
px: number
py: number
regionLabel: string
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function NodeGlobe({
nodes,
onNodeClick,
selectedNetwork,
className,
}: NodeGlobeProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [hoveredNode, setHoveredNode] = useState<ResolvedNode | null>(null)
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
const MAP_W = 1000
const MAP_H = 500
// ------- derived data -------
const filteredNodes = useMemo(() => {
if (!selectedNetwork || selectedNetwork === "all") return nodes
return nodes.filter((n) => n.network === selectedNetwork)
}, [nodes, selectedNetwork])
const resolvedNodes = useMemo<ResolvedNode[]>(() => {
const out: ResolvedNode[] = []
for (const node of filteredNodes) {
let lat = node.latitude
let lng = node.longitude
let regionLabel = node.region
if ((lat == null || lng == null) && REGION_COORDS[node.region]) {
const rc = REGION_COORDS[node.region]
lat = rc.lat
lng = rc.lng
regionLabel = rc.label
}
if (lat == null || lng == null) continue
const { x, y } = toMercator(lat, lng)
out.push({ ...node, px: x * MAP_W, py: y * MAP_H, regionLabel })
}
return out
}, [filteredNodes])
const networks = useMemo(() => {
const s = new Set(nodes.map((n) => n.network))
return Array.from(s).sort()
}, [nodes])
// ------- event handlers -------
const handleMouseEnter = useCallback(
(node: ResolvedNode, e: React.MouseEvent) => {
setHoveredNode(node)
const rect = containerRef.current?.getBoundingClientRect()
if (rect) {
setTooltipPos({
x: e.clientX - rect.left + 14,
y: e.clientY - rect.top - 10,
})
}
},
[],
)
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!hoveredNode) return
const rect = containerRef.current?.getBoundingClientRect()
if (rect) {
setTooltipPos({
x: e.clientX - rect.left + 14,
y: e.clientY - rect.top - 10,
})
}
},
[hoveredNode],
)
const handleMouseLeave = useCallback(() => {
setHoveredNode(null)
}, [])
// ------- render -------
return (
<div ref={containerRef} className={cn("relative w-full", className)}>
{/* Pulse keyframes injected once */}
<style>{`
@keyframes ng-dot-pulse {
0%, 100% { r: 5; opacity: 0.9 }
50% { r: 7; opacity: 1 }
}
@keyframes ng-ring-pulse {
0% { r: 5; opacity: 0.5 }
100% { r: 18; opacity: 0 }
}
.ng-dot-running { animation: ng-dot-pulse 2s ease-in-out infinite }
.ng-dot-syncing { animation: ng-dot-pulse 1.2s ease-in-out infinite }
.ng-ring { animation: ng-ring-pulse 2.5s ease-out infinite; pointer-events: none }
`}</style>
{/* SVG flat map */}
<svg
viewBox={`0 0 ${MAP_W} ${MAP_H}`}
className="w-full h-auto"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="World map showing blockchain node locations"
>
{/* Ocean background */}
<rect width={MAP_W} height={MAP_H} rx={8} fill="#0a0e17" />
{/* Graticule grid */}
{Array.from({ length: 17 }, (_, i) => {
const x = (i + 1) * (MAP_W / 18)
return <line key={`v${i}`} x1={x} y1={0} x2={x} y2={MAP_H} stroke="#1e293b" strokeWidth={0.5} />
})}
{Array.from({ length: 8 }, (_, i) => {
const y = (i + 1) * (MAP_H / 9)
return <line key={`h${i}`} x1={0} y1={y} x2={MAP_W} y2={y} stroke="#1e293b" strokeWidth={0.5} />
})}
{/* Landmasses */}
<path d={WORLD_PATH} fill="#1a2332" stroke="#2d3f54" strokeWidth={1} strokeLinejoin="round" />
{/* Node markers */}
{resolvedNodes.map((node) => {
const color = STATUS_COLORS[node.status]
const hovered = hoveredNode?.id === node.id
// Stagger animation starts to avoid synchronised pulses
const delay = `${(node.id.charCodeAt(0) % 7) * 0.35}s`
return (
<g
key={node.id}
transform={`translate(${node.px},${node.py})`}
className="cursor-pointer"
onMouseEnter={(e) => handleMouseEnter(node, e)}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={() => onNodeClick?.(node.id)}
role="button"
tabIndex={0}
aria-label={`${node.name} - ${STATUS_LABELS[node.status]}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onNodeClick?.(node.id)
}}
>
{/* Expanding ring for active statuses */}
{(node.status === "running" || node.status === "syncing") && (
<circle
r={5}
fill="none"
stroke={color}
strokeWidth={1.5}
className="ng-ring"
style={{ animationDelay: delay }}
/>
)}
{/* Ambient glow */}
<circle r={hovered ? 12 : 8} fill={color} opacity={0.15} />
{/* Primary dot */}
<circle
r={hovered ? 7 : 5}
fill={color}
stroke="#0a0e17"
strokeWidth={1.5}
opacity={0.9}
className={cn(
node.status === "running" && "ng-dot-running",
node.status === "syncing" && "ng-dot-syncing",
)}
style={{ animationDelay: delay }}
/>
{/* Centre highlight */}
<circle r={hovered ? 3 : 2} fill="#fff" opacity={0.6} />
</g>
)
})}
</svg>
{/* Tooltip */}
{hoveredNode && (
<div
className="absolute z-50 pointer-events-none rounded-lg border border-border bg-popover p-3 text-sm shadow-lg min-w-[180px]"
style={{
left: tooltipPos.x,
top: tooltipPos.y,
transform:
tooltipPos.x > (containerRef.current?.clientWidth ?? 800) * 0.7
? "translateX(-110%)"
: undefined,
}}
>
<div className="font-medium text-foreground">{hoveredNode.name}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{hoveredNode.chain} / {hoveredNode.network}
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: STATUS_COLORS[hoveredNode.status] }}
/>
<span className="text-foreground">{STATUS_LABELS[hoveredNode.status]}</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">{hoveredNode.regionLabel}</div>
<div className="text-xs text-muted-foreground">Type: {hoveredNode.nodeType}</div>
{hoveredNode.peerCount != null && (
<div className="text-xs text-muted-foreground">Peers: {hoveredNode.peerCount}</div>
)}
{hoveredNode.syncProgress != null && hoveredNode.status === "syncing" && (
<div className="text-xs text-muted-foreground">
Sync: {(hoveredNode.syncProgress * 100).toFixed(1)}%
</div>
)}
</div>
)}
{/* Legend */}
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-xs text-muted-foreground">
{(Object.keys(STATUS_COLORS) as NodeLocation["status"][]).map((status) => (
<div key={status} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ background: STATUS_COLORS[status] }}
/>
<span>{STATUS_LABELS[status]}</span>
</div>
))}
{networks.length > 0 && (
<div className="ml-auto">
{resolvedNodes.length} node{resolvedNodes.length !== 1 ? "s" : ""}
{selectedNetwork && selectedNetwork !== "all" ? ` on ${selectedNetwork}` : ""}
</div>
)}
</div>
</div>
)
}