Files
ttrpg_manager/src/lib/map/viewport.ts
T
NilsBriggen c28c29a6c6 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>
2026-06-08 12:34:00 +02:00

45 lines
1.7 KiB
TypeScript

/** 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 };
}