import { useEffect, useRef, useState } from 'react'; import type { Point, Viewport } from '@/lib/map'; import { fitViewport, zoomToPoint } from '@/lib/map'; import { cn } from '@/lib/cn'; /** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */ export interface CanvasDrawing { id: string; kind: 'freehand' | 'line' | 'rect' | 'circle' | 'arrow' | 'text'; points: Point[]; color: string; width: number; text?: string | undefined; } /** Minimal token shape both the editor (MapToken) and player view (PlayerToken) satisfy. */ export interface CanvasToken { id: string; label: string; color: string; col: number; row: number; size: number; kind: 'pc' | 'npc' | 'monster' | 'object'; /** resolved icon data URL (token image or linked character portrait) */ image?: string | undefined; hp?: { current: number; max: number; temp: number } | undefined; conditions: { name: string; value?: number | undefined }[]; } export interface CanvasView { image?: string | undefined; gridSize: number; showGrid: boolean; fogEnabled: boolean; revealed: string[]; tokens: CanvasToken[]; drawings: CanvasDrawing[]; /** sight-blocking walls, drawn faintly for the GM (not sent to players) */ walls?: { points: Point[] }[] | undefined; } export interface Overlay { cells?: string[]; line?: { a: Point; b: Point } | undefined; label?: { at: Point; text: string } | undefined; draft?: CanvasDrawing | undefined; /** in-progress polygon: committed vertices + a rubber-band point at the cursor */ poly?: { points: Point[]; cursor?: Point | undefined } | undefined; /** brush footprint at the cursor (radius in cells) */ brush?: { col: number; row: number; size: number } | undefined; } type Phase = 'down' | 'move' | 'up' | 'hover'; interface Props { view: CanvasView; /** CSS height of the clip viewport (e.g. 'calc(100vh - 14rem)') */ viewportHeight?: string; readOnly?: boolean; /** dim unrevealed cells fully (player view) vs. translucent (GM editor) */ playerFog?: boolean; overlay?: Overlay; onPointer?: (phase: Phase, p: { point: Point; col: number; row: number }) => void; onLeave?: () => void; onTokenMove?: (id: string, col: number, row: number) => void; onTokenClick?: (id: string) => void; tokensDraggable?: boolean; onReady?: (info: { cols: number; rows: number }) => void; } const MAX_DPR = 2.5; /** * Shared map renderer. A single canvas draws the image + grid + fog + drawings + * overlay with a camera transform (translate+scale) at the device's pixel * resolution, so everything stays crisp at any zoom (unlike CSS-scaling a * natural-resolution bitmap). Tokens are DOM, in a matching transformed layer. */ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onLeave, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) { const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); const [vp, setVp] = useState({ zoom: 1, panX: 0, panY: 0 }); const [size, setSize] = useState({ w: 0, h: 0 }); const outerRef = useRef(null); const canvasRef = useRef(null); const imgRef = useRef(null); const fittedRef = useRef(undefined); const gridSize = view.gridSize; const W = natural?.w ?? 0; const H = natural?.h ?? 0; const cols = natural ? Math.ceil(W / gridSize) : 0; const rows = natural ? Math.ceil(H / gridSize) : 0; // Load the image bitmap (used both for sizing and for canvas drawImage). useEffect(() => { if (!view.image) { setNatural(null); imgRef.current = null; return; } const img = new Image(); img.onload = () => { imgRef.current = img; setNatural({ w: img.naturalWidth, h: img.naturalHeight }); }; img.src = view.image; }, [view.image]); useEffect(() => { if (natural) onReady?.({ cols, rows }); }, [cols, rows, natural, onReady]); // Track the viewport's CSS size. useEffect(() => { const el = outerRef.current; if (!el) return; const update = () => setSize({ w: el.clientWidth, h: el.clientHeight }); update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, []); // Fit once per image, as soon as both the image and viewport size are known. useEffect(() => { if (!natural || size.w === 0 || fittedRef.current === view.image) return; fittedRef.current = view.image; setVp(fitViewport(size.w, size.h, natural.w, natural.h)); }, [natural, size, view.image]); // Wheel zoom (native + non-passive so the page doesn't scroll). useEffect(() => { const el = outerRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const r = el.getBoundingClientRect(); setVp((v) => zoomToPoint(v, Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top)); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); // Redraw whenever camera/content/size changes (rAF-coalesced). useEffect(() => { const raf = requestAnimationFrame(() => { drawScene(canvasRef.current, imgRef.current, size.w, size.h, { vp, W, H, gridSize, cols, rows, showGrid: view.showGrid, fogEnabled: view.fogEnabled, revealed: view.revealed, playerFog: !!playerFog, drawings: view.drawings, overlay, walls: view.walls, }); }); return () => cancelAnimationFrame(raf); }); const toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => { const r = outerRef.current!.getBoundingClientRect(); const x = (e.clientX - r.left - vp.panX) / vp.zoom; const y = (e.clientY - r.top - vp.panY) / vp.zoom; return { point: { x, y }, col: Math.floor(x / gridSize), row: Math.floor(y / gridSize) }; }; const pan = useRef<{ x: number; y: number; px: number; py: number } | null>(null); const interactive = !readOnly && !!onPointer; const ixDown = (e: React.PointerEvent) => { const wantPan = e.button === 1 || e.button === 2 || !interactive; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); if (wantPan) { pan.current = { x: e.clientX, y: e.clientY, px: vp.panX, py: vp.panY }; e.preventDefault(); return; } onPointer!('down', toPoint(e)); }; const ixMove = (e: React.PointerEvent) => { if (pan.current) { const { x, y, px, py } = pan.current; setVp((v) => ({ ...v, panX: px + (e.clientX - x), panY: py + (e.clientY - y) })); return; } if (interactive) onPointer!(e.buttons ? 'move' : 'hover', toPoint(e)); }; const ixUp = (e: React.PointerEvent) => { if (pan.current) { pan.current = null; return; } if (interactive) onPointer!('up', toPoint(e)); }; const zoomCentered = (factor: number) => setVp((v) => zoomToPoint(v, factor, size.w / 2, size.h / 2)); const fit = () => { if (natural) setVp(fitViewport(size.w, size.h, natural.w, natural.h)); }; // NOTE: always render the outer container (even with no image yet) so the // ResizeObserver attaches. In the networked player view the map image arrives // late over WebSocket; if we early-returned, the observer never bound, `size` // stayed 0, fit() never ran, and the map showed at 100% instead of fit. return (
{!view.image ?

No image.

: !natural ?

Loading map…

: null} {/* pointer surface (below tokens so token drags win; tokens are small) */} {view.image && (
{ if (interactive) onLeave?.(); }} onContextMenu={(e) => e.preventDefault()} /> )} {/* tokens: DOM positioned in screen space (no CSS scale → labels stay crisp) */} {natural && (
{view.tokens.map((t) => ( ))}
)} {natural && (
{Math.round(vp.zoom * 100)}%
)}
); } interface SceneParams { vp: Viewport; W: number; H: number; gridSize: number; cols: number; rows: number; showGrid: boolean; fogEnabled: boolean; revealed: string[]; playerFog: boolean; drawings: CanvasDrawing[]; overlay?: Overlay | undefined; walls?: { points: Point[] }[] | undefined; } /** Draw the whole scene at device resolution with a camera transform. */ function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | null, vw: number, vh: number, p: SceneParams): void { if (!canvas || vw === 0 || vh === 0) return; const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); const bw = Math.round(vw * dpr), bh = Math.round(vh * dpr); if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw; canvas.height = bh; } const ctx = canvas.getContext('2d'); if (!ctx) return; const { zoom, panX, panY } = p.vp; // Camera maps world px → CSS px → device px. Device transform = dpr only. const cam = () => ctx.setTransform(zoom * dpr, 0, 0, zoom * dpr, panX * dpr, panY * dpr); const dev = () => ctx.setTransform(dpr, 0, 0, dpr, 0, 0); dev(); ctx.clearRect(0, 0, vw, vh); if (!img) return; // Map image cam(); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, p.W, p.H); // Fog (world space) if (p.fogEnabled) { const revealed = new Set(p.revealed); ctx.fillStyle = p.playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.55)'; for (let c = 0; c < p.cols; c++) for (let r = 0; r < p.rows; r++) { if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * p.gridSize, r * p.gridSize, p.gridSize + 0.5, p.gridSize + 0.5); } } // Grid (device space → crisp 1px lines) if (p.showGrid) { const step = p.gridSize * zoom; if (step >= 4) { dev(); ctx.strokeStyle = 'rgba(255,255,255,0.28)'; ctx.lineWidth = 1; ctx.beginPath(); const x0 = Math.max(0, panX), x1 = Math.min(vw, panX + p.W * zoom); const y0 = Math.max(0, panY), y1 = Math.min(vh, panY + p.H * zoom); for (let c = Math.max(0, Math.floor((-panX) / step)); c <= p.cols; c++) { const x = Math.round(panX + c * step) + 0.5; if (x < x0 - 1 || x > x1 + 1) continue; ctx.moveTo(x, y0); ctx.lineTo(x, y1); } for (let r = Math.max(0, Math.floor((-panY) / step)); r <= p.rows; r++) { const y = Math.round(panY + r * step) + 0.5; if (y < y0 - 1 || y > y1 + 1) continue; ctx.moveTo(x0, y); ctx.lineTo(x1, y); } ctx.stroke(); } } // Drawings (world space; crisp under the camera transform) cam(); for (const d of p.drawings) drawShape(ctx, d); // Walls (GM only — faint, so imported line-of-sight is visible while editing) if (p.walls?.length) { cam(); ctx.strokeStyle = 'rgba(80,200,255,0.6)'; ctx.lineWidth = 2 / zoom; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; for (const w of p.walls) { if (w.points.length < 2) continue; ctx.beginPath(); ctx.moveTo(w.points[0]!.x, w.points[0]!.y); for (const pt of w.points.slice(1)) ctx.lineTo(pt.x, pt.y); ctx.stroke(); } } // Overlay (AoE/measure/draft/poly/brush) const o = p.overlay; if (o) { cam(); if (o.cells?.length) { ctx.fillStyle = 'rgba(212,175,55,0.35)'; for (const k of o.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * p.gridSize, r! * p.gridSize, p.gridSize, p.gridSize); } } if (o.draft) drawShape(ctx, o.draft); if (o.brush) { ctx.strokeStyle = 'rgba(212,175,55,0.95)'; ctx.lineWidth = 2 / zoom; const s = o.brush.size; ctx.strokeRect((o.brush.col - s) * p.gridSize, (o.brush.row - s) * p.gridSize, (s * 2 + 1) * p.gridSize, (s * 2 + 1) * p.gridSize); } if (o.poly && o.poly.points.length) { const pts = o.poly.points; ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2 / zoom; ctx.beginPath(); ctx.moveTo(pts[0]!.x, pts[0]!.y); for (const pt of pts.slice(1)) ctx.lineTo(pt.x, pt.y); if (o.poly.cursor) ctx.lineTo(o.poly.cursor.x, o.poly.cursor.y); if (pts.length >= 2) ctx.closePath(); ctx.stroke(); ctx.fillStyle = '#fff'; for (const pt of pts) { ctx.beginPath(); ctx.arc(pt.x, pt.y, 4 / zoom, 0, Math.PI * 2); ctx.fill(); } } if (o.line) { ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2 / zoom; ctx.beginPath(); ctx.moveTo(o.line.a.x, o.line.a.y); ctx.lineTo(o.line.b.x, o.line.b.y); ctx.stroke(); } if (o.label) { // label in screen space → fixed, readable size at any zoom dev(); ctx.font = '600 13px sans-serif'; ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.75)'; ctx.lineWidth = 3; const x = panX + o.label.at.x * zoom + 8, y = panY + o.label.at.y * zoom - 8; ctx.strokeText(o.label.text, x, y); ctx.fillText(o.label.text, x, y); } } dev(); } /** Draw a vector annotation in world coordinates (crisp under the camera). */ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void { ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; const p = d.points; if (d.kind === 'text' && d.text && p[0]) { ctx.font = `${Math.max(12, d.width * 5)}px sans-serif`; ctx.fillText(d.text, p[0].x, p[0].y); return; } if (p.length < 2) return; const a = p[0]!, b = p[p.length - 1]!; ctx.beginPath(); if (d.kind === 'rect') { ctx.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y)); } else if (d.kind === 'circle') { ctx.arc(a.x, a.y, Math.hypot(b.x - a.x, b.y - a.y), 0, Math.PI * 2); } else { ctx.moveTo(p[0]!.x, p[0]!.y); for (const pt of p.slice(1)) ctx.lineTo(pt.x, pt.y); if (d.kind === 'arrow') { const ang = Math.atan2(b.y - p[p.length - 2]!.y, b.x - p[p.length - 2]!.x), h = 10 + d.width; ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang - 0.4), b.y - h * Math.sin(ang - 0.4)); ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang + 0.4), b.y - h * Math.sin(ang + 0.4)); } } ctx.stroke(); } function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick }: { token: CanvasToken; gridSize: number; vp: Viewport; cols: number; rows: number; draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined; }) { const ref = useRef(null); const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(null); const [drift, setDrift] = useState({ dx: 0, dy: 0 }); const { zoom, panX, panY } = vp; const span = gridSize * token.size * zoom; // on-screen px const box = span * 0.92; const frac = token.hp && token.hp.max > 0 ? Math.max(0, Math.min(1, token.hp.current / token.hp.max)) : null; const down = (e: React.PointerEvent) => { if (draggable) { e.stopPropagation(); ref.current?.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, col: token.col, row: token.row, moved: false }; } }; const move = (e: React.PointerEvent) => { if (!drag.current) return; const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.current.moved = true; setDrift({ dx, dy }); }; const up = (e: React.PointerEvent) => { if (!drag.current) return; e.stopPropagation(); ref.current?.releasePointerCapture(e.pointerId); const moved = drag.current.moved; if (moved && onMove) { const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / (gridSize * zoom)))); const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / (gridSize * zoom)))); onMove(token.id, col, row); } else if (!moved && onClick) onClick(token.id); drag.current = null; setDrift({ dx: 0, dy: 0 }); }; return (
{token.image && ( )} {frac !== null && ( 0.5 ? '#66bb6a' : frac > 0 ? '#ffa726' : '#ef5350'} strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" /> )} {!token.image && {token.label}} {token.conditions.length > 0 && ( c.name).join(', ')}> {token.conditions.length} )}
); }