Files
ttrpg_manager/src/features/world/map/MapCanvas.tsx
T
NilsBriggen 36f0ea66b3 Phase 3: Universal VTT (.dd2vtt/.uvtt) import + export
- src/lib/vtt/uvtt.ts: pure, tested parser converting UVTT grid-unit geometry to
  image pixels (pixels_per_grid → gridSize), extracting the base64 image, walls
  (line_of_sight), doors (portals) and lights; plus toUvtt() for export.
- BattleMap gains walls/doors/lights (Dexie v11, backfilled). mapsRepo.createFromVtt.
- MapsPage "+ Add map" now accepts .dd2vtt/.uvtt/.df2vtt (text) and images, and a
  per-map "Export .uvtt" (image dims → map_size). downloadText() helper.
- Walls render faintly for the GM (CanvasView.walls / drawScene) so imported
  line-of-sight is visible; Phase 4 will turn walls into dynamic vision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:51:56 +02:00

454 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Viewport>({ zoom: 1, panX: 0, panY: 0 });
const [size, setSize] = useState({ w: 0, h: 0 });
const outerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const fittedRef = useRef<string | undefined>(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 (
<div ref={outerRef} data-testid="map-viewport" data-zoom={vp.zoom} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
{!view.image
? <p className="p-3 text-sm text-muted">No image.</p>
: !natural
? <p className="p-3 text-sm text-muted">Loading map</p>
: null}
<canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" />
{/* pointer surface (below tokens so token drags win; tokens are small) */}
{view.image && (
<div
className="absolute inset-0"
style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
onPointerDown={ixDown}
onPointerMove={ixMove}
onPointerUp={ixUp}
onPointerLeave={() => { if (interactive) onLeave?.(); }}
onContextMenu={(e) => e.preventDefault()}
/>
)}
{/* tokens: DOM positioned in screen space (no CSS scale → labels stay crisp) */}
{natural && (
<div className="pointer-events-none absolute inset-0">
{view.tokens.map((t) => (
<TokenChip key={t.id} token={t} gridSize={gridSize} vp={vp} cols={cols} rows={rows}
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
))}
</div>
)}
{natural && (
<div className="absolute bottom-2 right-2 flex items-center gap-1 rounded-md border border-line bg-panel/90 px-1 py-0.5 text-xs shadow backdrop-blur">
<button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1 / 1.25)} aria-label="Zoom out"></button>
<span className="w-10 text-center tabular-nums text-muted">{Math.round(vp.zoom * 100)}%</span>
<button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1.25)} aria-label="Zoom in">+</button>
<button className="grid h-6 place-items-center rounded px-1.5 hover:bg-elevated" onClick={fit} aria-label="Fit map to view" title="Fit"></button>
</div>
)}
</div>
);
}
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<HTMLDivElement>(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 (
<div
ref={ref}
onPointerDown={down} onPointerMove={move} onPointerUp={up}
title={token.label}
data-testid="map-token"
className={cn('pointer-events-auto absolute grid place-items-center rounded-full border-2 border-black/50 font-bold leading-none text-black shadow', frac !== null && frac <= 0 && 'opacity-50')}
style={{
width: box, height: box,
left: panX + token.col * gridSize * zoom + span * 0.04 + drift.dx,
top: panY + token.row * gridSize * zoom + span * 0.04 + drift.dy,
fontSize: Math.max(8, Math.min(22, span * 0.3)),
background: token.color,
cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
touchAction: 'none',
}}
>
{token.image && (
<img src={token.image} alt="" draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full rounded-full object-cover" />
)}
{frac !== null && (
<svg className="pointer-events-none absolute inset-0" viewBox="0 0 36 36" aria-hidden>
<circle cx="18" cy="18" r="17" fill="none" stroke="rgba(0,0,0,0.35)" strokeWidth="2" />
<circle cx="18" cy="18" r="17" fill="none" stroke={frac > 0.5 ? '#66bb6a' : frac > 0 ? '#ffa726' : '#ef5350'}
strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" />
</svg>
)}
{!token.image && <span className="z-10 truncate px-0.5">{token.label}</span>}
{token.conditions.length > 0 && (
<span className="absolute -right-1 -top-1 z-10 grid h-4 w-4 place-items-center rounded-full bg-danger text-[9px] text-white" title={token.conditions.map((c) => c.name).join(', ')}>
{token.conditions.length}
</span>
)}
</div>
);
}