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:
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createCharacter } from './helpers';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
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.getByRole('heading', { name: 'Battle map' })).toBeVisible();
|
||||||
await expect(page.getByTestId('map-token')).toHaveCount(1);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function PlayerBoards({ snapshot, image }: { snapshot: Snapshot; image?:
|
|||||||
{map && (
|
{map && (
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
|
<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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { PlayerMap } from '@/lib/map';
|
|||||||
import { MapCanvas, type CanvasToken } from '@/features/world/map/MapCanvas';
|
import { MapCanvas, type CanvasToken } from '@/features/world/map/MapCanvas';
|
||||||
|
|
||||||
/** Read-only render of a player-facing map projection + its image. */
|
/** 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) => ({
|
const tokens: CanvasToken[] = map.tokens.map((t) => ({
|
||||||
id: t.id, label: t.label, color: t.color, col: t.col, row: t.row,
|
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,
|
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
|
<MapCanvas
|
||||||
readOnly
|
readOnly
|
||||||
playerFog
|
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 }}
|
view={{ image, gridSize: map.gridSize, showGrid: map.showGrid, fogEnabled: map.fogEnabled, revealed: map.revealed, tokens, drawings: map.drawings }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { Point } from '@/lib/map';
|
import type { Point, Viewport } from '@/lib/map';
|
||||||
|
import { fitViewport, zoomToPoint, worldFromScreen } from '@/lib/map';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
|
/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
|
||||||
@@ -44,7 +45,8 @@ export interface Overlay {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
view: CanvasView;
|
view: CanvasView;
|
||||||
maxWidth?: number;
|
/** CSS height of the scroll/clip viewport (e.g. 'calc(100vh - 14rem)') */
|
||||||
|
viewportHeight?: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
/** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
|
/** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
|
||||||
playerFog?: boolean;
|
playerFog?: boolean;
|
||||||
@@ -56,13 +58,26 @@ interface Props {
|
|||||||
onReady?: (info: { cols: number; rows: number }) => void;
|
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 [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 fogRef = useRef<HTMLCanvasElement>(null);
|
||||||
const drawRef = useRef<HTMLCanvasElement>(null);
|
const drawRef = useRef<HTMLCanvasElement>(null);
|
||||||
const overlayRef = 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(() => {
|
useEffect(() => {
|
||||||
if (!view.image) { setNatural(null); return; }
|
if (!view.image) { setNatural(null); return; }
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -70,109 +85,167 @@ export function MapCanvas({ view, maxWidth = 880, readOnly, playerFog, overlay,
|
|||||||
img.src = view.image;
|
img.src = view.image;
|
||||||
}, [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]);
|
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
|
// Fog
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cv = fogRef.current;
|
const cv = fogRef.current;
|
||||||
if (!cv || !natural) return;
|
if (!cv || !natural) return;
|
||||||
cv.width = displayW; cv.height = displayH;
|
cv.width = W; cv.height = H;
|
||||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||||
ctx.clearRect(0, 0, displayW, displayH);
|
ctx.clearRect(0, 0, W, H);
|
||||||
if (!view.fogEnabled) return;
|
if (!view.fogEnabled) return;
|
||||||
const revealed = new Set(view.revealed);
|
const revealed = new Set(view.revealed);
|
||||||
ctx.fillStyle = playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.6)';
|
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++) {
|
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
|
// Drawings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cv = drawRef.current;
|
const cv = drawRef.current;
|
||||||
if (!cv || !natural) return;
|
if (!cv || !natural) return;
|
||||||
cv.width = displayW; cv.height = displayH;
|
cv.width = W; cv.height = H;
|
||||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||||
ctx.clearRect(0, 0, displayW, displayH);
|
ctx.clearRect(0, 0, W, H);
|
||||||
for (const d of view.drawings) drawShape(ctx, d, scale);
|
for (const d of view.drawings) drawShape(ctx, d);
|
||||||
}, [view.drawings, displayW, displayH, scale, natural]);
|
}, [view.drawings, W, H, natural]);
|
||||||
|
|
||||||
// Overlay (AoE/measure/draft)
|
// Overlay (AoE/measure/draft)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cv = overlayRef.current;
|
const cv = overlayRef.current;
|
||||||
if (!cv || !natural) return;
|
if (!cv || !natural) return;
|
||||||
cv.width = displayW; cv.height = displayH;
|
cv.width = W; cv.height = H;
|
||||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||||
ctx.clearRect(0, 0, displayW, displayH);
|
ctx.clearRect(0, 0, W, H);
|
||||||
if (overlay?.cells?.length) {
|
if (overlay?.cells?.length) {
|
||||||
ctx.fillStyle = 'rgba(212,175,55,0.35)';
|
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) {
|
if (overlay?.line) {
|
||||||
ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2;
|
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) {
|
if (overlay?.label) {
|
||||||
ctx.font = '600 13px sans-serif'; ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = 3;
|
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);
|
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 toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = (worldRef.current ?? (e.currentTarget as HTMLElement)).getBoundingClientRect();
|
||||||
const x = (e.clientX - rect.left) / scale, y = (e.clientY - rect.top) / scale;
|
const pt = worldFromScreen(vp.zoom, e.clientX, e.clientY, rect.left, rect.top);
|
||||||
return { point: { x, y }, col: Math.floor(x / view.gridSize), row: Math.floor(y / view.gridSize) };
|
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 (!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 (
|
return (
|
||||||
<div className="overflow-auto rounded-lg border border-line bg-surface p-2">
|
<div ref={outerRef} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
|
||||||
<div className="relative" style={{ width: displayW, height: displayH }}>
|
{!natural ? (
|
||||||
<img src={view.image} alt="" className="absolute inset-0 h-full w-full select-none" draggable={false} />
|
<p className="p-3 text-sm text-muted">Loading map…</p>
|
||||||
{view.showGrid && (
|
) : (
|
||||||
<div className="pointer-events-none absolute inset-0" style={{
|
<div
|
||||||
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)',
|
ref={worldRef}
|
||||||
backgroundSize: `${cellPx}px ${cellPx}px`,
|
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' }}
|
||||||
<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 }} />
|
<img src={view.image} alt="" className="absolute inset-0 h-full w-full select-none" draggable={false} />
|
||||||
<canvas ref={overlayRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
|
{view.showGrid && (
|
||||||
{interactive && (
|
<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
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
|
||||||
onPointerDown={(e) => { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); onPointer!('down', toPoint(e)); }}
|
onPointerDown={ixDown}
|
||||||
onPointerMove={(e) => { if (e.buttons) onPointer!('move', toPoint(e)); }}
|
onPointerMove={ixMove}
|
||||||
onPointerUp={(e) => onPointer!('up', toPoint(e))}
|
onPointerUp={ixUp}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
/>
|
/>
|
||||||
)}
|
{view.tokens.map((t) => (
|
||||||
{view.tokens.map((t) => (
|
<TokenChip key={t.id} token={t} gridSize={gridSize} zoom={vp.zoom} cols={cols} rows={rows}
|
||||||
<TokenChip key={t.id} token={t} cellPx={cellPx} cols={cols} rows={rows}
|
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
|
||||||
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
|
))}
|
||||||
))}
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: number): void {
|
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void {
|
||||||
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width * scale; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||||
const p = d.points.map((pt) => ({ x: pt.x * scale, y: pt.y * scale }));
|
const p = d.points;
|
||||||
if (d.kind === 'text' && d.text && p[0]) {
|
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);
|
ctx.fillText(d.text, p[0].x, p[0].y);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -187,7 +260,7 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: numbe
|
|||||||
ctx.moveTo(p[0]!.x, p[0]!.y);
|
ctx.moveTo(p[0]!.x, p[0]!.y);
|
||||||
for (const pt of p.slice(1)) ctx.lineTo(pt.x, pt.y);
|
for (const pt of p.slice(1)) ctx.lineTo(pt.x, pt.y);
|
||||||
if (d.kind === 'arrow') {
|
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));
|
||||||
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();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
|
function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onClick }: {
|
||||||
token: CanvasToken; cellPx: number; cols: number; rows: number;
|
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;
|
draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(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 [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 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) => {
|
const down = (e: React.PointerEvent) => {
|
||||||
@@ -216,11 +289,13 @@ function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
|
|||||||
};
|
};
|
||||||
const up = (e: React.PointerEvent) => {
|
const up = (e: React.PointerEvent) => {
|
||||||
if (!drag.current) return;
|
if (!drag.current) return;
|
||||||
|
e.stopPropagation();
|
||||||
ref.current?.releasePointerCapture(e.pointerId);
|
ref.current?.releasePointerCapture(e.pointerId);
|
||||||
const moved = drag.current.moved;
|
const moved = drag.current.moved;
|
||||||
if (moved && onMove) {
|
if (moved && onMove) {
|
||||||
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / cellPx)));
|
// screen-px drift → world-px (÷zoom) → cell delta (÷gridSize)
|
||||||
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / cellPx)));
|
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);
|
onMove(token.id, col, row);
|
||||||
} else if (!moved && onClick) onClick(token.id);
|
} else if (!moved && onClick) onClick(token.id);
|
||||||
drag.current = null; setDrift({ dx: 0, dy: 0 });
|
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')}
|
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={{
|
style={{
|
||||||
width: span * 0.92, height: span * 0.92,
|
width: span * 0.92, height: span * 0.92,
|
||||||
left: token.col * cellPx + span * 0.04 + drift.dx,
|
left: token.col * gridSize + span * 0.04 + drift.dx / zoom,
|
||||||
top: token.row * cellPx + span * 0.04 + drift.dy,
|
top: token.row * gridSize + span * 0.04 + drift.dy / zoom,
|
||||||
background: token.color,
|
background: token.color,
|
||||||
cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
|
cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
|
||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
type Point,
|
type Point,
|
||||||
} from '@/lib/map';
|
} from '@/lib/map';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
|
import { useEncounters } from '@/features/combat/hooks';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { MapCanvas, type CanvasToken, type Overlay } from './MapCanvas';
|
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'];
|
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000'];
|
||||||
type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping';
|
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 }) {
|
export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaign }) {
|
||||||
const [m, setM] = useState<BattleMap>(map);
|
const [m, setM] = useState<BattleMap>(map);
|
||||||
const characters = useCharacters(campaign.id);
|
const characters = useCharacters(campaign.id);
|
||||||
|
const encounters = useEncounters(campaign.id);
|
||||||
const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
|
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; });
|
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 [gmDraw, setGmDraw] = useState(true);
|
||||||
const [dims, setDims] = useState({ cols: 0, rows: 0 });
|
const [dims, setDims] = useState({ cols: 0, rows: 0 });
|
||||||
const [editToken, setEditToken] = useState<string | null>(null);
|
const [editToken, setEditToken] = useState<string | null>(null);
|
||||||
|
const [showPalette, setShowPalette] = useState(true);
|
||||||
|
|
||||||
// transient interaction state
|
// transient interaction state
|
||||||
const [overlay, setOverlay] = useState<Overlay>({});
|
const [overlay, setOverlay] = useState<Overlay>({});
|
||||||
@@ -46,15 +50,20 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
const draftPts = useRef<Point[]>([]);
|
const draftPts = useRef<Point[]>([]);
|
||||||
|
|
||||||
const distMode = campaign.system === 'pf2e' ? 'pf2e' : '5e';
|
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 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 {
|
return {
|
||||||
id: t.id, label: t.label || (linked?.name ?? ''), color: t.color, col: t.col, row: t.row,
|
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: linked?.hp ?? t.hp, conditions: t.conditions,
|
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) =>
|
const commitReveal = (cells: string[], reveal: boolean) =>
|
||||||
update({ revealed: reveal ? applyReveal(m.revealed, cells) : applyHide(m.revealed, cells) });
|
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 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 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 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 removeToken = (id: string) => update({ tokens: m.tokens.filter((t) => t.id !== id) });
|
||||||
const token = m.tokens.find((t) => t.id === editToken) ?? null;
|
const token = m.tokens.find((t) => t.id === editToken) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
<div className="grid gap-2" style={{ gridTemplateColumns: showPalette ? '210px minmax(0, 1fr)' : 'minmax(0, 1fr)' }}>
|
||||||
<Input className="h-8 max-w-36" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
|
{showPalette && (
|
||||||
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => (
|
<TokenPalette characters={characters} encounters={encounters} existingTokens={m.tokens}
|
||||||
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
|
onPlace={addTokenSpec} onPlaceMany={addTokenSpecs} onClose={() => setShowPalette(false)} />
|
||||||
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}</Button>
|
)}
|
||||||
))}
|
|
||||||
|
<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>
|
</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">
|
<p className="mt-2 text-xs text-muted">
|
||||||
Click a token to edit it. Use <strong>Reveal/Hide</strong> (brush, rectangle, polygon) for fog,
|
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.
|
||||||
<strong> Measure</strong>/<strong>AoE</strong> for ranges, <strong>Draw</strong> for annotations, then “Open player view” to show players.
|
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>
|
</p>
|
||||||
|
|
||||||
{token && (
|
{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">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">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>
|
<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>
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<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 }} />)}
|
{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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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({});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './distance';
|
|||||||
export * from './shapes';
|
export * from './shapes';
|
||||||
export * from './fog';
|
export * from './fog';
|
||||||
export * from './projection';
|
export * from './projection';
|
||||||
|
export * from './viewport';
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -98,6 +98,8 @@ export const mapTokenSchema = z.object({
|
|||||||
kind: z.enum(['pc', 'npc', 'monster', 'object']).default('npc'),
|
kind: z.enum(['pc', 'npc', 'monster', 'object']).default('npc'),
|
||||||
/** optional link to a Character for a live HP ring / conditions */
|
/** optional link to a Character for a live HP ring / conditions */
|
||||||
characterId: z.string().optional(),
|
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) */
|
/** denormalized HP snapshot (used when no live combatant drives the ring) */
|
||||||
hp: hpSchema.optional(),
|
hp: hpSchema.optional(),
|
||||||
conditions: z.array(conditionSchema).default([]),
|
conditions: z.array(conditionSchema).default([]),
|
||||||
|
|||||||
Reference in New Issue
Block a user