Presentation-only camera over the native-resolution canvas: zoom-to-fit default (reset via fit button, replacing zoom 1:1), wheel/pinch zoom anchored at the cursor (fit x0.8 to 8x), pointer drag pan with edge clamping, and a 140px minimap with viewport rectangle that recenters on click and hides when the whole map fits. Tooltips account for the transform; no page scroll in any mode.
- commit
- ebc7efc2126ea17197be150964bb7db50b408b07
- parent
- 6d99d4c45fbe67e41cc0ff009771a20ac00bd065
- author
- Dami <dami.etoile@gmail.com>
| M | web/ | 11 | +- |
| M | web/ | 228 | +++++++++++++++++++++++++++++- |
| M | web/ | 41 | ++++-- |
@@ -159,14 +159,14 @@ function buildViewer(
159159 );160160 161161 // ---- world viewport (map view owned by render/map-view) -----------------162- const zoomBtn = h('button', { class: 'small' }, 'zoom 1:1');162+ const fitBtn = h('button', { class: 'small', title: 'reset view — zoom to fit' }, 'fit');163163 const povSel = h(164164 'select',165165 null,166166 h('option', { value: '-1' }, 'POV: omniscient'),167167 sim.scripts.map((s) => h('option', { value: String(s.slot) }, `POV: ${s.scriptName} (slot ${s.slot})`)),168168 );169- const worldToolbar = h('div', { class: 'row world-toolbar' }, povSel, zoomBtn);169+ const worldToolbar = h('div', { class: 'row world-toolbar' }, povSel, fitBtn);170170 const mapHost = h('div', { class: 'map-host' });171171 const mapView = createMapView(mapHost, { mapSize, names });172172 @@ -448,10 +448,9 @@ function buildViewer(
448448 runner.setPov(runner.pov); // keep pov449449 runner.restart(); // start() re-sends the watch flag450450 };451- zoomBtn.onclick = () => {452- const zoomed = mapHost.classList.toggle('zoomed');453- zoomBtn.textContent = zoomed ? 'zoom fit' : 'zoom 1:1';454- };451+ // camera lives in the map view (wheel zoom, drag pan, minimap); this only452+ // resets it to the default zoom-to-fit framing.453+ fitBtn.onclick = () => mapView.resetView();455454 povSel.onchange = () => {456455 // POV only affects the map — feeds are per-tribe channels.457456 runner?.setPov(Number(povSel.value));@@ -2,10 +2,13 @@
22 * Urizen-tileset map renderer for the viewer's Watch tab.33 *44 * Renders 1:1 at 12px per map tile (mapSize 128 → 1536px canvas); the page5- * scales/zooms it purely with CSS (see the map-view styles appended to6- * style.css). Terrain is rasterized once to an offscreen layer and only7- * redrawn when the terrain string changes (fog reveals, POV switches);8- * entities are composited over it every frame.5+ * displays it through a mapping-software camera (zoom + center, applied as a6+ * CSS transform inside the overflow-hidden host — see the map-view styles in7+ * style.css). Wheel zooms at the cursor, drag pans, a corner minimap shows8+ * the viewport when zoomed in. The camera is presentation-only: sim frames9+ * still rasterize the full world. Terrain is rasterized once to an offscreen10+ * layer and only redrawn when the terrain string changes (fog reveals, POV11+ * switches); entities are composited over it every frame.912 */1013 import type { MapFrame } from '../sim/frames';1114 import type { ThreatKind, WarbandKind } from '/protocol';@@ -64,6 +67,8 @@ export function createMapView(
6467 ): {6568 draw(frame: MapFrame): void;6669 attachTooltips(getFrame: () => MapFrame | null): { dispose(): void };70+ /** Reset the camera to the default zoom-to-fit, centered view. */71+ resetView(): void;6772 dispose(): void;6873 } {6974 const { mapSize, names } = opts;@@ -78,6 +83,206 @@ export function createMapView(
7883 const ctx = ctx2d(canvas);7984 ctx.imageSmoothingEnabled = false;8085 86+ // ---- camera (OpenLayers-style pan/zoom; presentation-only) --------------87+ // The camera never touches the world raster: it is a CSS transform on the88+ // canvas inside the overflow-hidden host. `zoom` is screen px per world px89+ // (1 = native 12px tiles); (cx, cy) is the world-px point under the90+ // viewport center.91+ const MAX_ZOOM = 8;92+ const MIN_FIT_FACTOR = 0.8; // may zoom out slightly past fit, no further93+ let hostW = 0;94+ let hostH = 0;95+ let zoom = 1;96+ let cx = sizePx / 2;97+ let cy = sizePx / 2;98+ let userMoved = false; // once true, resizes keep the user's view (clamped)99+ let panning = false;100+101+ const fitZoom = (): number =>102+ hostW > 0 && hostH > 0 ? Math.min(hostW / sizePx, hostH / sizePx) : 1;103+ const clampZoom = (z: number): number =>104+ Math.min(MAX_ZOOM, Math.max(fitZoom() * MIN_FIT_FACTOR, z));105+106+ /** Keep the map on screen: no gap past an edge when zoomed in; an axis the107+ * whole map fits on stays centered. */108+ function clampCenter(): void {109+ const halfW = hostW / (2 * zoom);110+ const halfH = hostH / (2 * zoom);111+ cx = halfW >= sizePx / 2 ? sizePx / 2 : Math.min(sizePx - halfW, Math.max(halfW, cx));112+ cy = halfH >= sizePx / 2 ? sizePx / 2 : Math.min(sizePx - halfH, Math.max(halfH, cy));113+ }114+115+ function applyCamera(): void {116+ // Snap the translation to device pixels so nearest-neighbor scaling117+ // doesn't shimmer on subpixel offsets.118+ const dpr = window.devicePixelRatio || 1;119+ const ox = Math.round((hostW / 2 - cx * zoom) * dpr) / dpr;120+ const oy = Math.round((hostH / 2 - cy * zoom) * dpr) / dpr;121+ canvas.style.transform = `translate3d(${ox}px, ${oy}px, 0) scale(${zoom})`;122+ drawMinimap();123+ }124+125+ /** Zoom to `z`, keeping the world point under host-relative (sx, sy) fixed. */126+ function zoomAt(sx: number, sy: number, z: number): void {127+ const nz = clampZoom(z);128+ if (nz === zoom) return;129+ const wx = (sx - hostW / 2) / zoom + cx;130+ const wy = (sy - hostH / 2) / zoom + cy;131+ zoom = nz;132+ cx = wx - (sx - hostW / 2) / zoom;133+ cy = wy - (sy - hostH / 2) / zoom;134+ userMoved = true;135+ clampCenter();136+ applyCamera();137+ }138+139+ function resetView(): void {140+ userMoved = false;141+ zoom = fitZoom();142+ cx = sizePx / 2;143+ cy = sizePx / 2;144+ applyCamera();145+ }146+147+ // wheel zoom, anchored at the cursor (trackpad pinch arrives as ctrl+wheel)148+ const onWheel = (e: WheelEvent): void => {149+ e.preventDefault();150+ const rect = container.getBoundingClientRect();151+ const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; // line → px152+ zoomAt(e.clientX - rect.left, e.clientY - rect.top, zoom * Math.exp(-dy * 0.0015));153+ };154+155+ // drag pan (mouse + touch via pointer events) and two-finger pinch zoom156+ const pointers = new Map<number, { x: number; y: number }>();157+ let pinchDist = 0;158+ const dist = (): number => {159+ const [a, b] = [...pointers.values()];160+ return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;161+ };162+ const onPointerDown = (e: PointerEvent): void => {163+ if (e.pointerType === 'mouse' && e.button !== 0) return;164+ container.setPointerCapture(e.pointerId);165+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });166+ if (pointers.size === 1) {167+ panning = true;168+ container.classList.add('panning');169+ } else if (pointers.size === 2) {170+ pinchDist = dist();171+ }172+ };173+ const onPointerMove = (e: PointerEvent): void => {174+ const p = pointers.get(e.pointerId);175+ if (!p) return;176+ if (pointers.size === 2) {177+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });178+ const [a, b] = [...pointers.values()];179+ if (!a || !b) return;180+ const d = dist();181+ if (pinchDist > 0 && d > 0) {182+ const rect = container.getBoundingClientRect();183+ zoomAt(184+ (a.x + b.x) / 2 - rect.left,185+ (a.y + b.y) / 2 - rect.top,186+ zoom * (d / pinchDist),187+ );188+ }189+ pinchDist = d;190+ return;191+ }192+ cx -= (e.clientX - p.x) / zoom;193+ cy -= (e.clientY - p.y) / zoom;194+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });195+ userMoved = true;196+ clampCenter();197+ applyCamera();198+ };199+ const onPointerEnd = (e: PointerEvent): void => {200+ pointers.delete(e.pointerId);201+ if (pointers.size < 2) pinchDist = 0;202+ if (pointers.size === 0) {203+ panning = false;204+ container.classList.remove('panning');205+ }206+ };207+ container.addEventListener('wheel', onWheel, { passive: false });208+ container.addEventListener('pointerdown', onPointerDown);209+ container.addEventListener('pointermove', onPointerMove);210+ container.addEventListener('pointerup', onPointerEnd);211+ container.addEventListener('pointercancel', onPointerEnd);212+213+ // refit (default view) or re-clamp (user view) whenever the pane resizes;214+ // also fires when the pane first becomes visible with a real size.215+ const ro = new ResizeObserver(() => {216+ const r = container.getBoundingClientRect();217+ if (r.width === 0 || r.height === 0) return; // pane hidden — keep state218+ hostW = r.width;219+ hostH = r.height;220+ if (userMoved) {221+ zoom = clampZoom(zoom);222+ clampCenter();223+ applyCamera();224+ } else {225+ resetView();226+ }227+ });228+ ro.observe(container);229+230+ // ---- minimap: downscaled snapshot + viewport rectangle -------------------231+ // Hidden while the whole map fits the pane (nothing to orient by).232+ const MM_CSS = 140;233+ const mmDpr = Math.max(1, Math.round(window.devicePixelRatio || 1));234+ const minimap = document.createElement('canvas');235+ minimap.className = 'map-minimap';236+ minimap.width = MM_CSS * mmDpr;237+ minimap.height = MM_CSS * mmDpr;238+ minimap.style.display = 'none';239+ container.append(minimap);240+ const mmctx = ctx2d(minimap);241+242+ function drawMinimap(): void {243+ const fits = zoom <= fitZoom() * 1.001;244+ minimap.style.display = fits ? 'none' : '';245+ if (fits) return;246+ const s = minimap.width / sizePx;247+ mmctx.fillStyle = '#000';248+ mmctx.fillRect(0, 0, minimap.width, minimap.height);249+ mmctx.drawImage(canvas, 0, 0, minimap.width, minimap.height);250+ // dim the whole snapshot, then repaint the viewport region bright251+ const ww = hostW / zoom; // viewport extent in world px252+ const wh = hostH / zoom;253+ const wx = cx - ww / 2;254+ const wy = cy - wh / 2;255+ mmctx.fillStyle = 'rgba(0, 0, 0, 0.45)';256+ mmctx.fillRect(0, 0, minimap.width, minimap.height);257+ mmctx.drawImage(canvas, wx, wy, ww, wh, wx * s, wy * s, ww * s, wh * s);258+ mmctx.strokeStyle = '#e0a458'; // --accent259+ mmctx.lineWidth = mmDpr;260+ mmctx.strokeRect(wx * s + 0.5, wy * s + 0.5, ww * s, wh * s);261+ }262+263+ // click/drag on the minimap recenters the camera264+ let mmDrag = false;265+ const mmRecenter = (e: PointerEvent): void => {266+ const r = minimap.getBoundingClientRect();267+ cx = ((e.clientX - r.left) / r.width) * sizePx;268+ cy = ((e.clientY - r.top) / r.height) * sizePx;269+ userMoved = true;270+ clampCenter();271+ applyCamera();272+ };273+ minimap.addEventListener('pointerdown', (e) => {274+ e.stopPropagation(); // don't start a host pan275+ e.preventDefault();276+ minimap.setPointerCapture(e.pointerId);277+ mmDrag = true;278+ mmRecenter(e);279+ });280+ minimap.addEventListener('pointermove', (e) => {281+ if (mmDrag) mmRecenter(e);282+ });283+ minimap.addEventListener('pointerup', () => { mmDrag = false; });284+ minimap.addEventListener('pointercancel', () => { mmDrag = false; });285+81286 // Terrain layer cache — redrawn only when the terrain string (or atlas82287 // availability) changes.83288 const terrainLayer = document.createElement('canvas');@@ -263,6 +468,8 @@ export function createMapView(
263468 atlas.draw(ctx, 'banner.flag', x + TILE / 2, y - TILE / 2 + 1, TILE * 0.75, color);264469 }265470 }471+472+ drawMinimap(); // keep the corner snapshot in sync with the world266473 }267474 268475 function attachTooltips(getFrame: () => MapFrame | null): { dispose(): void } {@@ -288,10 +495,12 @@ export function createMapView(
288495 289496 const onMove = (e: MouseEvent): void => {290497 const frame = getFrame();291- if (!frame || frame.terrain.length !== mapSize * mapSize) {498+ if (!frame || frame.terrain.length !== mapSize * mapSize || panning) {292499 tip.style.display = 'none';293500 return;294501 }502+ // getBoundingClientRect reflects the camera's CSS transform, so this503+ // screen→tile mapping stays correct at any zoom/pan.295504 const rect = canvas.getBoundingClientRect();296505 const tx = Math.floor(((e.clientX - rect.left) / rect.width) * mapSize);297506 const ty = Math.floor(((e.clientY - rect.top) / rect.height) * mapSize);@@ -352,8 +561,17 @@ export function createMapView(
352561 return {353562 draw,354563 attachTooltips,564+ resetView,355565 dispose() {356566 disposed = true;567+ ro.disconnect();568+ container.removeEventListener('wheel', onWheel);569+ container.removeEventListener('pointerdown', onPointerDown);570+ container.removeEventListener('pointermove', onPointerMove);571+ container.removeEventListener('pointerup', onPointerEnd);572+ container.removeEventListener('pointercancel', onPointerEnd);573+ container.classList.remove('panning');574+ minimap.remove();357575 canvas.remove();358576 },359577 };@@ -730,31 +730,46 @@ canvas.chart { display: block; width: 100%; }
730730 .chart-tip .ct-name { color: var(--text-dim); }731731 732732 /* ------------------------------------------------------------------------733- * map-view (Urizen renderer) — appended by web/src/render/map-view.ts owner.734- * The canvas renders 1:1 at 12px/tile and is scaled purely by CSS here.735- * `.map-host.zoomed` is toggled by the viewer's zoom button.733+ * map-view (Urizen renderer) — owned by web/src/render/map-view.ts.734+ * The canvas renders 1:1 at 12px/tile; the view is a camera (zoom + center)735+ * applied as a CSS transform by map-view.ts inside this clipped host.736+ * Wheel zooms at the cursor, drag pans, the corner minimap navigates.736737 * ---------------------------------------------------------------------- */737738 .map-host {739+ position: relative;738740 background: var(--bg-inset);739741 border: 1px solid var(--border);740742 border-radius: var(--radius);741743 flex: 1;742744 min-height: 0;743745 overflow: hidden;744- /* letterbox: center the square canvas in whatever pane shape we get */745- display: flex;746- align-items: center;747- justify-content: center;746+ cursor: grab;747+ touch-action: none; /* pointer events drive pan/pinch — no native gestures */748+ user-select: none;748749 }750+.map-host.panning { cursor: grabbing; }749751 .map-host canvas.urizen-map {750- display: block;752+ position: absolute;753+ left: 0;754+ top: 0;755+ transform-origin: 0 0; /* map-view.ts sets translate+scale */751756 image-rendering: pixelated;752- /* fit the pane; the element box stays square so hover hit-testing is exact */753- max-width: 100%;754- max-height: 100%;757+ display: block;758+ will-change: transform;759+}760+/* corner minimap: snapshot + viewport rectangle (hidden at zoom-to-fit) */761+.map-host canvas.map-minimap {762+ position: absolute;763+ right: 10px;764+ bottom: 10px;765+ width: 140px;766+ height: 140px;767+ background: #000;768+ border: 1px solid var(--border-bright);769+ box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.6);770+ cursor: crosshair;771+ z-index: 3;755772 }756-.map-host.zoomed { overflow: auto; display: block; }757-.map-host.zoomed canvas.urizen-map { max-width: none; max-height: none; }758773 759774 /* ------- chronicle identity cards (rail content in chronicle mode) ------- */760775 .identity-card .identity-desc {