-
-
Other Network Health Panel
+
+
+
Validator topology
+
+
+
+
+
+
Other Network Health Panel
+
+
+
-
-
);
diff --git a/e2e/topology-performance.spec.ts b/e2e/topology-performance.spec.ts
new file mode 100644
index 0000000..5020f89
--- /dev/null
+++ b/e2e/topology-performance.spec.ts
@@ -0,0 +1,28 @@
+import { test, expect } from '@playwright/test'
+
+test('10,000 node topology keeps p95 frame time below 33ms', async ({ page }) => {
+ await page.goto('/network')
+ await page.getByLabel('High-density validator topology canvas').waitFor()
+
+ const p95 = await page.evaluate(async () => {
+ const samples: number[] = []
+ let last = performance.now()
+ const deadline = performance.now() + 30_000
+ return await new Promise
((resolve) => {
+ const sample = (now: number) => {
+ samples.push(now - last)
+ last = now
+ window.dispatchEvent(new WheelEvent('wheel', { deltaY: Math.sin(now / 500) * 12 }))
+ if (now >= deadline) {
+ samples.sort((a, b) => a - b)
+ resolve(samples[Math.floor(samples.length * 0.95)] ?? 0)
+ return
+ }
+ requestAnimationFrame(sample)
+ }
+ requestAnimationFrame(sample)
+ })
+ })
+
+ expect(p95).toBeLessThan(33)
+})
diff --git a/src/components/network/NetworkGraph.tsx b/src/components/network/NetworkGraph.tsx
new file mode 100644
index 0000000..9939135
--- /dev/null
+++ b/src/components/network/NetworkGraph.tsx
@@ -0,0 +1,10 @@
+'use client'
+
+import { NodeTopologyMap } from '@/src/components/network/NodeTopologyMap'
+import { useNodeTopology } from '@/src/hooks/useNodeTopology'
+
+/** Compatibility wrapper that routes the legacy graph surface to canvas rendering. */
+export function NetworkGraph() {
+ const topology = useNodeTopology()
+ return ({ ...node, x: node.x ?? 0, y: node.y ?? 0 }))} edges={topology.edges} />
+}
diff --git a/src/components/network/NodeTopologyMap.tsx b/src/components/network/NodeTopologyMap.tsx
new file mode 100644
index 0000000..13d6fa5
--- /dev/null
+++ b/src/components/network/NodeTopologyMap.tsx
@@ -0,0 +1,376 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { runForceLayout } from "@/src/lib/forceLayout";
+import type { Edge, PositionedNode } from "@/src/types/topology";
+
+const HEIGHT = 640;
+const CELL_SIZE = 100;
+const NODE_RADIUS = 4;
+
+type SpatialHash = Map;
+type Hover = { node: PositionedNode; x: number; y: number } | null;
+
+function createWorker(): Worker | null {
+ try {
+ return new Worker(
+ new URL("../../workers/forceLayout.worker.ts", import.meta.url),
+ );
+ } catch {
+ return null;
+ }
+}
+
+function cellKey(cx: number, cy: number) {
+ return `${cx}:${cy}`;
+}
+
+function buildSpatialHash(nodes: PositionedNode[]): SpatialHash {
+ const hash: SpatialHash = new Map();
+ for (const node of nodes) {
+ const cx = Math.floor(node.x / CELL_SIZE);
+ const cy = Math.floor(node.y / CELL_SIZE);
+ const key = cellKey(cx, cy);
+ const bucket = hash.get(key);
+ if (bucket) bucket.push(node);
+ else hash.set(key, [node]);
+ }
+ return hash;
+}
+
+function latencyColor(latencyMs: number) {
+ if (latencyMs < 80) return "rgba(34,197,94,0.5)";
+ if (latencyMs < 180) return "rgba(250,204,21,0.5)";
+ return "rgba(248,113,113,0.55)";
+}
+
+function statusColor(status: PositionedNode["status"]) {
+ if (status === "offline") return "#f87171";
+ if (status === "syncing") return "#facc15";
+ return "#22c55e";
+}
+
+class TopologyCanvas {
+ private edgePathCache = new Map();
+ private topologyKey = "";
+
+ render(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ nodes: PositionedNode[],
+ edges: Edge[],
+ hover: PositionedNode | null,
+ ) {
+ ctx.clearRect(0, 0, width, height);
+ this.drawGrid(ctx, width, height);
+ this.drawEdges(ctx, nodes, edges);
+ this.drawNodes(ctx, width, height, nodes);
+ if (hover) this.drawSelection(ctx, hover);
+ }
+
+ private drawGrid(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ ) {
+ ctx.save();
+ ctx.strokeStyle = "rgba(148,163,184,0.08)";
+ ctx.lineWidth = 1;
+ for (let x = 0; x < width; x += CELL_SIZE) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, height);
+ ctx.stroke();
+ }
+ for (let y = 0; y < height; y += CELL_SIZE) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ private drawEdges(
+ ctx: CanvasRenderingContext2D,
+ nodes: PositionedNode[],
+ edges: Edge[],
+ ) {
+ const byId = new Map(nodes.map((node) => [node.id, node]));
+ const key = `${nodes.length}:${edges.length}`;
+ if (key !== this.topologyKey) {
+ this.edgePathCache.clear();
+ this.topologyKey = key;
+ }
+
+ ctx.save();
+ ctx.lineWidth = 0.8;
+ for (const edge of edges) {
+ const source = byId.get(edge.source);
+ const target = byId.get(edge.target);
+ if (!source || !target) continue;
+ let path = this.edgePathCache.get(edge.id);
+ if (!path) {
+ path = new Path2D();
+ const midX = (source.x + target.x) / 2;
+ const midY = (source.y + target.y) / 2 - 18;
+ path.moveTo(source.x, source.y);
+ path.quadraticCurveTo(midX, midY, target.x, target.y);
+ this.edgePathCache.set(edge.id, path);
+ }
+ ctx.strokeStyle = latencyColor(edge.latencyMs);
+ ctx.stroke(path);
+
+ const angle = Math.atan2(target.y - source.y, target.x - source.x);
+ ctx.fillStyle = latencyColor(edge.latencyMs);
+ ctx.beginPath();
+ ctx.moveTo(target.x, target.y);
+ ctx.lineTo(
+ target.x - 8 * Math.cos(angle - 0.35),
+ target.y - 8 * Math.sin(angle - 0.35),
+ );
+ ctx.lineTo(
+ target.x - 8 * Math.cos(angle + 0.35),
+ target.y - 8 * Math.sin(angle + 0.35),
+ );
+ ctx.closePath();
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ private drawNodes(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ nodes: PositionedNode[],
+ ) {
+ const cx = width / 2;
+ const cy = height / 2;
+ const maxDistance = Math.hypot(cx, cy);
+ ctx.save();
+ for (const node of nodes) {
+ const distanceRatio = Math.hypot(node.x - cx, node.y - cy) / maxDistance;
+ const full = distanceRatio <= 0.25;
+ const radius = distanceRatio > 0.5 ? 2 : NODE_RADIUS;
+ ctx.beginPath();
+ ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
+ ctx.fillStyle = statusColor(node.status);
+ ctx.fill();
+ if (full) {
+ ctx.strokeStyle = "rgba(226,232,240,0.75)";
+ ctx.lineWidth = 1.2;
+ ctx.stroke();
+ ctx.font = "10px ui-sans-serif, system-ui, sans-serif";
+ ctx.fillStyle = "rgba(226,232,240,0.9)";
+ ctx.fillText(node.label, node.x + 7, node.y - 7);
+ }
+ }
+ ctx.restore();
+ }
+
+ private drawSelection(ctx: CanvasRenderingContext2D, node: PositionedNode) {
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(node.x, node.y, 10, 0, Math.PI * 2);
+ ctx.strokeStyle = "#38bdf8";
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ ctx.restore();
+ }
+}
+
+export function NodeTopologyMap({
+ nodes,
+ edges,
+}: {
+ nodes: PositionedNode[];
+ edges: Edge[];
+}) {
+ const containerRef = useRef(null);
+ const canvasRef = useRef(null);
+ const rendererRef = useRef(new TopologyCanvas());
+ const workerRef = useRef(null);
+ const rafRef = useRef(null);
+ const nodesRef = useRef(nodes);
+ const spatialRef = useRef(buildSpatialHash(nodes));
+ const [size, setSize] = useState({ width: 0, height: HEIGHT });
+ const [hover, setHover] = useState(null);
+ const gpuMode = useMemo(
+ () =>
+ typeof navigator !== "undefined" &&
+ "gpu" in navigator &&
+ nodes.length > 5000,
+ [nodes.length],
+ );
+
+ const topologyKey = useMemo(
+ () => `${nodes.length}:${edges.length}`,
+ [nodes.length, edges.length],
+ );
+
+ // `gpuMode` is derived from `navigator` capability and `nodes.length`.
+ // Computed via `useMemo` above to avoid calling setState inside an effect.
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const update = () => setSize({ width: el.clientWidth, height: HEIGHT });
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ if (size.width === 0) return;
+ const initial = nodes.map((node) => ({
+ ...node,
+ x: node.x + size.width / 2,
+ y: node.y + size.height / 2,
+ }));
+ nodesRef.current = initial;
+ spatialRef.current = buildSpatialHash(initial);
+ const worker = createWorker();
+ workerRef.current = worker;
+ if (worker) {
+ worker.onmessage = (
+ event: MessageEvent<{
+ type: "TICK";
+ payload: { nodes: PositionedNode[] };
+ }>,
+ ) => {
+ nodesRef.current = event.data.payload.nodes;
+ spatialRef.current = buildSpatialHash(event.data.payload.nodes);
+ };
+ worker.postMessage({
+ type: "INIT",
+ payload: {
+ nodes: initial,
+ edges,
+ width: size.width,
+ height: size.height,
+ },
+ });
+ }
+ return () => {
+ worker?.postMessage({ type: "STOP" });
+ worker?.terminate();
+ };
+ }, [topologyKey, nodes, edges, size.width, size.height]);
+
+ useEffect(() => {
+ workerRef.current?.postMessage({ type: "RESIZE", payload: size });
+ }, [size]);
+
+ useEffect(() => {
+ const tick = () => {
+ if (!workerRef.current) {
+ runForceLayout(nodesRef.current, edges, {
+ width: size.width,
+ height: size.height,
+ });
+ spatialRef.current = buildSpatialHash(nodesRef.current);
+ }
+ const canvas = canvasRef.current;
+ const ctx = canvas?.getContext("2d");
+ if (canvas && ctx && size.width > 0) {
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = Math.round(size.width * dpr);
+ canvas.height = Math.round(size.height * dpr);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ rendererRef.current.render(
+ ctx,
+ size.width,
+ size.height,
+ nodesRef.current,
+ edges,
+ hover?.node ?? null,
+ );
+ }
+ rafRef.current = requestAnimationFrame(tick);
+ };
+ rafRef.current = requestAnimationFrame(tick);
+ return () => {
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
+ };
+ }, [edges, hover, size]);
+
+ const hitTest = useCallback((x: number, y: number) => {
+ const cx = Math.floor(x / CELL_SIZE);
+ const cy = Math.floor(y / CELL_SIZE);
+ let best: PositionedNode | null = null;
+ let bestDistance = 14 * 14;
+ for (let ox = -1; ox <= 1; ox++) {
+ for (let oy = -1; oy <= 1; oy++) {
+ for (const node of spatialRef.current.get(cellKey(cx + ox, cy + oy)) ??
+ []) {
+ const d = (node.x - x) ** 2 + (node.y - y) ** 2;
+ if (d < bestDistance) {
+ bestDistance = d;
+ best = node;
+ }
+ }
+ }
+ }
+ return best;
+ }, []);
+
+ return (
+
+
+
+ {nodes.length.toLocaleString()} validators ·{" "}
+ {edges.length.toLocaleString()} consensus channels
+
+
+ {gpuMode
+ ? "WebGPU-capable point-sprite path available"
+ : "Canvas + worker physics"}{" "}
+ · spatial hash {CELL_SIZE}px cells
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/useNodeList.ts b/src/hooks/useNodeList.ts
index 249b495..499cbeb 100644
--- a/src/hooks/useNodeList.ts
+++ b/src/hooks/useNodeList.ts
@@ -1,7 +1,11 @@
-'use client';
+"use client";
-import { useMemo, useSyncExternalStore } from 'react';
-import { useNodeStore, type NodeInfo, type FilterState } from '@/src/store/nodeStore';
+import { useMemo, useSyncExternalStore } from "react";
+import {
+ useNodeStore,
+ type NodeInfo,
+ type FilterState,
+} from "@/src/store/nodeStore";
interface FilteredResult {
nodes: NodeInfo[];
@@ -47,9 +51,13 @@ export function useNodeList(): NodeInfo[] {
// Memoized filtered list — only invalidated when both versions change
const { nodes, filter, dataVersion, filterVersion } = snapshot;
const filtered = useMemo(() => {
+ // Refer to version counters so they are treated as used by the linter
+ void dataVersion;
+ void filterVersion;
+
return nodes.filter((node) => {
// Status filter
- if (filter.status !== 'all' && node.status !== filter.status) {
+ if (filter.status !== "all" && node.status !== filter.status) {
return false;
}
diff --git a/src/hooks/useNodeTopology.ts b/src/hooks/useNodeTopology.ts
new file mode 100644
index 0000000..88145aa
--- /dev/null
+++ b/src/hooks/useNodeTopology.ts
@@ -0,0 +1,38 @@
+import { useMemo } from 'react'
+import type { Edge, Node } from '@/src/types/topology'
+
+const NODE_COUNT = 10000
+const EDGE_COUNT = 14000
+const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
+
+/** Deterministic high-density topology data used by the canvas network map. */
+export function useNodeTopology(nodeCount = NODE_COUNT) {
+ return useMemo(() => {
+ const nodes: Node[] = Array.from({ length: nodeCount }, (_, i) => {
+ const radius = 30 + Math.sqrt(i) * 7
+ const theta = i * GOLDEN_ANGLE
+ return {
+ id: `validator-${i}`,
+ label: `Validator ${i}`,
+ status: i % 41 === 0 ? 'offline' : i % 17 === 0 ? 'syncing' : 'active',
+ x: Math.cos(theta) * radius,
+ y: Math.sin(theta) * radius,
+ latencyMs: 20 + ((i * 37) % 220),
+ stake: 32 + (i % 96),
+ }
+ })
+
+ const edges: Edge[] = Array.from({ length: Math.min(EDGE_COUNT, nodeCount * 2) }, (_, i) => {
+ const source = i % nodeCount
+ const target = (source + 1 + ((i * 97) % Math.max(1, nodeCount - 1))) % nodeCount
+ return {
+ id: `edge-${i}`,
+ source: `validator-${source}`,
+ target: `validator-${target}`,
+ latencyMs: 15 + ((source + target) % 260),
+ }
+ })
+
+ return { nodes, edges }
+ }, [nodeCount])
+}
diff --git a/src/lib/forceLayout.ts b/src/lib/forceLayout.ts
new file mode 100644
index 0000000..add5f07
--- /dev/null
+++ b/src/lib/forceLayout.ts
@@ -0,0 +1,51 @@
+import type { Edge, PositionedNode } from '@/src/types/topology'
+
+export interface ForceLayoutOptions {
+ width: number
+ height: number
+ iterations?: number
+}
+
+/**
+ * Lightweight force-layout tick that avoids pulling graph nodes into React's
+ * render path. It is intentionally dependency-free so the same code can be
+ * mirrored in a Web Worker and run at 60 physics updates per second.
+ */
+export function runForceLayout(
+ nodes: PositionedNode[],
+ edges: Edge[],
+ { width, height, iterations = 1 }: ForceLayoutOptions,
+): PositionedNode[] {
+ const byId = new Map(nodes.map((node) => [node.id, node]))
+ const centerX = width / 2
+ const centerY = height / 2
+
+ for (let step = 0; step < iterations; step++) {
+ for (const edge of edges) {
+ const source = byId.get(edge.source)
+ const target = byId.get(edge.target)
+ if (!source || !target) continue
+ const dx = target.x - source.x
+ const dy = target.y - source.y
+ const distance = Math.max(1, Math.hypot(dx, dy))
+ const force = (distance - 85) * 0.0008
+ const fx = dx * force
+ const fy = dy * force
+ source.vx = (source.vx ?? 0) + fx
+ source.vy = (source.vy ?? 0) + fy
+ target.vx = (target.vx ?? 0) - fx
+ target.vy = (target.vy ?? 0) - fy
+ }
+
+ for (const node of nodes) {
+ const cx = centerX - node.x
+ const cy = centerY - node.y
+ node.vx = ((node.vx ?? 0) + cx * 0.0006) * 0.88
+ node.vy = ((node.vy ?? 0) + cy * 0.0006) * 0.88
+ node.x += node.vx
+ node.y += node.vy
+ }
+ }
+
+ return nodes
+}
diff --git a/src/types/topology.ts b/src/types/topology.ts
new file mode 100644
index 0000000..2f93e88
--- /dev/null
+++ b/src/types/topology.ts
@@ -0,0 +1,37 @@
+export type NodeStatus = 'active' | 'syncing' | 'offline'
+
+export interface Node {
+ id: string
+ label: string
+ status: NodeStatus
+ x?: number
+ y?: number
+ latencyMs?: number
+ stake?: number
+}
+
+export interface Edge {
+ id: string
+ source: string
+ target: string
+ latencyMs: number
+}
+
+export interface PositionedNode extends Node {
+ x: number
+ y: number
+ vx?: number
+ vy?: number
+}
+
+export interface PositionedEdge extends Edge {
+ sourceX: number
+ sourceY: number
+ targetX: number
+ targetY: number
+}
+
+export interface TopologySnapshot {
+ nodes: PositionedNode[]
+ edges: Edge[]
+}
diff --git a/src/workers/forceLayout.worker.ts b/src/workers/forceLayout.worker.ts
new file mode 100644
index 0000000..5796ef6
--- /dev/null
+++ b/src/workers/forceLayout.worker.ts
@@ -0,0 +1,50 @@
+import type { Edge, PositionedNode } from '@/src/types/topology'
+import { runForceLayout } from '@/src/lib/forceLayout'
+
+type InitMessage = {
+ type: 'INIT'
+ payload: { nodes: PositionedNode[]; edges: Edge[]; width: number; height: number }
+}
+type ResizeMessage = { type: 'RESIZE'; payload: { width: number; height: number } }
+type StopMessage = { type: 'STOP' }
+
+type WorkerMessage = InitMessage | ResizeMessage | StopMessage
+
+let nodes: PositionedNode[] = []
+let edges: Edge[] = []
+let width = 1200
+let height = 640
+let frame = 0
+let timer: ReturnType | null = null
+
+function postSnapshot() {
+ postMessage({ type: 'TICK', payload: { nodes, edges } })
+}
+
+function start() {
+ if (timer) clearInterval(timer)
+ timer = setInterval(() => {
+ runForceLayout(nodes, edges, { width, height, iterations: 1 })
+ frame += 1
+ // 60 physics updates/sec; transfer render positions at 30 FPS.
+ if (frame % 2 === 0) postSnapshot()
+ }, 1000 / 60)
+}
+
+self.addEventListener('message', (event: MessageEvent) => {
+ const msg = event.data
+ if (msg.type === 'INIT') {
+ nodes = msg.payload.nodes
+ edges = msg.payload.edges
+ width = msg.payload.width
+ height = msg.payload.height
+ postSnapshot()
+ start()
+ } else if (msg.type === 'RESIZE') {
+ width = msg.payload.width
+ height = msg.payload.height
+ } else if (msg.type === 'STOP' && timer) {
+ clearInterval(timer)
+ timer = null
+ }
+})