-
- {view.showGrid && (
-
- )}
-
-
-
- {interactive && (
+
+ {!natural ? (
+
Loading map…
+ ) : (
+
+
+ {view.showGrid && (
+
+ )}
+
+
+
{ (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) => (
-
- ))}
-
+ {view.tokens.map((t) => (
+
+ ))}
+
+ )}
+
+ {natural && (
+
+ zoomCentered(1 / 1.25)} aria-label="Zoom out">−
+ {Math.round(vp.zoom * 100)}%
+ zoomCentered(1.25)} aria-label="Zoom in">+
+ ⊡
+
+ )}
);
}
-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
(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',
diff --git a/src/features/world/map/MapEditor.tsx b/src/features/world/map/MapEditor.tsx
index 6c70252..61fb170 100644
--- a/src/features/world/map/MapEditor.tsx
+++ b/src/features/world/map/MapEditor.tsx
@@ -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(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) => 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(null);
+ const [showPalette, setShowPalette] = useState(true);
// transient interaction state
const [overlay, setOverlay] = useState({});
@@ -46,15 +50,20 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const draftPts = useRef([]);
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) => 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 (
-
-
update({ name: e.target.value })} aria-label="Map name" />
- {(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => (
-
{ setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}
- ))}
+
+ {showPalette && (
+
setShowPalette(false)} />
+ )}
+
+
+
+ {!showPalette && setShowPalette(true)}>Tokens ▸ }
+ update({ name: e.target.value })} aria-label="Map name" />
+ {(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => (
+ { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}
+ ))}
+
+
+ {/* Contextual sub-toolbar */}
+
+ update({ showGrid: e.target.checked })} /> Grid
+ update({ fogEnabled: e.target.checked })} /> Fog
+ Cell px update({ gridSize })} aria-label="Grid cell size" />
+ Feet/cell update({ gridUnit: { feet } })} aria-label="Feet per cell" />
+
+ {(tool === 'reveal' || tool === 'hide') && (
+ <>
+
+ {(['brush', 'rect', 'poly'] as FogShape[]).map((s) => (
+ { setFogShape(s); setPoly([]); setOverlay({}); }} className="capitalize">{s}
+ ))}
+ {fogShape === 'brush' && Size }
+ {fogShape === 'poly' && Finish polygon ({poly.length}) }
+ update({ revealed: revealAll(dims.cols, dims.rows) })}>Reveal all
+ update({ revealed: hideAll() })}>Hide all
+ >
+ )}
+ {tool === 'aoe' && (
+ <>
+
+ {(['circle', 'cone', 'line', 'square'] as AoeShape[]).map((s) => (
+ { setAoeShape(s); setOverlay({}); }} className="capitalize">{s}
+ ))}
+ Feet
+ setOverlay({})}>Clear
+ >
+ )}
+ {tool === 'draw' && (
+ <>
+
+ setDrawKind(e.target.value as DrawKind)} aria-label="Draw kind">
+ {(['freehand', 'line', 'arrow', 'rect', 'circle', 'text'] as DrawKind[]).map((k) => {k} )}
+
+ setDrawColor(e.target.value)} aria-label="Draw color" className="h-7 w-9" />
+ setGmDraw(e.target.checked)} /> GM only
+ update({ drawings: m.drawings.slice(0, -1) })}>Undo draw
+ update({ drawings: [] })}>Clear drawings
+ >
+ )}
+
+ + Token
+
+
+
patchToken(id, { col, row })}
+ onTokenClick={(id) => setEditToken(id)}
+ onReady={setDims}
+ />
+
- {/* Contextual sub-toolbar */}
-
- update({ showGrid: e.target.checked })} /> Grid
- update({ fogEnabled: e.target.checked })} /> Fog
- Cell px update({ gridSize })} aria-label="Grid cell size" />
- Feet/cell update({ gridUnit: { feet } })} aria-label="Feet per cell" />
-
- {(tool === 'reveal' || tool === 'hide') && (
- <>
-
- {(['brush', 'rect', 'poly'] as FogShape[]).map((s) => (
- { setFogShape(s); setPoly([]); setOverlay({}); }} className="capitalize">{s}
- ))}
- {fogShape === 'brush' && Size }
- {fogShape === 'poly' && Finish polygon ({poly.length}) }
- update({ revealed: revealAll(dims.cols, dims.rows) })}>Reveal all
- update({ revealed: hideAll() })}>Hide all
- >
- )}
- {tool === 'aoe' && (
- <>
-
- {(['circle', 'cone', 'line', 'square'] as AoeShape[]).map((s) => (
- { setAoeShape(s); setOverlay({}); }} className="capitalize">{s}
- ))}
- Feet
- setOverlay({})}>Clear
- >
- )}
- {tool === 'draw' && (
- <>
-
- setDrawKind(e.target.value as DrawKind)} aria-label="Draw kind">
- {(['freehand', 'line', 'arrow', 'rect', 'circle', 'text'] as DrawKind[]).map((k) => {k} )}
-
- setDrawColor(e.target.value)} aria-label="Draw color" className="h-7 w-9" />
- setGmDraw(e.target.checked)} /> GM only
- update({ drawings: m.drawings.slice(0, -1) })}>Undo draw
- update({ drawings: [] })}>Clear drawings
- >
- )}
-
- + Token
-
-
-
patchToken(id, { col, row })}
- onTokenClick={(id) => setEditToken(id)}
- onReady={setDims}
- />
- Click a token to edit it. Use Reveal/Hide (brush, rectangle, polygon) for fog,
- Measure /AoE for ranges, Draw 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 Reveal/Hide for fog, Measure /AoE for ranges, Draw for annotations, then “Show to players”.
{token && (
@@ -212,6 +235,9 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
Size patchToken(token.id, { size: Number(e.target.value) })}>{[1, 2, 3, 4].map((s) => {s}×{s} )}
Kind patchToken(token.id, { kind: e.target.value as MapToken['kind'] })}>{['pc', 'npc', 'monster', 'object'].map((k) => {k} )}
Link character patchToken(token.id, e.target.value ? { characterId: e.target.value } : { characterId: undefined })}>— none — {characters.map((c) => {c.name} )}
+ {activeEncounter && (
+ Link combatant patchToken(token.id, e.target.value ? { combatantId: e.target.value } : { combatantId: undefined })}>— none — {activeEncounter.combatants.map((c) => {c.name} )}
+ )}
{TOKEN_COLORS.map((c) =>
patchToken(token.id, { color: c })} className={cn('h-6 w-6 rounded-full border', token.color === c ? 'border-accent' : 'border-line')} style={{ background: c }} />)}
diff --git a/src/features/world/map/TokenPalette.test.ts b/src/features/world/map/TokenPalette.test.ts
new file mode 100644
index 0000000..cae9d2a
--- /dev/null
+++ b/src/features/world/map/TokenPalette.test.ts
@@ -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([]);
+ });
+});
diff --git a/src/features/world/map/TokenPalette.tsx b/src/features/world/map/TokenPalette.tsx
new file mode 100644
index 0000000..a4d3b6b
--- /dev/null
+++ b/src/features/world/map/TokenPalette.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/features/world/map/tokens.ts b/src/features/world/map/tokens.ts
new file mode 100644
index 0000000..4125cf0
--- /dev/null
+++ b/src/features/world/map/tokens.ts
@@ -0,0 +1,16 @@
+import type { Character, MapToken } from '@/lib/schemas';
+
+export type TokenSpec = Omit;
+
+export const KIND_COLOR: Record = {
+ pc: '#64b5f6', npc: '#66bb6a', monster: '#ef5350', object: '#bdbdbd',
+};
+
+export function baseSpec(over: Partial): 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[]): Character[] {
+ return pcs.filter((c) => !existingTokens.some((t) => t.characterId === c.id));
+}
diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts
index 97473c2..c30fc5e 100644
--- a/src/lib/db/db.ts
+++ b/src/lib/db/db.ts
@@ -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({});
}
}
diff --git a/src/lib/map/index.ts b/src/lib/map/index.ts
index d39d791..4783ae1 100644
--- a/src/lib/map/index.ts
+++ b/src/lib/map/index.ts
@@ -4,3 +4,4 @@ export * from './distance';
export * from './shapes';
export * from './fog';
export * from './projection';
+export * from './viewport';
diff --git a/src/lib/map/viewport.test.ts b/src/lib/map/viewport.test.ts
new file mode 100644
index 0000000..a21f2cc
--- /dev/null
+++ b/src/lib/map/viewport.test.ts
@@ -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 });
+ });
+});
diff --git a/src/lib/map/viewport.ts b/src/lib/map/viewport.ts
new file mode 100644
index 0000000..9e3b78e
--- /dev/null
+++ b/src/lib/map/viewport.ts
@@ -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 };
+}
diff --git a/src/lib/schemas/world.ts b/src/lib/schemas/world.ts
index a2faaaf..5ee3a18 100644
--- a/src/lib/schemas/world.ts
+++ b/src/lib/schemas/world.ts
@@ -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([]),