Major refactor

This commit is contained in:
2026-04-03 23:04:51 -04:00
parent e7f337c735
commit 35d07e8df1
124 changed files with 4929 additions and 2429 deletions

782
ui/src/components/Grid.tsx Normal file
View File

@@ -0,0 +1,782 @@
import {
useRef, useEffect, useCallback, useState,
forwardRef, useImperativeHandle,
} from 'react';
import type { GridCell, GridToken, Tool, ServerMessage, ClientMessage } from '../types';
import { useWebSocket } from '../hooks/useWebSocket';
import TokenDialog from './TokenDialog';
import './Grid.css';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_ZOOM = 40;
const MIN_ZOOM = 8;
const MAX_ZOOM = 160;
const ZOOM_STEP = 1.12;
const BG_COLOR = '#111827';
const GRID_COLOR = 'rgba(255,255,255,0.07)';
const GRID_COLOR_MAJOR = 'rgba(255,255,255,0.16)';
/** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */
const MAX_FLOOD_CELLS = 2500;
/** World units per second for WASD keyboard panning. */
const WASD_PAN_SPEED = 12;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Camera {
offsetX: number;
offsetY: number;
zoom: number;
}
interface Props {
mapId: string;
tool: Tool;
paintColor: string;
tokenColor: string;
onColorsLoaded: (colors: string[]) => void;
}
export interface GridHandle {
sendColorUpdate: (colors: string[]) => void;
}
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
function canvasToCell(cx: number, cy: number, cam: Camera): { x: number; y: number } {
return {
x: Math.floor(cx / cam.zoom + cam.offsetX),
y: Math.floor(cy / cam.zoom + cam.offsetY),
};
}
function cellToCanvas(cellX: number, cellY: number, cam: Camera): { x: number; y: number } {
return {
x: (cellX - cam.offsetX) * cam.zoom,
y: (cellY - cam.offsetY) * cam.zoom,
};
}
function drawToken(
ctx: CanvasRenderingContext2D,
cellX: number,
cellY: number,
label: string,
color: string,
cam: Camera,
) {
const zoom = cam.zoom;
const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
const cx = px + zoom / 2;
const cy = py + zoom / 2;
const r = zoom * 0.38;
ctx.shadowColor = 'rgba(0,0,0,0.6)';
ctx.shadowBlur = 5;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = Math.max(1, zoom * 0.04);
ctx.stroke();
ctx.shadowBlur = 0;
if (zoom >= 14) {
const words = label.trim().split(/\s+/);
const initials =
words.length >= 2
? (words[0][0] + words[1][0]).toUpperCase()
: label.slice(0, 2).toUpperCase();
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(initials, cx, cy);
}
}
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
/**
* BFS flood fill from (startX, startY) through uncolored cells.
* Returns the list of cells to fill, or null if the region is unbounded
* (search exceeded MAX_FLOOD_CELLS).
*/
function floodFill(
startX: number,
startY: number,
cells: Map<string, GridCell>,
): Array<{ x: number; y: number }> | null {
const visited = new Set<string>();
const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
const found: Array<{ x: number; y: number }> = [];
visited.add(cellKey(startX, startY));
const dirs = [
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 },
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 },
];
while (queue.length > 0) {
const { x, y } = queue.shift()!;
found.push({ x, y });
if (found.length > MAX_FLOOD_CELLS) return null; // unbounded
for (const { dx, dy } of dirs) {
const nx = x + dx;
const ny = y + dy;
const key = cellKey(nx, ny);
if (!visited.has(key) && !cells.has(key)) {
visited.add(key);
queue.push({ x: nx, y: ny });
}
}
}
return found;
}
/**
* Clamp the camera so at least one colored cell or token remains visible in
* the current viewport. No-op when the map has no content yet.
*/
function clampCameraToContent(
cam: Camera,
cells: Map<string, GridCell>,
tokens: Map<string, GridToken>,
canvasW: number,
canvasH: number,
) {
if (cells.size === 0 && tokens.size === 0) return;
const viewLeft = cam.offsetX;
const viewRight = cam.offsetX + canvasW / cam.zoom;
const viewTop = cam.offsetY;
const viewBottom = cam.offsetY + canvasH / cam.zoom;
// Quick visibility check
let anyVisible = false;
cellLoop: for (const cell of cells.values()) {
if (
cell.x + 1 > viewLeft && cell.x < viewRight &&
cell.y + 1 > viewTop && cell.y < viewBottom
) {
anyVisible = true;
break cellLoop;
}
}
if (!anyVisible) {
for (const tok of tokens.values()) {
if (
tok.x + 1 > viewLeft && tok.x < viewRight &&
tok.y + 1 > viewTop && tok.y < viewBottom
) {
anyVisible = true;
break;
}
}
}
if (anyVisible) return;
// Find the bounding box of all content
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (const c of cells.values()) {
if (c.x < minX) minX = c.x;
if (c.x > maxX) maxX = c.x;
if (c.y < minY) minY = c.y;
if (c.y > maxY) maxY = c.y;
}
for (const t of tokens.values()) {
if (t.x < minX) minX = t.x;
if (t.x > maxX) maxX = t.x;
if (t.y < minY) minY = t.y;
if (t.y > maxY) maxY = t.y;
}
const viewW = canvasW / cam.zoom;
const viewH = canvasH / cam.zoom;
// X: bring the nearest content edge to the nearest viewport edge
if (maxX + 1 <= viewLeft) {
// All content is to the left — show rightmost cell at the left edge
cam.offsetX = maxX;
} else if (minX >= viewRight) {
// All content is to the right — show leftmost cell at the right edge
cam.offsetX = minX - viewW + 1;
}
// Y: same logic
if (maxY + 1 <= viewTop) {
cam.offsetY = maxY;
} else if (minY >= viewBottom) {
cam.offsetY = minY - viewH + 1;
}
}
// ---------------------------------------------------------------------------
// Grid component
// ---------------------------------------------------------------------------
const Grid = forwardRef<GridHandle, Props>(function Grid(
{ mapId, tool, paintColor, tokenColor, onColorsLoaded },
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cameraRef = useRef<Camera>({ offsetX: -2, offsetY: -2, zoom: DEFAULT_ZOOM });
const cellsRef = useRef<Map<string, GridCell>>(new Map());
const tokensRef = useRef<Map<string, GridToken>>(new Map());
const [tick, setTick] = useState(0);
const redraw = useCallback(() => setTick(n => n + 1), []);
// ---- Mouse interaction state (refs to avoid stale closures) ----
const isPanning = useRef(false);
const panStart = useRef<{ mx: number; my: number; ox: number; oy: number } | null>(null);
const isDrawing = useRef(false);
const isErasing = useRef(false);
const lastPainted = useRef<string | null>(null);
const isDragging = useRef(false);
const dragTokenId = useRef<string | null>(null);
const dragCellPos = useRef<{ x: number; y: number } | null>(null);
// ---- WASD state ----
const keysHeld = useRef<Set<string>>(new Set());
const rafId = useRef<number | null>(null);
const lastFrameTime = useRef<number | null>(null);
// ---- Stable send ref so handlers never go stale ----
const sendRef = useRef<(msg: ClientMessage) => void>(() => {});
const [cursor, setCursor] = useState<string>('default');
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(null);
// -------------------------------------------------------------------------
// Imperative handle — lets App.tsx trigger a color WS update
// -------------------------------------------------------------------------
useImperativeHandle(ref, () => ({
sendColorUpdate(colors: string[]) {
sendRef.current({ type: 'update_colors', colors });
},
}));
// -------------------------------------------------------------------------
// Resize canvas to fill container
// -------------------------------------------------------------------------
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const resize = () => {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
redraw();
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(container);
return () => observer.disconnect();
}, [redraw]);
// -------------------------------------------------------------------------
// WebSocket
// -------------------------------------------------------------------------
// Keep a stable ref to the callback so handleMessage doesn't re-create
const onColorsLoadedRef = useRef(onColorsLoaded);
useEffect(() => { onColorsLoadedRef.current = onColorsLoaded; }, [onColorsLoaded]);
const handleMessage = useCallback((msg: ServerMessage) => {
switch (msg.type) {
case 'state': {
cellsRef.current.clear();
tokensRef.current.clear();
msg.cells.forEach(c => cellsRef.current.set(cellKey(c.x, c.y), c));
msg.tokens.forEach(t => tokensRef.current.set(t.id, t));
onColorsLoadedRef.current(msg.colors);
redraw();
break;
}
case 'cell_painted': {
const key = cellKey(msg.x, msg.y);
cellsRef.current.set(key, {
map_id: mapId,
x: msg.x, y: msg.y, color: msg.color,
});
redraw();
break;
}
case 'cells_batch_painted': {
msg.cells.forEach(c => {
const key = cellKey(c.x, c.y);
cellsRef.current.set(key, {
map_id: mapId,
x: c.x, y: c.y, color: c.color,
});
});
redraw();
break;
}
case 'cell_erased': {
cellsRef.current.delete(cellKey(msg.x, msg.y));
redraw();
break;
}
case 'token_added': {
tokensRef.current.set(msg.id, {
id: msg.id, map_id: mapId,
x: msg.x, y: msg.y, label: msg.label, color: msg.color,
});
redraw();
break;
}
case 'token_moved': {
const tok = tokensRef.current.get(msg.id);
if (tok) {
tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y });
redraw();
}
break;
}
case 'token_deleted': {
tokensRef.current.delete(msg.id);
redraw();
break;
}
case 'colors_updated': {
onColorsLoadedRef.current(msg.colors);
break;
}
case 'error':
console.error('[Grid WS]', msg.message);
break;
}
}, [mapId, redraw]);
const { send } = useWebSocket(mapId, handleMessage);
useEffect(() => { sendRef.current = send; }, [send]);
// -------------------------------------------------------------------------
// Canvas draw
// -------------------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const W = canvas.width;
const H = canvas.height;
const cam = cameraRef.current;
const { offsetX, offsetY, zoom } = cam;
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, W, H);
const startCX = Math.floor(offsetX) - 1;
const endCX = Math.ceil(offsetX + W / zoom) + 1;
const startCY = Math.floor(offsetY) - 1;
const endCY = Math.ceil(offsetY + H / zoom) + 1;
// Painted cells
cellsRef.current.forEach(cell => {
if (cell.x < startCX || cell.x > endCX || cell.y < startCY || cell.y > endCY) return;
const { x: px, y: py } = cellToCanvas(cell.x, cell.y, cam);
ctx.fillStyle = cell.color;
ctx.fillRect(px, py, zoom, zoom);
});
// Grid lines
if (zoom >= 5) {
ctx.lineWidth = 1;
for (let x = startCX; x <= endCX; x++) {
ctx.strokeStyle = x % 5 === 0 ? GRID_COLOR_MAJOR : GRID_COLOR;
const px = (x - offsetX) * zoom + 0.5;
ctx.beginPath();
ctx.moveTo(px, 0);
ctx.lineTo(px, H);
ctx.stroke();
}
for (let y = startCY; y <= endCY; y++) {
ctx.strokeStyle = y % 5 === 0 ? GRID_COLOR_MAJOR : GRID_COLOR;
const py = (y - offsetY) * zoom + 0.5;
ctx.beginPath();
ctx.moveTo(0, py);
ctx.lineTo(W, py);
ctx.stroke();
}
}
// Tokens (skip the one being dragged)
tokensRef.current.forEach(token => {
if (isDragging.current && dragTokenId.current === token.id) return;
drawToken(ctx, token.x, token.y, token.label, token.color, cam);
});
// Drag ghost
if (isDragging.current && dragCellPos.current && dragTokenId.current) {
const tok = tokensRef.current.get(dragTokenId.current);
if (tok) {
ctx.globalAlpha = 0.6;
drawToken(ctx, dragCellPos.current.x, dragCellPos.current.y, tok.label, tok.color, cam);
ctx.globalAlpha = 1;
}
}
}, [tick]);
// -------------------------------------------------------------------------
// Camera helpers
// -------------------------------------------------------------------------
function applyClampAndRedraw() {
const canvas = canvasRef.current;
if (canvas) {
clampCameraToContent(
cameraRef.current, cellsRef.current, tokensRef.current,
canvas.width, canvas.height,
);
}
redraw();
}
function applyZoom(canvasX: number, canvasY: number, factor: number) {
const cam = cameraRef.current;
const worldX = canvasX / cam.zoom + cam.offsetX;
const worldY = canvasY / cam.zoom + cam.offsetY;
const newZoom = clamp(cam.zoom * factor, MIN_ZOOM, MAX_ZOOM);
cam.offsetX = worldX - canvasX / newZoom;
cam.offsetY = worldY - canvasY / newZoom;
cam.zoom = newZoom;
applyClampAndRedraw();
}
// -------------------------------------------------------------------------
// Wheel → zoom
// -------------------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
applyZoom(cx, cy, factor);
};
canvas.addEventListener('wheel', onWheel, { passive: false });
return () => canvas.removeEventListener('wheel', onWheel);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
// WASD panning — requestAnimationFrame loop
// -------------------------------------------------------------------------
useEffect(() => {
function rafTick(timestamp: number) {
const keys = keysHeld.current;
if (keys.size === 0) {
lastFrameTime.current = null;
rafId.current = null;
return;
}
const dt = lastFrameTime.current !== null
? (timestamp - lastFrameTime.current) / 1000
: 0;
lastFrameTime.current = timestamp;
const cam = cameraRef.current;
const speed = WASD_PAN_SPEED;
if (keys.has('a')) cam.offsetX -= speed * dt;
if (keys.has('d')) cam.offsetX += speed * dt;
if (keys.has('w')) cam.offsetY -= speed * dt;
if (keys.has('s')) cam.offsetY += speed * dt;
const canvas = canvasRef.current;
if (canvas) {
clampCameraToContent(cam, cellsRef.current, tokensRef.current, canvas.width, canvas.height);
}
setTick(n => n + 1);
rafId.current = requestAnimationFrame(rafTick);
}
function onKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
// Don't intercept WASD when modifier keys are held (e.g. Shift+keys are for tool shortcuts)
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
const key = e.key.toLowerCase();
if (['w', 'a', 's', 'd'].includes(key)) {
e.preventDefault();
keysHeld.current.add(key);
if (rafId.current === null) {
lastFrameTime.current = null;
rafId.current = requestAnimationFrame(rafTick);
}
}
}
function onKeyUp(e: KeyboardEvent) {
keysHeld.current.delete(e.key.toLowerCase());
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
if (rafId.current !== null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
// Mouse helpers
// -------------------------------------------------------------------------
function getCanvasPoint(e: React.MouseEvent) {
const rect = canvasRef.current!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function tokenAtCell(x: number, y: number): GridToken | null {
for (const t of tokensRef.current.values()) {
if (t.x === x && t.y === y) return t;
}
return null;
}
// -------------------------------------------------------------------------
// Mouse handlers
// -------------------------------------------------------------------------
function handleMouseDown(e: React.MouseEvent) {
e.preventDefault();
const { x: mx, y: my } = getCanvasPoint(e);
const cell = canvasToCell(mx, my, cameraRef.current);
// ---- Pan tool ----
if (tool === 'pan' && e.button === 0) {
isPanning.current = true;
panStart.current = { mx, my, ox: cameraRef.current.offsetX, oy: cameraRef.current.offsetY };
setCursor('grabbing');
return;
}
// ---- Zoom tool ----
if (tool === 'zoom') {
if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP);
else if (e.button === 2) applyZoom(mx, my, 1 / (ZOOM_STEP * ZOOM_STEP));
return;
}
// ---- Draw tool ----
if (tool === 'draw') {
if (e.button === 0) {
if (e.shiftKey) {
// Shift+click → flood fill uncolored region
const key = cellKey(cell.x, cell.y);
if (!cellsRef.current.has(key)) {
const region = floodFill(cell.x, cell.y, cellsRef.current);
if (region === null || region.length === 1) {
// Unbounded or trivially single cell → paint one cell
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
} else {
// Bounded enclosed region → batch paint
sendRef.current({
type: 'paint_cells',
cells: region.map(({ x, y }) => ({ x, y, color: paintColor })),
});
}
}
} else {
isDrawing.current = true;
lastPainted.current = cellKey(cell.x, cell.y);
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
}
} else if (e.button === 2) {
isErasing.current = true;
const key = cellKey(cell.x, cell.y);
lastPainted.current = key;
if (cellsRef.current.has(key)) {
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
}
}
return;
}
// ---- Token tool ----
if (tool === 'token') {
if (e.button === 2) {
const tok = tokenAtCell(cell.x, cell.y);
if (tok) sendRef.current({ type: 'delete_token', id: tok.id });
return;
}
if (e.button === 0) {
const tok = tokenAtCell(cell.x, cell.y);
if (tok) {
isDragging.current = true;
dragTokenId.current = tok.id;
dragCellPos.current = { x: cell.x, y: cell.y };
redraw();
} else {
setDialogPos({ x: cell.x, y: cell.y });
}
}
}
}
function handleMouseMove(e: React.MouseEvent) {
const { x: mx, y: my } = getCanvasPoint(e);
// Pan
if (isPanning.current && panStart.current) {
const cam = cameraRef.current;
const dx = (mx - panStart.current.mx) / cam.zoom;
const dy = (my - panStart.current.my) / cam.zoom;
cam.offsetX = panStart.current.ox - dx;
cam.offsetY = panStart.current.oy - dy;
applyClampAndRedraw();
return;
}
// Draw / erase stroke
if (isDrawing.current || isErasing.current) {
const cell = canvasToCell(mx, my, cameraRef.current);
const key = cellKey(cell.x, cell.y);
if (lastPainted.current !== key) {
lastPainted.current = key;
if (isDrawing.current) {
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
} else if (isErasing.current && cellsRef.current.has(key)) {
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
}
}
return;
}
// Token drag
if (isDragging.current && dragCellPos.current) {
const cell = canvasToCell(mx, my, cameraRef.current);
if (dragCellPos.current.x !== cell.x || dragCellPos.current.y !== cell.y) {
dragCellPos.current = { x: cell.x, y: cell.y };
redraw();
}
}
}
function handleMouseUp(_e: React.MouseEvent) {
if (isPanning.current) {
isPanning.current = false;
panStart.current = null;
setCursor('grab');
return;
}
if (isDrawing.current || isErasing.current) {
isDrawing.current = false;
isErasing.current = false;
lastPainted.current = null;
return;
}
if (isDragging.current && dragTokenId.current && dragCellPos.current) {
const tok = tokensRef.current.get(dragTokenId.current);
if (tok && (tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)) {
sendRef.current({
type: 'move_token',
id: dragTokenId.current,
x: dragCellPos.current.x,
y: dragCellPos.current.y,
});
}
isDragging.current = false;
dragTokenId.current = null;
dragCellPos.current = null;
redraw();
}
}
function handleMouseLeave() {
isPanning.current = false;
panStart.current = null;
isDrawing.current = false;
isErasing.current = false;
lastPainted.current = null;
if (isDragging.current) {
isDragging.current = false;
dragTokenId.current = null;
dragCellPos.current = null;
redraw();
}
}
// Sync cursor CSS to active tool
useEffect(() => {
switch (tool) {
case 'pan': setCursor('grab'); break;
case 'zoom': setCursor('zoom-in'); break;
case 'draw': setCursor('crosshair'); break;
case 'token': setCursor('crosshair'); break;
}
}, [tool]);
function handleAddToken(label: string, color: string) {
if (!dialogPos) return;
sendRef.current({ type: 'add_token', x: dialogPos.x, y: dialogPos.y, label, color });
setDialogPos(null);
}
return (
<div ref={containerRef} className="grid-container">
<canvas
ref={canvasRef}
className="grid-canvas"
style={{ cursor }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onContextMenu={e => e.preventDefault()}
/>
{dialogPos && (
<TokenDialog
defaultColor={tokenColor}
onConfirm={handleAddToken}
onCancel={() => setDialogPos(null)}
/>
)}
</div>
);
});
export default Grid;