P20 fix: crisp device-resolution map rendering + live draw previews
Render the map on a single viewport-sized canvas with a camera transform at devicePixelRatio (image, fog, drawings, overlay) and draw the grid as crisp 1px device-space lines — instead of CSS-scaling a natural-resolution bitmap, which blurred the grid/fog/text at any zoom. Tokens now position in screen space so their labels stay sharp too. Add live tool feedback via a 'hover' phase: polygon shows its outline + vertices + rubber-band + fill preview, the brush shows its footprint, and circle/square AoE preview under the cursor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Point, Viewport } from '@/lib/map';
|
||||
import { fitViewport, zoomToPoint, worldFromScreen } from '@/lib/map';
|
||||
import { fitViewport, zoomToPoint } from '@/lib/map';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
|
||||
@@ -41,36 +41,46 @@ export interface Overlay {
|
||||
line?: { a: Point; b: Point } | undefined;
|
||||
label?: { at: Point; text: string } | undefined;
|
||||
draft?: CanvasDrawing | undefined;
|
||||
/** in-progress polygon: committed vertices + a rubber-band point at the cursor */
|
||||
poly?: { points: Point[]; cursor?: Point | undefined } | undefined;
|
||||
/** brush footprint at the cursor (radius in cells) */
|
||||
brush?: { col: number; row: number; size: number } | undefined;
|
||||
}
|
||||
|
||||
type Phase = 'down' | 'move' | 'up' | 'hover';
|
||||
|
||||
interface Props {
|
||||
view: CanvasView;
|
||||
/** CSS height of the scroll/clip viewport (e.g. 'calc(100vh - 14rem)') */
|
||||
/** CSS height of the clip viewport (e.g. 'calc(100vh - 14rem)') */
|
||||
viewportHeight?: string;
|
||||
readOnly?: boolean;
|
||||
/** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
|
||||
playerFog?: boolean;
|
||||
overlay?: Overlay;
|
||||
onPointer?: (phase: 'down' | 'move' | 'up', p: { point: Point; col: number; row: number }) => void;
|
||||
onPointer?: (phase: Phase, p: { point: Point; col: number; row: number }) => void;
|
||||
onLeave?: () => void;
|
||||
onTokenMove?: (id: string, col: number, row: number) => void;
|
||||
onTokenClick?: (id: string) => void;
|
||||
tokensDraggable?: boolean;
|
||||
onReady?: (info: { cols: number; rows: number }) => void;
|
||||
}
|
||||
|
||||
const MAX_DPR = 2.5;
|
||||
|
||||
/**
|
||||
* Shared map renderer. 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.
|
||||
* Shared map renderer. A single canvas draws the image + grid + fog + drawings +
|
||||
* overlay with a camera transform (translate+scale) at the device's pixel
|
||||
* resolution, so everything stays crisp at any zoom (unlike CSS-scaling a
|
||||
* natural-resolution bitmap). Tokens are DOM, in a matching transformed layer.
|
||||
*/
|
||||
export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
|
||||
export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onLeave, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
|
||||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||
const [vp, setVp] = useState<Viewport>({ zoom: 1, panX: 0, panY: 0 });
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const fogRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const fittedRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const gridSize = view.gridSize;
|
||||
const W = natural?.w ?? 0;
|
||||
@@ -78,24 +88,35 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
|
||||
const cols = natural ? Math.ceil(W / gridSize) : 0;
|
||||
const rows = natural ? Math.ceil(H / gridSize) : 0;
|
||||
|
||||
// Load the image bitmap (used both for sizing and for canvas drawImage).
|
||||
useEffect(() => {
|
||||
if (!view.image) { setNatural(null); return; }
|
||||
if (!view.image) { setNatural(null); imgRef.current = null; return; }
|
||||
const img = new Image();
|
||||
img.onload = () => setNatural({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
img.onload = () => { imgRef.current = img; setNatural({ w: img.naturalWidth, h: img.naturalHeight }); };
|
||||
img.src = view.image;
|
||||
}, [view.image]);
|
||||
|
||||
useEffect(() => { if (natural) onReady?.({ cols, rows }); }, [cols, rows, natural, onReady]);
|
||||
|
||||
// Fit the image into the viewport whenever a new image loads.
|
||||
const fit = useCallback(() => {
|
||||
// Track the viewport's CSS size.
|
||||
useEffect(() => {
|
||||
const el = outerRef.current;
|
||||
if (!el || !natural) return;
|
||||
setVp(fitViewport(el.clientWidth, el.clientHeight, natural.w, natural.h));
|
||||
}, [natural]);
|
||||
useEffect(() => { fit(); }, [fit]);
|
||||
if (!el) return;
|
||||
const update = () => setSize({ w: el.clientWidth, h: el.clientHeight });
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Wheel zoom (native, non-passive so we can prevent page scroll).
|
||||
// Fit once per image, as soon as both the image and viewport size are known.
|
||||
useEffect(() => {
|
||||
if (!natural || size.w === 0 || fittedRef.current === view.image) return;
|
||||
fittedRef.current = view.image;
|
||||
setVp(fitViewport(size.w, size.h, natural.w, natural.h));
|
||||
}, [natural, size, view.image]);
|
||||
|
||||
// Wheel zoom (native + non-passive so the page doesn't scroll).
|
||||
useEffect(() => {
|
||||
const el = outerRef.current;
|
||||
if (!el) return;
|
||||
@@ -108,62 +129,25 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
// Fog
|
||||
// Redraw whenever camera/content/size changes (rAF-coalesced).
|
||||
useEffect(() => {
|
||||
const cv = fogRef.current;
|
||||
if (!cv || !natural) return;
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||
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 * gridSize, r * gridSize, gridSize + 0.5, gridSize + 0.5);
|
||||
}
|
||||
}, [view.revealed, view.fogEnabled, playerFog, W, H, gridSize, cols, rows, natural]);
|
||||
|
||||
// Drawings
|
||||
useEffect(() => {
|
||||
const cv = drawRef.current;
|
||||
if (!cv || !natural) return;
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||
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 = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||
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! * gridSize, r! * gridSize, gridSize, gridSize); }
|
||||
}
|
||||
if (overlay?.draft) drawShape(ctx, overlay.draft);
|
||||
if (overlay?.line) {
|
||||
ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2;
|
||||
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 + 6, y = overlay.label.at.y - 6;
|
||||
ctx.strokeText(overlay.label.text, x, y); ctx.fillText(overlay.label.text, x, y);
|
||||
}
|
||||
}, [overlay, W, H, gridSize, natural]);
|
||||
const raf = requestAnimationFrame(() => {
|
||||
drawScene(canvasRef.current, imgRef.current, size.w, size.h, {
|
||||
vp, W, H, gridSize, cols, rows,
|
||||
showGrid: view.showGrid, fogEnabled: view.fogEnabled, revealed: view.revealed,
|
||||
playerFog: !!playerFog, drawings: view.drawings, overlay,
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
const toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => {
|
||||
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) };
|
||||
const r = outerRef.current!.getBoundingClientRect();
|
||||
const x = (e.clientX - r.left - vp.panX) / vp.zoom;
|
||||
const y = (e.clientY - r.top - vp.panY) / vp.zoom;
|
||||
return { point: { x, y }, col: Math.floor(x / gridSize), row: Math.floor(y / gridSize) };
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -179,51 +163,40 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
|
||||
setVp((v) => ({ ...v, panX: px + (e.clientX - x), panY: py + (e.clientY - y) }));
|
||||
return;
|
||||
}
|
||||
if (interactive && e.buttons) onPointer!('move', toPoint(e));
|
||||
if (interactive) onPointer!(e.buttons ? 'move' : 'hover', toPoint(e));
|
||||
};
|
||||
const ixUp = (e: React.PointerEvent) => {
|
||||
if (pan.current) { pan.current = null; return; }
|
||||
if (interactive) onPointer!('up', toPoint(e));
|
||||
};
|
||||
|
||||
const zoomCentered = (factor: number) => setVp((v) => zoomToPoint(v, factor, size.w / 2, size.h / 2));
|
||||
const fit = () => { if (natural) setVp(fitViewport(size.w, size.h, natural.w, natural.h)); };
|
||||
|
||||
if (!view.image) return <p className="text-sm text-muted">No image.</p>;
|
||||
|
||||
const zoomCentered = (factor: number) => {
|
||||
const el = outerRef.current; if (!el) return;
|
||||
setVp((v) => zoomToPoint(v, factor, el.clientWidth / 2, el.clientHeight / 2));
|
||||
};
|
||||
|
||||
return (
|
||||
<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: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
|
||||
onPointerDown={ixDown}
|
||||
onPointerMove={ixMove}
|
||||
onPointerUp={ixUp}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
<div ref={outerRef} data-testid="map-viewport" data-zoom={vp.zoom} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
|
||||
{!natural && <p className="p-3 text-sm text-muted">Loading map…</p>}
|
||||
|
||||
<canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" />
|
||||
|
||||
{/* pointer surface (below tokens so token drags win; tokens are small) */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
|
||||
onPointerDown={ixDown}
|
||||
onPointerMove={ixMove}
|
||||
onPointerUp={ixUp}
|
||||
onPointerLeave={() => { if (interactive) onLeave?.(); }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
|
||||
{/* tokens: DOM positioned in screen space (no CSS scale → labels stay crisp) */}
|
||||
{natural && (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
{view.tokens.map((t) => (
|
||||
<TokenChip key={t.id} token={t} gridSize={gridSize} zoom={vp.zoom} cols={cols} rows={rows}
|
||||
<TokenChip key={t.id} token={t} gridSize={gridSize} vp={vp} cols={cols} rows={rows}
|
||||
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
|
||||
))}
|
||||
</div>
|
||||
@@ -234,13 +207,125 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
|
||||
<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>
|
||||
<button className="grid h-6 place-items-center rounded px-1.5 hover:bg-elevated" onClick={fit} aria-label="Fit map to view" title="Fit">⊡</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SceneParams {
|
||||
vp: Viewport;
|
||||
W: number; H: number; gridSize: number; cols: number; rows: number;
|
||||
showGrid: boolean; fogEnabled: boolean; revealed: string[]; playerFog: boolean;
|
||||
drawings: CanvasDrawing[]; overlay?: Overlay | undefined;
|
||||
}
|
||||
|
||||
/** Draw the whole scene at device resolution with a camera transform. */
|
||||
function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | null, vw: number, vh: number, p: SceneParams): void {
|
||||
if (!canvas || vw === 0 || vh === 0) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||
const bw = Math.round(vw * dpr), bh = Math.round(vh * dpr);
|
||||
if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw; canvas.height = bh; }
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const { zoom, panX, panY } = p.vp;
|
||||
// Camera maps world px → CSS px → device px. Device transform = dpr only.
|
||||
const cam = () => ctx.setTransform(zoom * dpr, 0, 0, zoom * dpr, panX * dpr, panY * dpr);
|
||||
const dev = () => ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
dev();
|
||||
ctx.clearRect(0, 0, vw, vh);
|
||||
if (!img) return;
|
||||
|
||||
// Map image
|
||||
cam();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(img, 0, 0, p.W, p.H);
|
||||
|
||||
// Fog (world space)
|
||||
if (p.fogEnabled) {
|
||||
const revealed = new Set(p.revealed);
|
||||
ctx.fillStyle = p.playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.55)';
|
||||
for (let c = 0; c < p.cols; c++) for (let r = 0; r < p.rows; r++) {
|
||||
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * p.gridSize, r * p.gridSize, p.gridSize + 0.5, p.gridSize + 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Grid (device space → crisp 1px lines)
|
||||
if (p.showGrid) {
|
||||
const step = p.gridSize * zoom;
|
||||
if (step >= 4) {
|
||||
dev();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
const x0 = Math.max(0, panX), x1 = Math.min(vw, panX + p.W * zoom);
|
||||
const y0 = Math.max(0, panY), y1 = Math.min(vh, panY + p.H * zoom);
|
||||
for (let c = Math.max(0, Math.floor((-panX) / step)); c <= p.cols; c++) {
|
||||
const x = Math.round(panX + c * step) + 0.5;
|
||||
if (x < x0 - 1 || x > x1 + 1) continue;
|
||||
ctx.moveTo(x, y0); ctx.lineTo(x, y1);
|
||||
}
|
||||
for (let r = Math.max(0, Math.floor((-panY) / step)); r <= p.rows; r++) {
|
||||
const y = Math.round(panY + r * step) + 0.5;
|
||||
if (y < y0 - 1 || y > y1 + 1) continue;
|
||||
ctx.moveTo(x0, y); ctx.lineTo(x1, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Drawings (world space; crisp under the camera transform)
|
||||
cam();
|
||||
for (const d of p.drawings) drawShape(ctx, d);
|
||||
|
||||
// Overlay (AoE/measure/draft/poly/brush)
|
||||
const o = p.overlay;
|
||||
if (o) {
|
||||
cam();
|
||||
if (o.cells?.length) {
|
||||
ctx.fillStyle = 'rgba(212,175,55,0.35)';
|
||||
for (const k of o.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * p.gridSize, r! * p.gridSize, p.gridSize, p.gridSize); }
|
||||
}
|
||||
if (o.draft) drawShape(ctx, o.draft);
|
||||
if (o.brush) {
|
||||
ctx.strokeStyle = 'rgba(212,175,55,0.95)';
|
||||
ctx.lineWidth = 2 / zoom;
|
||||
const s = o.brush.size;
|
||||
ctx.strokeRect((o.brush.col - s) * p.gridSize, (o.brush.row - s) * p.gridSize, (s * 2 + 1) * p.gridSize, (s * 2 + 1) * p.gridSize);
|
||||
}
|
||||
if (o.poly && o.poly.points.length) {
|
||||
const pts = o.poly.points;
|
||||
ctx.strokeStyle = '#d4af37';
|
||||
ctx.lineWidth = 2 / zoom;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (const pt of pts.slice(1)) ctx.lineTo(pt.x, pt.y);
|
||||
if (o.poly.cursor) ctx.lineTo(o.poly.cursor.x, o.poly.cursor.y);
|
||||
if (pts.length >= 2) ctx.closePath();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#fff';
|
||||
for (const pt of pts) { ctx.beginPath(); ctx.arc(pt.x, pt.y, 4 / zoom, 0, Math.PI * 2); ctx.fill(); }
|
||||
}
|
||||
if (o.line) {
|
||||
ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2 / zoom;
|
||||
ctx.beginPath(); ctx.moveTo(o.line.a.x, o.line.a.y); ctx.lineTo(o.line.b.x, o.line.b.y); ctx.stroke();
|
||||
}
|
||||
if (o.label) {
|
||||
// label in screen space → fixed, readable size at any zoom
|
||||
dev();
|
||||
ctx.font = '600 13px sans-serif';
|
||||
ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.75)'; ctx.lineWidth = 3;
|
||||
const x = panX + o.label.at.x * zoom + 8, y = panY + o.label.at.y * zoom - 8;
|
||||
ctx.strokeText(o.label.text, x, y); ctx.fillText(o.label.text, x, y);
|
||||
}
|
||||
}
|
||||
dev();
|
||||
}
|
||||
|
||||
/** Draw a vector annotation in world coordinates (crisp under the camera). */
|
||||
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void {
|
||||
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
const p = d.points;
|
||||
@@ -268,14 +353,16 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void {
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onClick }: {
|
||||
token: CanvasToken; gridSize: number; zoom: number; cols: number; rows: number;
|
||||
function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick }: {
|
||||
token: CanvasToken; gridSize: number; vp: Viewport; cols: number; rows: number;
|
||||
draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(null);
|
||||
const [drift, setDrift] = useState({ dx: 0, dy: 0 });
|
||||
const span = gridSize * token.size;
|
||||
const { zoom, panX, panY } = vp;
|
||||
const span = gridSize * token.size * zoom; // on-screen px
|
||||
const box = span * 0.92;
|
||||
const frac = token.hp && token.hp.max > 0 ? Math.max(0, Math.min(1, token.hp.current / token.hp.max)) : null;
|
||||
|
||||
const down = (e: React.PointerEvent) => {
|
||||
@@ -293,9 +380,8 @@ function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onCli
|
||||
ref.current?.releasePointerCapture(e.pointerId);
|
||||
const moved = drag.current.moved;
|
||||
if (moved && onMove) {
|
||||
// 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)));
|
||||
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / (gridSize * zoom))));
|
||||
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / (gridSize * zoom))));
|
||||
onMove(token.id, col, row);
|
||||
} else if (!moved && onClick) onClick(token.id);
|
||||
drag.current = null; setDrift({ dx: 0, dy: 0 });
|
||||
@@ -307,11 +393,12 @@ function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onCli
|
||||
onPointerDown={down} onPointerMove={move} onPointerUp={up}
|
||||
title={token.label}
|
||||
data-testid="map-token"
|
||||
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('pointer-events-auto absolute grid place-items-center rounded-full border-2 border-black/50 font-bold leading-none text-black shadow', frac !== null && frac <= 0 && 'opacity-50')}
|
||||
style={{
|
||||
width: span * 0.92, height: span * 0.92,
|
||||
left: token.col * gridSize + span * 0.04 + drift.dx / zoom,
|
||||
top: token.row * gridSize + span * 0.04 + drift.dy / zoom,
|
||||
width: box, height: box,
|
||||
left: panX + token.col * gridSize * zoom + span * 0.04 + drift.dx,
|
||||
top: panY + token.row * gridSize * zoom + span * 0.04 + drift.dy,
|
||||
fontSize: Math.max(8, Math.min(22, span * 0.3)),
|
||||
background: token.color,
|
||||
cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
|
||||
touchAction: 'none',
|
||||
|
||||
Reference in New Issue
Block a user