P20: map viewport zoom/pan/fit + token placement palette

Render maps at natural resolution inside a CSS-transform world layer (zoom,
pan, fit-to-view) instead of shrinking to maxWidth — large maps are now usable.
Pure viewport math in src/lib/map/viewport.ts (unit-tested). New TokenPalette
places party PCs / active-encounter combatants / blanks in one click with dup
detection; tokens gain optional combatantId (Dexie v9) for live encounter HP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 12:34:00 +02:00
parent a8cb7d65f4
commit c28c29a6c6
13 changed files with 505 additions and 137 deletions
+46
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -36,3 +37,48 @@ test('maps: upload, place token, reveal all, show to players', async ({ page })
await expect(page.getByRole('heading', { name: 'Battle map' })).toBeVisible();
await expect(page.getByTestId('map-token')).toHaveCount(1);
});
test('maps: zoom controls scale the canvas and fit resets', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Zoom');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
const world = page.getByTestId('map-world');
await expect(world).toBeVisible();
// World layer is sized to the image's natural pixels (not shrunk to a cap).
await expect(world).toHaveCSS('width', '512px');
const scaleOf = async () => {
const t = await world.evaluate((el) => getComputedStyle(el).transform);
const m = /matrix\(([^,]+)/.exec(t);
return m ? Number(m[1]) : 1;
};
const before = await scaleOf();
await page.getByRole('button', { name: 'Zoom in' }).click();
await page.getByRole('button', { name: 'Zoom in' }).click();
expect(await scaleOf()).toBeGreaterThan(before);
await page.getByRole('button', { name: 'Fit map to view' }).click();
expect(await scaleOf()).toBeCloseTo(before, 1);
});
test('maps: place a party token from the palette', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Party');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click();
await createCharacter(page, 'Aria');
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
// Palette lists the PC; clicking it drops a linked token.
await page.getByRole('button', { name: /Aria/ }).click();
await expect(page.getByTestId('map-token')).toHaveCount(1);
await expect(page.getByTestId('map-token')).toContainText('Aria');
});
+1 -1
View File
@@ -12,7 +12,7 @@ export function PlayerBoards({ snapshot, image }: { snapshot: Snapshot; image?:
{map && (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
<PlayerMapView map={map as PlayerMap} image={image} maxWidth={1100} />
<PlayerMapView map={map as PlayerMap} image={image} viewportHeight="65vh" />
</section>
)}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { PlayerMap } from '@/lib/map';
import { MapCanvas, type CanvasToken } from '@/features/world/map/MapCanvas';
/** Read-only render of a player-facing map projection + its image. */
export function PlayerMapView({ map, image, maxWidth }: { map: PlayerMap; image?: string | undefined; maxWidth?: number | undefined }) {
export function PlayerMapView({ map, image, viewportHeight }: { map: PlayerMap; image?: string | undefined; viewportHeight?: string | undefined }) {
const tokens: CanvasToken[] = map.tokens.map((t) => ({
id: t.id, label: t.label, color: t.color, col: t.col, row: t.row,
size: t.size, kind: t.kind, hp: t.hp, conditions: t.conditions,
@@ -11,7 +11,7 @@ export function PlayerMapView({ map, image, maxWidth }: { map: PlayerMap; image?
<MapCanvas
readOnly
playerFog
{...(maxWidth !== undefined ? { maxWidth } : {})}
{...(viewportHeight !== undefined ? { viewportHeight } : {})}
view={{ image, gridSize: map.gridSize, showGrid: map.showGrid, fogEnabled: map.fogEnabled, revealed: map.revealed, tokens, drawings: map.drawings }}
/>
);
+142 -67
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import type { Point } from '@/lib/map';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Point, Viewport } from '@/lib/map';
import { fitViewport, zoomToPoint, worldFromScreen } from '@/lib/map';
import { cn } from '@/lib/cn';
/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
@@ -44,7 +45,8 @@ export interface Overlay {
interface Props {
view: CanvasView;
maxWidth?: number;
/** CSS height of the scroll/clip viewport (e.g. 'calc(100vh - 14rem)') */
viewportHeight?: string;
readOnly?: boolean;
/** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
playerFog?: boolean;
@@ -56,13 +58,26 @@ interface Props {
onReady?: (info: { cols: number; rows: number }) => void;
}
/** Shared map renderer: image + grid + fog + drawings + AoE/measure overlay + tokens. */
export function MapCanvas({ view, maxWidth = 880, readOnly, playerFog, overlay, onPointer, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
/**
* Shared map renderer. The image + all layers live in a "world" div sized to the
* image's natural pixels; a CSS transform (translate+scale) provides zoom/pan so
* large maps render crisp at full resolution instead of being shrunk to fit.
*/
export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, 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 outerRef = useRef<HTMLDivElement>(null);
const worldRef = useRef<HTMLDivElement>(null);
const fogRef = useRef<HTMLCanvasElement>(null);
const drawRef = useRef<HTMLCanvasElement>(null);
const overlayRef = useRef<HTMLCanvasElement>(null);
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;
useEffect(() => {
if (!view.image) { setNatural(null); return; }
const img = new Image();
@@ -70,109 +85,167 @@ export function MapCanvas({ view, maxWidth = 880, readOnly, playerFog, overlay,
img.src = view.image;
}, [view.image]);
const displayW = natural ? Math.min(natural.w, maxWidth) : 0;
const scale = natural ? displayW / natural.w : 1;
const displayH = natural ? natural.h * scale : 0;
const cellPx = view.gridSize * scale;
const cols = natural ? Math.ceil(natural.w / view.gridSize) : 0;
const rows = natural ? Math.ceil(natural.h / view.gridSize) : 0;
useEffect(() => { if (natural) onReady?.({ cols, rows }); }, [cols, rows, natural, onReady]);
// Fit the image into the viewport whenever a new image loads.
const fit = useCallback(() => {
const el = outerRef.current;
if (!el || !natural) return;
setVp(fitViewport(el.clientWidth, el.clientHeight, natural.w, natural.h));
}, [natural]);
useEffect(() => { fit(); }, [fit]);
// Wheel zoom (native, non-passive so we can prevent page 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);
}, []);
// Fog
useEffect(() => {
const cv = fogRef.current;
if (!cv || !natural) return;
cv.width = displayW; cv.height = displayH;
cv.width = W; cv.height = H;
const ctx = cv.getContext('2d'); if (!ctx) return;
ctx.clearRect(0, 0, displayW, displayH);
ctx.clearRect(0, 0, W, H);
if (!view.fogEnabled) return;
const revealed = new Set(view.revealed);
ctx.fillStyle = playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.6)';
for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * cellPx, r * cellPx, cellPx + 0.5, cellPx + 0.5);
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * gridSize, r * gridSize, gridSize + 0.5, gridSize + 0.5);
}
}, [view.revealed, view.fogEnabled, playerFog, displayW, displayH, cellPx, cols, rows, natural]);
}, [view.revealed, view.fogEnabled, playerFog, W, H, gridSize, cols, rows, natural]);
// Drawings
useEffect(() => {
const cv = drawRef.current;
if (!cv || !natural) return;
cv.width = displayW; cv.height = displayH;
cv.width = W; cv.height = H;
const ctx = cv.getContext('2d'); if (!ctx) return;
ctx.clearRect(0, 0, displayW, displayH);
for (const d of view.drawings) drawShape(ctx, d, scale);
}, [view.drawings, displayW, displayH, scale, natural]);
ctx.clearRect(0, 0, W, H);
for (const d of view.drawings) drawShape(ctx, d);
}, [view.drawings, W, H, natural]);
// Overlay (AoE/measure/draft)
useEffect(() => {
const cv = overlayRef.current;
if (!cv || !natural) return;
cv.width = displayW; cv.height = displayH;
cv.width = W; cv.height = H;
const ctx = cv.getContext('2d'); if (!ctx) return;
ctx.clearRect(0, 0, displayW, displayH);
ctx.clearRect(0, 0, W, H);
if (overlay?.cells?.length) {
ctx.fillStyle = 'rgba(212,175,55,0.35)';
for (const k of overlay.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * cellPx, r! * cellPx, cellPx, cellPx); }
for (const k of overlay.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * gridSize, r! * gridSize, gridSize, gridSize); }
}
if (overlay?.draft) drawShape(ctx, overlay.draft, scale);
if (overlay?.draft) drawShape(ctx, overlay.draft);
if (overlay?.line) {
ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(overlay.line.a.x * scale, overlay.line.a.y * scale); ctx.lineTo(overlay.line.b.x * scale, overlay.line.b.y * scale); ctx.stroke();
ctx.beginPath(); ctx.moveTo(overlay.line.a.x, overlay.line.a.y); ctx.lineTo(overlay.line.b.x, overlay.line.b.y); ctx.stroke();
}
if (overlay?.label) {
ctx.font = '600 13px sans-serif'; ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = 3;
const x = overlay.label.at.x * scale + 6, y = overlay.label.at.y * scale - 6;
const x = overlay.label.at.x + 6, y = overlay.label.at.y - 6;
ctx.strokeText(overlay.label.text, x, y); ctx.fillText(overlay.label.text, x, y);
}
}, [overlay, displayW, displayH, cellPx, scale, natural]);
}, [overlay, W, H, gridSize, natural]);
const toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / scale, y = (e.clientY - rect.top) / scale;
return { point: { x, y }, col: Math.floor(x / view.gridSize), row: Math.floor(y / view.gridSize) };
const rect = (worldRef.current ?? (e.currentTarget as HTMLElement)).getBoundingClientRect();
const pt = worldFromScreen(vp.zoom, e.clientX, e.clientY, rect.left, rect.top);
return { point: pt, col: Math.floor(pt.x / gridSize), row: Math.floor(pt.y / gridSize) };
};
// Pan (drag) state. Pan happens on middle/right button always, or on primary
// button when no tool owns the canvas (move tool / read-only player view).
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 && e.buttons) onPointer!('move', toPoint(e));
};
const ixUp = (e: React.PointerEvent) => {
if (pan.current) { pan.current = null; return; }
if (interactive) onPointer!('up', toPoint(e));
};
if (!view.image) return <p className="text-sm text-muted">No image.</p>;
if (!natural) return <p className="text-sm text-muted">Loading map</p>;
const interactive = !readOnly && !!onPointer;
const zoomCentered = (factor: number) => {
const el = outerRef.current; if (!el) return;
setVp((v) => zoomToPoint(v, factor, el.clientWidth / 2, el.clientHeight / 2));
};
return (
<div className="overflow-auto rounded-lg border border-line bg-surface p-2">
<div className="relative" style={{ width: displayW, height: displayH }}>
<img src={view.image} alt="" className="absolute inset-0 h-full w-full select-none" draggable={false} />
{view.showGrid && (
<div className="pointer-events-none absolute inset-0" style={{
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.22) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.22) 1px, transparent 1px)',
backgroundSize: `${cellPx}px ${cellPx}px`,
}} />
)}
<canvas ref={fogRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
<canvas ref={drawRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
<canvas ref={overlayRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
{interactive && (
<div ref={outerRef} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
{!natural ? (
<p className="p-3 text-sm text-muted">Loading map</p>
) : (
<div
ref={worldRef}
data-testid="map-world"
className="absolute left-0 top-0"
style={{ width: W, height: H, transform: `translate(${vp.panX}px, ${vp.panY}px) scale(${vp.zoom})`, transformOrigin: '0 0', willChange: 'transform' }}
>
<img src={view.image} alt="" className="absolute inset-0 h-full w-full select-none" draggable={false} />
{view.showGrid && (
<div className="pointer-events-none absolute inset-0" style={{
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.22) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.22) 1px, transparent 1px)',
backgroundSize: `${gridSize}px ${gridSize}px`,
}} />
)}
<canvas ref={fogRef} className="pointer-events-none absolute inset-0" style={{ width: W, height: H }} />
<canvas ref={drawRef} className="pointer-events-none absolute inset-0" style={{ width: W, height: H }} />
<canvas ref={overlayRef} className="pointer-events-none absolute inset-0" style={{ width: W, height: H }} />
<div
className="absolute inset-0"
style={{ cursor: 'crosshair', touchAction: 'none' }}
onPointerDown={(e) => { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); onPointer!('down', toPoint(e)); }}
onPointerMove={(e) => { if (e.buttons) onPointer!('move', toPoint(e)); }}
onPointerUp={(e) => onPointer!('up', toPoint(e))}
style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
onPointerDown={ixDown}
onPointerMove={ixMove}
onPointerUp={ixUp}
onContextMenu={(e) => e.preventDefault()}
/>
)}
{view.tokens.map((t) => (
<TokenChip key={t.id} token={t} cellPx={cellPx} cols={cols} rows={rows}
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
))}
</div>
{view.tokens.map((t) => (
<TokenChip key={t.id} token={t} gridSize={gridSize} zoom={vp.zoom} 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 px-1.5 place-items-center rounded hover:bg-elevated" onClick={fit} aria-label="Fit map to view" title="Fit"></button>
</div>
)}
</div>
);
}
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: number): void {
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width * scale; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
const p = d.points.map((pt) => ({ x: pt.x * scale, y: pt.y * scale }));
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 * scale)}px sans-serif`;
ctx.font = `${Math.max(12, d.width * 5)}px sans-serif`;
ctx.fillText(d.text, p[0].x, p[0].y);
return;
}
@@ -187,7 +260,7 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: numbe
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 * scale;
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));
}
@@ -195,14 +268,14 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: numbe
ctx.stroke();
}
function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
token: CanvasToken; cellPx: number; cols: number; rows: number;
function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onClick }: {
token: CanvasToken; gridSize: number; zoom: number; 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 span = cellPx * token.size;
const span = gridSize * token.size;
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) => {
@@ -216,11 +289,13 @@ function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
};
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 / cellPx)));
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / cellPx)));
// screen-px drift → world-px (÷zoom) → cell delta (÷gridSize)
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / zoom / gridSize)));
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / zoom / gridSize)));
onMove(token.id, col, row);
} else if (!moved && onClick) onClick(token.id);
drag.current = null; setDrift({ dx: 0, dy: 0 });
@@ -235,8 +310,8 @@ function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
className={cn('absolute grid place-items-center rounded-full border-2 border-black/50 text-[11px] font-bold text-black shadow', frac !== null && frac <= 0 && 'opacity-50')}
style={{
width: span * 0.92, height: span * 0.92,
left: token.col * cellPx + span * 0.04 + drift.dx,
top: token.row * cellPx + span * 0.04 + drift.dy,
left: token.col * gridSize + span * 0.04 + drift.dx / zoom,
top: token.row * gridSize + span * 0.04 + drift.dy / zoom,
background: token.color,
cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
touchAction: 'none',
+93 -67
View File
@@ -9,12 +9,14 @@ import {
type Point,
} from '@/lib/map';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { Modal } from '@/components/ui/Modal';
import { cn } from '@/lib/cn';
import { MapCanvas, type CanvasToken, type Overlay } from './MapCanvas';
import { TokenPalette, type TokenSpec } from './TokenPalette';
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000'];
type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping';
@@ -25,6 +27,7 @@ type DrawKind = MapDrawing['kind'];
export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaign }) {
const [m, setM] = useState<BattleMap>(map);
const characters = useCharacters(campaign.id);
const encounters = useEncounters(campaign.id);
const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
@@ -38,6 +41,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const [gmDraw, setGmDraw] = useState(true);
const [dims, setDims] = useState({ cols: 0, rows: 0 });
const [editToken, setEditToken] = useState<string | null>(null);
const [showPalette, setShowPalette] = useState(true);
// transient interaction state
const [overlay, setOverlay] = useState<Overlay>({});
@@ -46,15 +50,20 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const draftPts = useRef<Point[]>([]);
const distMode = campaign.system === 'pf2e' ? 'pf2e' : '5e';
const activeEncounter = useMemo(() => encounters.find((e) => e.status === 'active') ?? null, [encounters]);
// Live HP from a linked character drives the ring.
// Live HP/conditions: prefer an active-encounter combatant, then a linked
// character, then the token's denormalized snapshot.
const canvasTokens: CanvasToken[] = useMemo(() => m.tokens.map((t) => {
const linked = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined;
const linkedChar = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined;
const linkedCb = t.combatantId && activeEncounter ? activeEncounter.combatants.find((c) => c.id === t.combatantId) : undefined;
return {
id: t.id, label: t.label || (linked?.name ?? ''), color: t.color, col: t.col, row: t.row,
size: t.size, kind: t.kind, hp: linked?.hp ?? t.hp, conditions: t.conditions,
id: t.id, label: t.label || (linkedChar?.name ?? linkedCb?.name ?? ''), color: t.color, col: t.col, row: t.row,
size: t.size, kind: t.kind,
hp: linkedCb?.hp ?? linkedChar?.hp ?? t.hp,
conditions: linkedCb?.conditions ?? t.conditions,
};
}), [m.tokens, characters]);
}), [m.tokens, characters, activeEncounter]);
const commitReveal = (cells: string[], reveal: boolean) =>
update({ revealed: reveal ? applyReveal(m.revealed, cells) : applyHide(m.revealed, cells) });
@@ -130,77 +139,91 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const finishPoly = () => { if (poly.length >= 3) commitReveal(polygonCells(poly, gridSpec()), tool === 'reveal'); setPoly([]); setOverlay({}); };
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
const removeToken = (id: string) => update({ tokens: m.tokens.filter((t) => t.id !== id) });
const token = m.tokens.find((t) => t.id === editToken) ?? null;
return (
<div>
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
<Input className="h-8 max-w-36" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => (
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}</Button>
))}
<div className="grid gap-2" style={{ gridTemplateColumns: showPalette ? '210px minmax(0, 1fr)' : 'minmax(0, 1fr)' }}>
{showPalette && (
<TokenPalette characters={characters} encounters={encounters} existingTokens={m.tokens}
onPlace={addTokenSpec} onPlaceMany={addTokenSpecs} onClose={() => setShowPalette(false)} />
)}
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
{!showPalette && <Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>Tokens </Button>}
<Input className="h-8 max-w-36" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => (
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}</Button>
))}
</div>
{/* Contextual sub-toolbar */}
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-xs text-muted">
<label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
<label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
<label className="flex items-center gap-1">Cell px<NumberField className="w-16" value={m.gridSize} min={10} max={400} onChange={(gridSize) => update({ gridSize })} aria-label="Grid cell size" /></label>
<label className="flex items-center gap-1">Feet/cell<NumberField className="w-14" value={m.gridUnit.feet} min={1} max={100} onChange={(feet) => update({ gridUnit: { feet } })} aria-label="Feet per cell" /></label>
{(tool === 'reveal' || tool === 'hide') && (
<>
<span className="mx-1 h-4 w-px bg-line" />
{(['brush', 'rect', 'poly'] as FogShape[]).map((s) => (
<Button key={s} size="sm" variant={fogShape === s ? 'primary' : 'ghost'} onClick={() => { setFogShape(s); setPoly([]); setOverlay({}); }} className="capitalize">{s}</Button>
))}
{fogShape === 'brush' && <label className="flex items-center gap-1">Size<NumberField className="w-12" value={brush} min={0} max={5} onChange={setBrush} aria-label="Brush size" /></label>}
{fogShape === 'poly' && <Button size="sm" variant="secondary" disabled={poly.length < 3} onClick={finishPoly}>Finish polygon ({poly.length})</Button>}
<Button size="sm" variant="ghost" onClick={() => update({ revealed: revealAll(dims.cols, dims.rows) })}>Reveal all</Button>
<Button size="sm" variant="ghost" onClick={() => update({ revealed: hideAll() })}>Hide all</Button>
</>
)}
{tool === 'aoe' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
{(['circle', 'cone', 'line', 'square'] as AoeShape[]).map((s) => (
<Button key={s} size="sm" variant={aoeShape === s ? 'primary' : 'ghost'} onClick={() => { setAoeShape(s); setOverlay({}); }} className="capitalize">{s}</Button>
))}
<label className="flex items-center gap-1">Feet<NumberField className="w-14" value={aoeFeet} min={5} max={120} onChange={setAoeFeet} aria-label="AoE size feet" /></label>
<Button size="sm" variant="ghost" onClick={() => setOverlay({})}>Clear</Button>
</>
)}
{tool === 'draw' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
<Select className="h-8 w-28" value={drawKind} onChange={(e) => setDrawKind(e.target.value as DrawKind)} aria-label="Draw kind">
{(['freehand', 'line', 'arrow', 'rect', 'circle', 'text'] as DrawKind[]).map((k) => <option key={k} value={k}>{k}</option>)}
</Select>
<input type="color" value={drawColor} onChange={(e) => setDrawColor(e.target.value)} aria-label="Draw color" className="h-7 w-9" />
<label className="flex items-center gap-1"><input type="checkbox" checked={gmDraw} onChange={(e) => setGmDraw(e.target.checked)} /> GM only</label>
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: m.drawings.slice(0, -1) })}>Undo draw</Button>
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: [] })}>Clear drawings</Button>
</>
)}
<span className="mx-1 h-4 w-px bg-line" />
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
</div>
<MapCanvas
viewportHeight="calc(100vh - 17rem)"
view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings }}
overlay={overlay}
tokensDraggable={tool === 'move'}
{...(tool !== 'move' ? { onPointer } : {})}
onTokenMove={(id, col, row) => patchToken(id, { col, row })}
onTokenClick={(id) => setEditToken(id)}
onReady={setDims}
/>
</div>
</div>
{/* Contextual sub-toolbar */}
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-xs text-muted">
<label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
<label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
<label className="flex items-center gap-1">Cell px<NumberField className="w-16" value={m.gridSize} min={10} max={400} onChange={(gridSize) => update({ gridSize })} aria-label="Grid cell size" /></label>
<label className="flex items-center gap-1">Feet/cell<NumberField className="w-14" value={m.gridUnit.feet} min={1} max={100} onChange={(feet) => update({ gridUnit: { feet } })} aria-label="Feet per cell" /></label>
{(tool === 'reveal' || tool === 'hide') && (
<>
<span className="mx-1 h-4 w-px bg-line" />
{(['brush', 'rect', 'poly'] as FogShape[]).map((s) => (
<Button key={s} size="sm" variant={fogShape === s ? 'primary' : 'ghost'} onClick={() => { setFogShape(s); setPoly([]); setOverlay({}); }} className="capitalize">{s}</Button>
))}
{fogShape === 'brush' && <label className="flex items-center gap-1">Size<NumberField className="w-12" value={brush} min={0} max={5} onChange={setBrush} aria-label="Brush size" /></label>}
{fogShape === 'poly' && <Button size="sm" variant="secondary" disabled={poly.length < 3} onClick={finishPoly}>Finish polygon ({poly.length})</Button>}
<Button size="sm" variant="ghost" onClick={() => update({ revealed: revealAll(dims.cols, dims.rows) })}>Reveal all</Button>
<Button size="sm" variant="ghost" onClick={() => update({ revealed: hideAll() })}>Hide all</Button>
</>
)}
{tool === 'aoe' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
{(['circle', 'cone', 'line', 'square'] as AoeShape[]).map((s) => (
<Button key={s} size="sm" variant={aoeShape === s ? 'primary' : 'ghost'} onClick={() => { setAoeShape(s); setOverlay({}); }} className="capitalize">{s}</Button>
))}
<label className="flex items-center gap-1">Feet<NumberField className="w-14" value={aoeFeet} min={5} max={120} onChange={setAoeFeet} aria-label="AoE size feet" /></label>
<Button size="sm" variant="ghost" onClick={() => setOverlay({})}>Clear</Button>
</>
)}
{tool === 'draw' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
<Select className="h-8 w-28" value={drawKind} onChange={(e) => setDrawKind(e.target.value as DrawKind)} aria-label="Draw kind">
{(['freehand', 'line', 'arrow', 'rect', 'circle', 'text'] as DrawKind[]).map((k) => <option key={k} value={k}>{k}</option>)}
</Select>
<input type="color" value={drawColor} onChange={(e) => setDrawColor(e.target.value)} aria-label="Draw color" className="h-7 w-9" />
<label className="flex items-center gap-1"><input type="checkbox" checked={gmDraw} onChange={(e) => setGmDraw(e.target.checked)} /> GM only</label>
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: m.drawings.slice(0, -1) })}>Undo draw</Button>
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: [] })}>Clear drawings</Button>
</>
)}
<span className="mx-1 h-4 w-px bg-line" />
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
</div>
<MapCanvas
view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings }}
overlay={overlay}
tokensDraggable={tool === 'move'}
{...(tool !== 'move' ? { onPointer } : {})}
onTokenMove={(id, col, row) => patchToken(id, { col, row })}
onTokenClick={(id) => setEditToken(id)}
onReady={setDims}
/>
<p className="mt-2 text-xs text-muted">
Click a token to edit it. Use <strong>Reveal/Hide</strong> (brush, rectangle, polygon) for fog,
<strong> Measure</strong>/<strong>AoE</strong> for ranges, <strong>Draw</strong> for annotations, then Open player view to show players.
Place party/foe tokens from the left palette. Drag to move (Move tool); scroll to zoom, drag empty space (or middle/right-drag) to pan.
Use <strong>Reveal/Hide</strong> for fog, <strong>Measure</strong>/<strong>AoE</strong> for ranges, <strong>Draw</strong> for annotations, then Show to players.
</p>
{token && (
@@ -212,6 +235,9 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
<label className="block text-xs text-muted">Size<Select value={token.size} onChange={(e) => patchToken(token.id, { size: Number(e.target.value) })}>{[1, 2, 3, 4].map((s) => <option key={s} value={s}>{s}×{s}</option>)}</Select></label>
<label className="block text-xs text-muted">Kind<Select value={token.kind} onChange={(e) => patchToken(token.id, { kind: e.target.value as MapToken['kind'] })}>{['pc', 'npc', 'monster', 'object'].map((k) => <option key={k} value={k}>{k}</option>)}</Select></label>
<label className="block text-xs text-muted">Link character<Select value={token.characterId ?? ''} onChange={(e) => patchToken(token.id, e.target.value ? { characterId: e.target.value } : { characterId: undefined })}><option value=""> none </option>{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></label>
{activeEncounter && (
<label className="block text-xs text-muted">Link combatant<Select value={token.combatantId ?? ''} onChange={(e) => patchToken(token.id, e.target.value ? { combatantId: e.target.value } : { combatantId: undefined })}><option value=""> none </option>{activeEncounter.combatants.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></label>
)}
</div>
<div className="flex flex-wrap items-center gap-1">
{TOKEN_COLORS.map((c) => <button key={c} aria-label={`color ${c}`} onClick={() => patchToken(token.id, { color: c })} className={cn('h-6 w-6 rounded-full border', token.color === c ? 'border-accent' : 'border-line')} style={{ background: c }} />)}
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import type { Character } from '@/lib/schemas';
import { unplacedPcs } from './tokens';
const pc = (id: string): Character => ({ id, name: id } as unknown as Character);
describe('unplacedPcs', () => {
it('returns only PCs without a linked token', () => {
const pcs = [pc('a'), pc('b'), pc('c')];
const tokens = [{ characterId: 'b' }, { characterId: undefined }];
expect(unplacedPcs(pcs, tokens).map((c) => c.id)).toEqual(['a', 'c']);
});
it('returns all when none placed, none when all placed', () => {
const pcs = [pc('a'), pc('b')];
expect(unplacedPcs(pcs, []).map((c) => c.id)).toEqual(['a', 'b']);
expect(unplacedPcs(pcs, [{ characterId: 'a' }, { characterId: 'b' }])).toEqual([]);
});
});
+92
View File
@@ -0,0 +1,92 @@
import type { Character, Combatant, Encounter, MapToken } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { KIND_COLOR, baseSpec, unplacedPcs, type TokenSpec } from './tokens';
export type { TokenSpec };
/** Left sidebar for one-click placement of party / encounter / blank tokens. */
export function TokenPalette({ characters, encounters, existingTokens, onPlace, onPlaceMany, onClose }: {
characters: Character[];
encounters: Encounter[];
existingTokens: MapToken[];
onPlace: (spec: TokenSpec) => void;
onPlaceMany: (specs: TokenSpec[]) => void;
onClose: () => void;
}) {
const pcs = characters.filter((c) => c.kind === 'pc');
const activeEnc = encounters.find((e) => e.status === 'active') ?? encounters.find((e) => e.combatants.length > 0) ?? null;
const hasPc = (id: string) => existingTokens.some((t) => t.characterId === id);
const hasCombatant = (cb: Combatant) => existingTokens.some((t) => t.combatantId === cb.id || (!t.combatantId && !t.characterId && t.label === cb.name));
const placePc = (c: Character) => onPlace(baseSpec({ label: c.name, color: KIND_COLOR.pc, kind: 'pc', characterId: c.id, hp: c.hp }));
const addAllPcs = () => onPlaceMany(
unplacedPcs(pcs, existingTokens).map((c, i) => baseSpec({ label: c.name, color: KIND_COLOR.pc, kind: 'pc', characterId: c.id, hp: c.hp, col: i, row: 0 })),
);
const placeCombatant = (cb: Combatant) => onPlace(baseSpec({
label: cb.name, color: KIND_COLOR[cb.kind], kind: cb.kind, hp: cb.hp, combatantId: cb.id,
...(cb.characterId ? { characterId: cb.characterId } : {}),
}));
return (
<aside className="flex h-full min-h-0 flex-col gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Tokens</h3>
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette"> Hide</button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
<section>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium text-ink">Party</span>
<Button size="sm" variant="ghost" disabled={pcs.length === 0 || pcs.every((c) => hasPc(c.id))} onClick={addAllPcs}>+ All</Button>
</div>
{pcs.length === 0 ? (
<p className="text-xs text-muted">No player characters in this campaign.</p>
) : (
<ul className="space-y-1">
{pcs.map((c) => (
<li key={c.id}>
<button onClick={() => placePc(c)} className="flex w-full items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-accent">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: KIND_COLOR.pc }} />
<span className="flex-1 truncate text-ink">{c.name}</span>
<span className="text-[10px] uppercase text-muted">{hasPc(c.id) ? 'placed' : `L${c.level}`}</span>
</button>
</li>
))}
</ul>
)}
</section>
{activeEnc && activeEnc.combatants.length > 0 && (
<section>
<div className="mb-1 truncate text-xs font-medium text-ink" title={activeEnc.name}>{activeEnc.name}</div>
<ul className="space-y-1">
{activeEnc.combatants.map((cb) => {
const placed = hasCombatant(cb);
return (
<li key={cb.id}>
<button onClick={() => placeCombatant(cb)} disabled={placed} className="flex w-full items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left enabled:hover:border-accent disabled:opacity-50">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: KIND_COLOR[cb.kind] }} />
<span className="flex-1 truncate text-ink">{cb.name}</span>
<span className="text-[10px] uppercase text-muted">{placed ? 'placed' : cb.kind}</span>
</button>
</li>
);
})}
</ul>
</section>
)}
<section>
<div className="mb-1 text-xs font-medium text-ink">Add blank</div>
<div className="flex flex-wrap gap-1">
<Button size="sm" variant="secondary" onClick={() => onPlace(baseSpec({ label: 'NPC', color: KIND_COLOR.npc, kind: 'npc' }))}>NPC</Button>
<Button size="sm" variant="secondary" onClick={() => onPlace(baseSpec({ label: 'Foe', color: KIND_COLOR.monster, kind: 'monster' }))}>Monster</Button>
<Button size="sm" variant="secondary" onClick={() => onPlace(baseSpec({ label: '', color: KIND_COLOR.object, kind: 'object' }))}>Object</Button>
</div>
</section>
</div>
</aside>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Character, MapToken } from '@/lib/schemas';
export type TokenSpec = Omit<MapToken, 'id'>;
export const KIND_COLOR: Record<MapToken['kind'], string> = {
pc: '#64b5f6', npc: '#66bb6a', monster: '#ef5350', object: '#bdbdbd',
};
export function baseSpec(over: Partial<TokenSpec>): TokenSpec {
return { label: '', color: '#d4af37', col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false, ...over };
}
/** PCs that don't already have a linked token on the map (for "+ All"). */
export function unplacedPcs(pcs: Character[], existingTokens: Pick<MapToken, 'characterId'>[]): Character[] {
return pcs.filter((c) => !existingTokens.some((t) => t.characterId === c.id));
}
+4
View File
@@ -96,6 +96,10 @@ export class TtrpgDatabase extends Dexie {
}
});
});
// v9 — Phase 20: tokens gain optional combatantId (live HP from an encounter
// combatant). Optional field — no backfill needed; existing rows parse fine.
this.version(9).stores({});
}
}
+1
View File
@@ -4,3 +4,4 @@ export * from './distance';
export * from './shapes';
export * from './fog';
export * from './projection';
export * from './viewport';
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { clampZoom, zoomToPoint, fitViewport, worldFromScreen, MIN_ZOOM, MAX_ZOOM } from './viewport';
describe('viewport math', () => {
it('clamps zoom to bounds', () => {
expect(clampZoom(0.001)).toBe(MIN_ZOOM);
expect(clampZoom(999)).toBe(MAX_ZOOM);
expect(clampZoom(1.5)).toBe(1.5);
});
it('keeps the world point under the cursor fixed when zooming', () => {
const start = { zoom: 1, panX: 0, panY: 0 };
const cx = 100, cy = 50;
const before = worldFromScreen(start.zoom, cx, cy, start.panX, start.panY);
const next = zoomToPoint(start, 2, cx, cy);
const after = worldFromScreen(next.zoom, cx, cy, next.panX, next.panY);
expect(next.zoom).toBe(2);
expect(after.x).toBeCloseTo(before.x);
expect(after.y).toBeCloseTo(before.y);
});
it('fits a large landscape map by shrinking and centering', () => {
const vp = fitViewport(800, 600, 4000, 3000);
expect(vp.zoom).toBeCloseTo(0.2);
expect(vp.panX).toBeCloseTo(0);
expect(vp.panY).toBeCloseTo(0);
});
it('never upscales a small map past 1:1, and centers it', () => {
const vp = fitViewport(800, 600, 100, 100);
expect(vp.zoom).toBe(1);
expect(vp.panX).toBeCloseTo(350);
expect(vp.panY).toBeCloseTo(250);
});
it('guards degenerate sizes', () => {
expect(fitViewport(800, 600, 0, 0)).toEqual({ zoom: 1, panX: 0, panY: 0 });
});
it('converts screen to world by dividing out zoom', () => {
expect(worldFromScreen(2, 120, 80, 20, 0)).toEqual({ x: 50, y: 40 });
});
});
+44
View File
@@ -0,0 +1,44 @@
/** Pure pan/zoom viewport math for the map canvas (no DOM, unit-tested). */
import type { Point } from './types';
export interface Viewport {
/** multiplier; 1 = natural image resolution */
zoom: number;
/** translation in screen pixels, applied before scale */
panX: number;
panY: number;
}
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 6;
export function clampZoom(z: number): number {
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z));
}
/**
* Zoom by `factor` keeping the world point under (cx, cy) fixed on screen.
* cx/cy are cursor coordinates relative to the viewport's top-left.
*/
export function zoomToPoint(vp: Viewport, factor: number, cx: number, cy: number): Viewport {
const zoom = clampZoom(vp.zoom * factor);
const wx = (cx - vp.panX) / vp.zoom;
const wy = (cy - vp.panY) / vp.zoom;
return { zoom, panX: cx - wx * zoom, panY: cy - wy * zoom };
}
/** Center a natural-size image inside a viewport, never upscaling past 1:1. */
export function fitViewport(vw: number, vh: number, naturalW: number, naturalH: number): Viewport {
if (naturalW <= 0 || naturalH <= 0 || vw <= 0 || vh <= 0) return { zoom: 1, panX: 0, panY: 0 };
const zoom = Math.min(vw / naturalW, vh / naturalH, 1);
return { zoom, panX: (vw - naturalW * zoom) / 2, panY: (vh - naturalH * zoom) / 2 };
}
/**
* Convert a screen point to world (natural image) pixels, given the bounding rect
* of the already-transformed world layer. Because the layer's rect reflects the
* CSS transform, dividing by zoom is all that's needed.
*/
export function worldFromScreen(zoom: number, clientX: number, clientY: number, rectLeft: number, rectTop: number): Point {
return { x: (clientX - rectLeft) / zoom, y: (clientY - rectTop) / zoom };
}
+2
View File
@@ -98,6 +98,8 @@ export const mapTokenSchema = z.object({
kind: z.enum(['pc', 'npc', 'monster', 'object']).default('npc'),
/** optional link to a Character for a live HP ring / conditions */
characterId: z.string().optional(),
/** optional link to an active-encounter Combatant for a live HP ring */
combatantId: z.string().optional(),
/** denormalized HP snapshot (used when no live combatant drives the ring) */
hp: hpSchema.optional(),
conditions: z.array(conditionSchema).default([]),