From 9f29f16a5c7b2fdd3cfdff3ef15dc5e1061c2cb3 Mon Sep 17 00:00:00 2001 From: Timrossid Date: Wed, 17 Jun 2026 10:42:14 +0100 Subject: [PATCH 1/3] feat: spatial hash grid, off-screen double-buffering, virtual camera zoom/pan for GridMap Implement performance improvements for 500+ grid resource node rendering: - SpatialHashGrid class (src/utils/spatial.ts) partitions canvas into CELL_SIZE cells and only queries visible nodes per frame - Off-screen canvas double-buffering separates drawing from compositing, preventing visual artifacts - devicePixelRatio-aware canvas scaling for sharp Retina rendering - Virtual camera transform supporting zoom (scroll wheel, 0.5x-5x) and pan (click-drag), culling off-screen nodes entirely - ResizeObserver with 100ms debounce to avoid layout thrashing - Batch node data updates within a single rAF frame - Per-frame resource pulsation via Date.now() sine wave - Viewport-constrained grid line drawing (only lines in visible range) - Pointer capture for reliable drag tracking --- src/components/spatial/GridMap.tsx | 257 ++++++++++++++++++++++++----- src/utils/spatial.ts | 55 ++++++ 2 files changed, 271 insertions(+), 41 deletions(-) create mode 100644 src/utils/spatial.ts diff --git a/src/components/spatial/GridMap.tsx b/src/components/spatial/GridMap.tsx index f10aace..e3efd48 100644 --- a/src/components/spatial/GridMap.tsx +++ b/src/components/spatial/GridMap.tsx @@ -2,6 +2,7 @@ import { useRef, useEffect, useCallback, useState } from "react"; import { useTheme } from "@/components/providers/ThemeProvider"; +import { SpatialHashGrid, type Bounds } from "@/utils/spatial"; interface GridNode { id: string; @@ -33,37 +34,144 @@ function generateNodes(count: number, width: number, height: number): GridNode[] const NODE_RADIUS = 6; const NODE_COUNT = 500; +const CELL_SIZE = NODE_RADIUS * 4; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 5; + +interface Camera { + x: number; + y: number; + zoom: number; +} + +function getViewportBounds( + canvas: HTMLCanvasElement, + camera: Camera +): Bounds { + const halfW = canvas.clientWidth / 2; + const halfH = canvas.clientHeight / 2; + return { + x: camera.x - halfW / camera.zoom, + y: camera.y - halfH / camera.zoom, + width: canvas.clientWidth / camera.zoom, + height: canvas.clientHeight / camera.zoom, + }; +} export function GridMap() { const canvasRef = useRef(null); const containerRef = useRef(null); + const offScreenRef = useRef(null); + const cameraRef = useRef({ x: 0, y: 0, zoom: 1 }); + const nodesRef = useRef([]); + const spatialRef = useRef | null>(null); + const animFrameRef = useRef(0); + const isDraggingRef = useRef(false); + const lastPointerRef = useRef({ x: 0, y: 0 }); + const dprRef = useRef(1); + const resizeTimerRef = useRef>(); + const needsRebuildRef = useRef(true); const { mode } = useTheme(); const [dimensions, setDimensions] = useState({ width: 800, height: 500 }); - const nodesRef = useRef([]); - const animFrame = useRef(0); + + const getCanvasSize = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return { w: 800, h: 500 }; + const dpr = dprRef.current; + return { w: canvas.clientWidth * dpr, h: canvas.clientHeight * dpr }; + }, []); + + const setupCanvasDpr = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + dprRef.current = dpr; + const rect = canvas.getBoundingClientRect(); + const w = rect.width; + const h = rect.height; + if (canvas.width !== w * dpr || canvas.height !== h * dpr) { + canvas.width = w * dpr; + canvas.height = h * dpr; + } + }, []); + + useEffect(() => { + spatialRef.current = new SpatialHashGrid(CELL_SIZE); + }, []); useEffect(() => { const container = containerRef.current; if (!container) return; const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width, height } = entry.contentRect; - setDimensions({ width: Math.floor(width), height: Math.floor(height) }); - } + clearTimeout(resizeTimerRef.current); + resizeTimerRef.current = setTimeout(() => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setDimensions({ width: Math.floor(width), height: Math.floor(height) }); + } + }, 100); }); observer.observe(container); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + clearTimeout(resizeTimerRef.current); + }; }, []); useEffect(() => { nodesRef.current = generateNodes(NODE_COUNT, dimensions.width, dimensions.height); + needsRebuildRef.current = true; }, [dimensions.width, dimensions.height]); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + setupCanvasDpr(); + }, [dimensions, setupCanvasDpr]); + + const rebuildSpatialIndex = useCallback(() => { + const spatial = spatialRef.current; + if (!spatial) return; + spatial.clear(); + const nodes = nodesRef.current; + for (let i = 0; i < nodes.length; i++) { + spatial.insert(nodes[i]); + } + needsRebuildRef.current = false; + }, []); + const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; + const dpr = dprRef.current; + const cssW = canvas.clientWidth; + const cssH = canvas.clientHeight; + if (cssW === 0 || cssH === 0) return; + + if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) { + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + } + + if (needsRebuildRef.current) { + rebuildSpatialIndex(); + } + + let offScreen = offScreenRef.current; + if (!offScreen) { + offScreen = document.createElement("canvas"); + offScreenRef.current = offScreen; + } + offScreen.width = canvas.width; + offScreen.height = canvas.height; + const ctx = canvas.getContext("2d"); - if (!ctx) return; + const offCtx = offScreen.getContext("2d"); + if (!ctx || !offCtx) return; + + const camera = cameraRef.current; + + offCtx.setTransform(dpr, 0, 0, dpr, 0, 0); const isDark = mode === "dark" || mode === "high-contrast"; const bg = isDark ? "#0a0a0a" : "#ffffff"; @@ -72,54 +180,118 @@ export function GridMap() { const faultColor = "#ef4444"; const gridColor = isDark ? "#1a1a1a" : "#f0f0f0"; - ctx.clearRect(0, 0, dimensions.width, dimensions.height); - ctx.fillStyle = bg; - ctx.fillRect(0, 0, dimensions.width, dimensions.height); + offCtx.fillStyle = bg; + offCtx.fillRect(0, 0, cssW, cssH); + + offCtx.save(); + offCtx.translate(cssW / 2, cssH / 2); + offCtx.scale(camera.zoom, camera.zoom); + offCtx.translate(-camera.x, -camera.y); - ctx.strokeStyle = gridColor; - ctx.lineWidth = 1; + offCtx.strokeStyle = gridColor; + offCtx.lineWidth = 1 / camera.zoom; const spacing = 40; - for (let x = 0; x < dimensions.width; x += spacing) { - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, dimensions.height); - ctx.stroke(); + const vp = getViewportBounds(canvas, camera); + const gridStartX = Math.floor(vp.x / spacing) * spacing; + const gridStartY = Math.floor(vp.y / spacing) * spacing; + const gridEndX = vp.x + vp.width; + const gridEndY = vp.y + vp.height; + for (let x = gridStartX; x <= gridEndX; x += spacing) { + offCtx.beginPath(); + offCtx.moveTo(x, gridStartY); + offCtx.lineTo(x, gridEndY); + offCtx.stroke(); } - for (let y = 0; y < dimensions.height; y += spacing) { - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(dimensions.width, y); - ctx.stroke(); + for (let y = gridStartY; y <= gridEndY; y += spacing) { + offCtx.beginPath(); + offCtx.moveTo(gridStartX, y); + offCtx.lineTo(gridEndX, y); + offCtx.stroke(); } - const nodes = nodesRef.current; - for (const node of nodes) { - ctx.beginPath(); - ctx.arc(node.x, node.y, NODE_RADIUS, 0, Math.PI * 2); + const visible = spatialRef.current?.query(vp) ?? []; + const now = Date.now(); + for (let i = 0; i < visible.length; i++) { + const node = visible[i]; + const pulse = 0.3 + Math.sin(now * 0.002 + i) * 0.1 + 0.1; + const alpha = pulse * 0.7 + 0.3; + + offCtx.beginPath(); + offCtx.arc(node.x, node.y, NODE_RADIUS, 0, Math.PI * 2); const color = node.status === "active" ? activeColor : node.status === "idle" ? idleColor : faultColor; - ctx.fillStyle = color; - ctx.globalAlpha = 0.3 + node.resource * 0.7; - ctx.fill(); - ctx.globalAlpha = 1; - ctx.strokeStyle = color; - ctx.lineWidth = 1.5; - ctx.stroke(); + offCtx.fillStyle = color; + offCtx.globalAlpha = alpha; + offCtx.fill(); + offCtx.globalAlpha = 1; + offCtx.strokeStyle = color; + offCtx.lineWidth = 1.5 / camera.zoom; + offCtx.stroke(); } - }, [dimensions, mode]); + + offCtx.restore(); + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + ctx.drawImage(offScreen, 0, 0, cssW, cssH); + }, [mode, rebuildSpatialIndex]); useEffect(() => { - animFrame.current = requestAnimationFrame(function loop() { + animFrameRef.current = requestAnimationFrame(function loop() { draw(); - animFrame.current = requestAnimationFrame(loop); + animFrameRef.current = requestAnimationFrame(loop); }); - return () => cancelAnimationFrame(animFrame.current); + return () => cancelAnimationFrame(animFrameRef.current); }, [draw]); + const handlePointerDown = useCallback((e: React.PointerEvent) => { + isDraggingRef.current = true; + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + const canvas = canvasRef.current; + if (canvas) canvas.setPointerCapture(e.pointerId); + }, []); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isDraggingRef.current) return; + const dx = e.clientX - lastPointerRef.current.x; + const dy = e.clientY - lastPointerRef.current.y; + const camera = cameraRef.current; + camera.x -= dx / camera.zoom; + camera.y -= dy / camera.zoom; + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + }, + [] + ); + + const handlePointerUp = useCallback(() => { + isDraggingRef.current = false; + }, []); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const camera = cameraRef.current; + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; + const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, camera.zoom * factor)); + + const worldX = (mx - rect.width / 2) / camera.zoom + camera.x; + const worldY = (my - rect.height / 2) / camera.zoom + camera.y; + + camera.x = worldX - (mx - rect.width / 2) / newZoom; + camera.y = worldY - (my - rect.height / 2) / newZoom; + camera.zoom = newZoom; + }, []); + return (
); diff --git a/src/utils/spatial.ts b/src/utils/spatial.ts new file mode 100644 index 0000000..f3456c9 --- /dev/null +++ b/src/utils/spatial.ts @@ -0,0 +1,55 @@ +export interface Bounds { + x: number; + y: number; + width: number; + height: number; +} + +export class SpatialHashGrid { + private cells = new Map(); + private cellSize: number; + + constructor(cellSize: number) { + this.cellSize = cellSize; + } + + private key(cx: number, cy: number): string { + return `${cx}:${cy}`; + } + + insert(node: T): void { + const cx = Math.floor(node.x / this.cellSize); + const cy = Math.floor(node.y / this.cellSize); + const k = this.key(cx, cy); + let bucket = this.cells.get(k); + if (!bucket) { + bucket = []; + this.cells.set(k, bucket); + } + bucket.push(node); + } + + clear(): void { + this.cells.clear(); + } + + query(bounds: Bounds): T[] { + const startCX = Math.floor(bounds.x / this.cellSize); + const endCX = Math.floor((bounds.x + bounds.width) / this.cellSize); + const startCY = Math.floor(bounds.y / this.cellSize); + const endCY = Math.floor((bounds.y + bounds.height) / this.cellSize); + const result: T[] = []; + for (let cx = startCX; cx <= endCX; cx++) { + for (let cy = startCY; cy <= endCY; cy++) { + const k = this.key(cx, cy); + const bucket = this.cells.get(k); + if (bucket) { + for (const node of bucket) { + result.push(node); + } + } + } + } + return result; + } +} From 67df2e0f2a9b5b130c15a6f27a94898aac39ee68 Mon Sep 17 00:00:00 2001 From: timot Date: Wed, 17 Jun 2026 12:01:42 +0100 Subject: [PATCH 2/3] fix: remove unused getCanvasSize function to fix lint error --- src/components/spatial/GridMap.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/spatial/GridMap.tsx b/src/components/spatial/GridMap.tsx index e3efd48..a656b39 100644 --- a/src/components/spatial/GridMap.tsx +++ b/src/components/spatial/GridMap.tsx @@ -74,13 +74,6 @@ export function GridMap() { const { mode } = useTheme(); const [dimensions, setDimensions] = useState({ width: 800, height: 500 }); - const getCanvasSize = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return { w: 800, h: 500 }; - const dpr = dprRef.current; - return { w: canvas.clientWidth * dpr, h: canvas.clientHeight * dpr }; - }, []); - const setupCanvasDpr = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; From d7a323567c24e1acba1402b5d6f2ec2c9429d2f5 Mon Sep 17 00:00:00 2001 From: timot Date: Wed, 17 Jun 2026 12:03:11 +0100 Subject: [PATCH 3/3] fix: pass initial value to useRef for resizeTimerRef --- src/components/spatial/GridMap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/spatial/GridMap.tsx b/src/components/spatial/GridMap.tsx index a656b39..e45f48c 100644 --- a/src/components/spatial/GridMap.tsx +++ b/src/components/spatial/GridMap.tsx @@ -69,7 +69,7 @@ export function GridMap() { const isDraggingRef = useRef(false); const lastPointerRef = useRef({ x: 0, y: 0 }); const dprRef = useRef(1); - const resizeTimerRef = useRef>(); + const resizeTimerRef = useRef>(undefined); const needsRebuildRef = useRef(true); const { mode } = useTheme(); const [dimensions, setDimensions] = useState({ width: 800, height: 500 });