diff --git a/README.md b/README.md index 1c7d6ee..46ed7fd 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,21 @@ Click the map to give it keyboard focus. | Escape | Leave the cottage or bench while the map has focus | | F / Fullscreen | Fill the browser window at the current map scale; use Exit fullscreen or Escape to return | | + / − or zoom buttons | Zoom the Townmap between 50% and 200% | -| 0 / percentage button | Reset to the initial map scale (100%) | +| Town / 0 / percentage button | Compact view at one CSS pixel per artwork pixel (100%) | +| Walk | A closer, crisp 2× view centred on Jack, without moving him | +| V | Switch between Town and Walk while the map has focus | | Overview | Frame working and blocked cottages, including active roommates and shed residents | | Evidence / Close | Show or hide the shared inspector in any view | | Click a cottage or its roster button | Open its inspector without walking there | +| Hover a cottage | Read its name, status, harness and current work without enlarging the map | | Enter cottage / Leave cottage | Visit or exit using buttons | | Click a resident / Talk to agent | Open their conversation and recorded activity | | Click a room object or inspector tab | Read the request, clock, journal, to-do list, PR desk, or shelves | | Click a parcel | Open its PR when a URL is available | +Town starts at 100% on every screen: a cottage is 54 × 66 CSS pixels. Walk uses +200% for a closer view. These scales stay the same when the window, evidence +drawer, or fleet size changes; they are not a percentage of the initial panel width. Fullscreen fills the browser window and shows more map without enlarging cottages. Browser tabs stay visible; it never requests native fullscreen or a macOS space. Details float on @@ -78,7 +84,7 @@ blocked residents, including roommates and children. Existing plots stay fixed through feed updates, fullscreen and resizing; arrivals fill spare lots or add an annex. Overview changes the camera, with the Details panel taken into account, and falls back to all visible cottages when none are active. It respects the -current filters and the 50% zoom floor, so very large fleets still need scrolling. +current filters and can fit below the ordinary 50% manual zoom floor. Branch labels express grouping, not inferred Git ancestry. Rooms and residents are generated from cottage and task identity. Return visits keep the furniture in place. A new explicit task gets a new home. HubTown sets the cozy base with switchboards and pigeonholes. AppTown fills its desks with computers, phones and charging leads. MemTown lines its walls with bookshelves. VaultTown is a clock-and-lock repair shop, with pendulums, key racks and scattered repair tools. Unknown projects get neutral homes. diff --git a/scripts/browser-viewport-smoke.mjs b/scripts/browser-viewport-smoke.mjs index e5de277..557fa98 100644 --- a/scripts/browser-viewport-smoke.mjs +++ b/scripts/browser-viewport-smoke.mjs @@ -45,6 +45,17 @@ try { // where a former fixed scroll offset would legitimately clamp at the edge. await evaluate('window.originalPanel=document.getElementById("panel"); window.nativeCalls=[]; document.getElementById("map-shell").requestFullscreen=()=>{nativeCalls.push("request");return Promise.resolve();}; document.exitFullscreen=()=>{nativeCalls.push("exit");return Promise.resolve();}; window.originalWindow=[innerWidth,innerHeight]; const v=document.getElementById("map-viewport"); v.scrollTop=Math.max(0,(window.cottageState().viewport.geometry.mapHeight-v.clientHeight)/2);'); const normal = await state(); + near(normal.viewport.scale,1,'Native Town scale',1e-8); + const beforeWalk=await evaluate('window.cottageState().scene.player'); + await click('#camera-walk'); + near((await state()).viewport.scale,2,'Walk scale',1e-8); + assert.deepEqual(await evaluate('window.cottageState().scene.player'),beforeWalk,'Walk frames Jack without teleporting him'); + assert.equal(await evaluate('document.getElementById("camera-walk").getAttribute("aria-pressed")'),'true'); + await key('v'); + near((await state()).viewport.scale,1,'V returns to Town scale',1e-8); + // Restore the original center before exercising fullscreen's preservation. + await evaluate(`document.getElementById('map-viewport').scrollTop=${normal.viewport.center.y}-document.getElementById('map-viewport').clientHeight/2`); + pass('Town and Walk use native and double pixels without moving Jack'); await click('#fullscreen-toggle'); await until('window.cottageState().display.fullscreen','In-window fullscreen did not open'); const full = await state(); @@ -164,6 +175,7 @@ try { frame.remove();return result; })()`); near(result.scale,result.initial,'Responsive fullscreen scale',1e-8); + near(result.initial,1,'Responsive startup uses native pixels',1e-8); assert.equal(result.overflow,false); assert.equal(result.closeVisible,true); assert.ok(result.panel.left>=0 && result.panel.right<=result.width); assert.ok(result.panel.top>=0 && result.panel.bottom<=result.height); diff --git a/src/map-display.mjs b/src/map-display.mjs index 502a2be..9f1022f 100644 --- a/src/map-display.mjs +++ b/src/map-display.mjs @@ -2,13 +2,14 @@ import {fullscreenButtonCopy} from './fullscreen.mjs'; import {nextZoom, zoomKey, ZOOM_LEVELS} from './viewport.mjs'; /** Fullscreen and panel chrome live outside the inspector's streaming content. */ -export function createMapDisplay({camera, canvas, getMode, onOverview, getWorkspaceView=()=> 'town', focusWorkspace}) { +export function createMapDisplay({camera, canvas, getMode, getWalkPoint, onOverview, getWorkspaceView=()=> 'town', focusWorkspace}) { const $ = id => document.getElementById(id); const root = $('map-shell'), viewport = $('map-viewport'), details = $('details-shell'); const panel = $('panel'); const outside = [...root.parentElement.children].filter(element => element !== root); const fullscreen = $('fullscreen-toggle'), toggle = $('details-toggle'), close = $('details-close'); const zoomControls = $('map-zoom'), minus = $('zoom-out'), plus = $('zoom-in'), reset = $('zoom-reset'); + const townView = $('camera-town'), walkView = $('camera-walk'); let active = false, detailsOpen = true, pageScroll = null; let frame = 0; let fullscreenReading = null; @@ -70,12 +71,23 @@ export function createMapDisplay({camera, canvas, getMode, onOverview, getWorksp const zoom = camera.state.zoom; zoomControls.hidden = getMode() === 'room'; minus.disabled = zoom <= ZOOM_LEVELS[0]; plus.disabled = zoom === ZOOM_LEVELS.at(-1); - reset.textContent = zoom + '%'; reset.setAttribute('aria-label', 'Map zoom ' + zoom + '%. Reset to 100%'); + reset.textContent = zoom + '%'; reset.setAttribute('aria-label', 'Map zoom ' + zoom + '%. Reset to Town view'); + townView.setAttribute('aria-pressed', String(camera.state.view === 'town')); + walkView.setAttribute('aria-pressed', String(camera.state.view === 'walk')); + } + function changeView(view) { + camera.setView(view, view === 'walk' ? getWalkPoint?.() : undefined); + syncZoom(); + $('overview-status').textContent = view === 'walk' ? 'Walk view. Camera follows Jack at 200%.' : 'Town view at 100%. Hover cottages for details.'; } function changeZoom(action) { + if (action === 'view') { changeView(camera.state.view === 'walk' ? 'town' : 'walk'); return; } camera.setZoom(action === 'reset' ? 100 : nextZoom(camera.state.zoom, action === 'in' ? 1 : -1)); syncZoom(); } + for (const [button, view] of [[townView, 'town'], [walkView, 'walk']]) { + button.addEventListener('click', () => { changeView(view); focusMap(); }); + } for (const [button, action] of [[minus, 'out'], [plus, 'in'], [reset, 'reset']]) { button.addEventListener('click', () => { changeZoom(action); focusMap(); }); } diff --git a/src/observatory.mjs b/src/observatory.mjs index 9f02ac5..2828c2d 100644 --- a/src/observatory.mjs +++ b/src/observatory.mjs @@ -783,7 +783,7 @@ export function createObservatory(api){ } if(followId)hint('On the bench with '+(byId(followId)?.name||'your agent')+' · Move or press Escape to leave'); else if(mode==='town'&&gramophone&&distance(player,gramophone)<24)hint('E · '+(extras.state.music.enabled?'Stop':'Play')+' the village gramophone'); - else if(mode==='town')hint('Arrow keys / WASD to walk · E to talk · + / − zoom · 0 reset · F fullscreen · Click to inspect'); + else if(mode==='town')hint('WASD / arrows to walk · E to talk · V Town / Walk · + / − zoom · 0 Town · F fullscreen · Hover or click cottages'); } function draw(time){ const dt=Math.min(.05,lastFrame?time-lastFrame:1/60);lastFrame=time; diff --git a/src/town-layout.mjs b/src/town-layout.mjs index 4df359d..c46b134 100644 --- a/src/town-layout.mjs +++ b/src/town-layout.mjs @@ -1,11 +1,10 @@ -/** The old two-district width remains the reference for 100% cottage scale. */ +/** Minimum packing width; camera scale is independent of district layout. */ export const MAP_REFERENCE_WIDTH = 768; /** Choose a packing budget once. Floating Details must not permanently narrow the world. */ -export function townWidthBudget({windowWidth, standardWidth}) { - const scale = Math.max(1, standardWidth) / MAP_REFERENCE_WIDTH; +export function townWidthBudget({windowWidth}) { const clearWidth = windowWidth - 24; - return Math.round(Math.max(MAP_REFERENCE_WIDTH, Math.min(1920, clearWidth / scale))); + return Math.round(Math.max(MAP_REFERENCE_WIDTH, Math.min(1920, clearWidth))); } const townOf = agent => agent.town || agent.role || 'WildTown'; diff --git a/src/town.html b/src/town.html index 098c0ae..35cd6d4 100644 --- a/src/town.html +++ b/src/town.html @@ -115,13 +115,19 @@ #room-canvas{position:absolute;top:0;left:0;width:100%;height:100%;background:#171e22} .map-viewport.is-indoors{overflow:hidden} .map-viewport.is-indoors .map-surface{display:none} - .map-zoom{display:inline-flex;gap:2px;align-items:center} + .map-zoom{display:inline-flex;gap:2px;align-items:center;flex-wrap:wrap} .map-zoom button{min-width:34px;padding:8px} + #camera-walk{margin-right:6px} #zoom-reset{min-width:62px;font-variant-numeric:tabular-nums} .details-shell{min-width:0;min-height:0} .details-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border:1px solid #44524f;border-bottom:0;background:#273238} .split.details-closed{grid-template-columns:minmax(0,1fr)} #perspective-stage{position:relative;min-width:0;min-height:0} + .cottage-peek{position:absolute;z-index:5;width:270px;max-width:calc(100% - 16px);padding:9px 11px;border:1px solid #9eaa83;border-radius:4px;background:#202e2bf5;box-shadow:0 4px 16px #101b2255;pointer-events:none;font:12px/1.4 ui-sans-serif,system-ui;color:#e4ebe2;overflow-wrap:anywhere} + .cottage-peek strong,.cottage-peek span{display:block} + .cottage-peek strong{color:#e8d39b;font-size:13px} + .cottage-peek span{color:#b5c6b8;margin-top:2px} + .cottage-peek p{margin:5px 0 0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} #command-view{position:absolute;inset:0;overflow:auto;outline:none;border-radius:3px} #command-view[hidden],.details-shell[hidden]{display:none!important} .split.is-fullscreen{position:fixed;inset:0;width:100%;height:100dvh;max-width:none;display:block;overflow:hidden;z-index:100;background:#171d23} @@ -262,9 +268,11 @@

Sound off
+ + - +
@@ -291,9 +299,10 @@

-
+
+

Arrow keys / WASD to walk · Walk into doors · E to talk · F for fullscreen · Click to inspect

diff --git a/src/town.mjs b/src/town.mjs index b2f8fba..26fd59c 100644 --- a/src/town.mjs +++ b/src/town.mjs @@ -580,10 +580,11 @@ cv.width = W; cv.height = 0; ctx.imageSmoothingEnabled = false; let mapDisplay; const mapViewport = createMapViewport({canvas:cv,viewport:document.getElementById('map-viewport'), - surface:document.getElementById('map-surface'),referenceWidth:MAP_REFERENCE_WIDTH,getObscured:()=>mapDisplay?.obscured()||{}}); -const packingWidth=townWidthBudget({windowWidth:window.innerWidth,standardWidth:document.getElementById('map-viewport').clientWidth}); + surface:document.getElementById('map-surface'),getObscured:()=>mapDisplay?.obscured()||{}}); +const packingWidth=townWidthBudget({windowWidth:window.innerWidth}); stableLayout=createTownLayout({maxWidth:packingWidth}); mapDisplay = createMapDisplay({camera:mapViewport,canvas:cv,getMode:()=>observatory?.mode||'town', + getWalkPoint:()=>observatory?.player, getWorkspaceView:()=>commandCenter?.state.view||'town', focusWorkspace:()=>document.getElementById('command-view').focus({preventScroll:true}),onOverview:()=>{ observatory?.unfollow(); @@ -1750,7 +1751,7 @@ function draw(){ t = performance.now()/1000; frameDt=Math.min(.05,lastDrawAt?t-lastDrawAt:1/60);lastDrawAt=t; // Clock labels age independently from feed polling, including a paused feed. - if(t-lastCommandClock>=1){lastCommandClock=t;updateCommandCenter();} + if(t-lastCommandClock>=1){lastCommandClock=t;updateCommandCenter();if(!cottagePeek.hidden)renderCottagePeek();} const villageLight=observatory?.lightAt(t); bedtimeFrames=bedtime.update(plots,villageLight,{time:t,reduce,present:agents,source:layoutSource||feedNamespace(ENDPOINT,builtInDemo)}); const bedtimeLabel=villageLifeLabel(villageLight,bedtimeFrames); @@ -1975,8 +1976,35 @@ function openBest(ag){ a.click(); } } -cv.addEventListener("mousemove", e=>{ hoverId = hit(e); cv.style.cursor = hoverId||hitTrade(e) ? "pointer":"default"; }); -cv.addEventListener("mouseleave", ()=>{ hoverId = null; }); +// Readable details belong to the interface, not to enlarged artwork pixels. +const cottagePeek=document.getElementById('cottage-peek'); +const perspectiveStage=document.getElementById('perspective-stage'); +function hideCottagePeek(){cottagePeek.hidden=true;cv.removeAttribute('aria-describedby');} +function renderCottagePeek(){ + const agent=agents.find(agent=>agent.id===hoverId); + if(!agent||observatory?.mode==='room'){hideCottagePeek();return;} + cottagePeek.querySelector('strong').textContent=agent.name||agent.id; + cottagePeek.querySelector('span').textContent=[townKey(agent),STATUS_TEXT[agent.status]||'unknown', + agent.harness||agent.model,feedStale?'feed stale':null].filter(Boolean).join(' · '); + cottagePeek.querySelector('p').textContent=agent.activity||agent.task||agent.branch||'Click to inspect this cottage'; + cottagePeek.hidden=false;cv.setAttribute('aria-describedby','cottage-peek'); +} +cv.addEventListener("mousemove", e=>{ + hoverId=hit(e);cv.style.cursor=hoverId||hitTrade(e)?'pointer':'default'; + renderCottagePeek(); + if(cottagePeek.hidden)return; + const stage=perspectiveStage.getBoundingClientRect(),view=document.getElementById('map-viewport').getBoundingClientRect(); + const w=cottagePeek.offsetWidth,h=cottagePeek.offsetHeight; + const left=Math.max(8,Math.min(e.clientX-stage.left+14,stage.width-w-8)); + const below=e.clientY-stage.top+18; + const top=below+h<=view.bottom-stage.top-8?below:e.clientY-stage.top-h-12; + cottagePeek.style.left=left+'px';cottagePeek.style.top=Math.max(view.top-stage.top+8,top)+'px'; +}); +cv.addEventListener("mouseleave", ()=>{hoverId=null;hideCottagePeek();}); +document.getElementById('map-shell').addEventListener('click',hideCottagePeek,true); +document.getElementById('map-shell').addEventListener('keydown',hideCottagePeek,true); +document.getElementById('map-shell').addEventListener('scroll',hideCottagePeek,true); +window.addEventListener('resize',hideCottagePeek); cv.addEventListener("click", e=>{ const site=hitTrade(e);if(site){commandCenter?.inspectTrade(site);return;} if(observatory?.handleClick(e))return; diff --git a/src/viewport.mjs b/src/viewport.mjs index 60c293a..e684308 100644 --- a/src/viewport.mjs +++ b/src/viewport.mjs @@ -1,5 +1,6 @@ /** The town's world pixels and the viewport's CSS pixels are independent. */ export const ZOOM_LEVELS = Object.freeze([50, 75, 100, 125, 150, 200]); +export const CAMERA_VIEWS = Object.freeze({town: 100, walk: 200}); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); export function nextZoom(current, direction) { @@ -24,6 +25,7 @@ export function zoomKey(event) { if (event.key === '+' || event.key === '=') return 'in'; if (event.key === '-') return 'out'; if (event.key === '0') return 'reset'; + if (event.key?.toLowerCase() === 'v') return 'view'; return null; } @@ -52,10 +54,11 @@ export function scrollForPoint(point, geometry, target = {x: geometry.width / 2, top: clamp(point.y * geometry.scale + geometry.y - target.y, 0, geometry.contentHeight - geometry.height)}; } -export function createMapViewport({viewport, surface, canvas, referenceWidth, getObscured = () => ({})}) { - // Remember the normal layout even if fullscreen is entered while the feed is still loading. - const initialWidth = viewport.clientWidth; - let worldWidth = 0, worldHeight = 0, baseScale = null, zoom = 100, geometry = null; +export function createMapViewport({viewport, surface, canvas, getObscured = () => ({})}) { + // 100% means one artwork pixel per CSS pixel, independent of startup width, + // display density, fullscreen, and how many districts the feed discovers. + const baseScale = 1; + let worldWidth = 0, worldHeight = 0, zoom = CAMERA_VIEWS.town, geometry = null; let indoors = false, pendingCenter = null; const scroll = () => ({left: viewport.scrollLeft, top: viewport.scrollTop}); const center = () => geometry && worldPoint({x: geometry.width / 2, y: geometry.height / 2}, geometry, scroll()); @@ -65,7 +68,6 @@ export function createMapViewport({viewport, surface, canvas, referenceWidth, ge function resize() { if (indoors || !worldWidth || !worldHeight || !viewport.clientWidth || !viewport.clientHeight) return; const anchor = pendingCenter || center(); - if (baseScale === null) baseScale = (initialWidth || viewport.clientWidth) / (referenceWidth || worldWidth); const next = viewportGeometry({worldWidth, worldHeight, width: viewport.clientWidth, height: viewport.clientHeight, scale: baseScale * zoom / 100, ...getObscured()}); if (!pendingCenter && geometry && Object.keys(next).every(key => next[key] === geometry[key])) return; @@ -94,8 +96,17 @@ export function createMapViewport({viewport, surface, canvas, referenceWidth, ge const target = {x: clamp(p.x, mx, width - mx), y: clamp(p.y, my, height - my)}; if (target.x !== p.x || target.y !== p.y) viewport.scrollTo({...scrollForPoint(point, geometry, target), behavior: 'instant'}); } + function setZoom(value) { + if (indoors || !ZOOM_LEVELS.includes(value) || value === zoom) return; + capture(); zoom = value; resize(); + } return { - capture, resize, focus, ensureVisible, + capture, resize, focus, ensureVisible, setZoom, + setView(view, point) { + if (indoors || !Object.hasOwn(CAMERA_VIEWS, view)) return; + setZoom(CAMERA_VIEWS[view]); + if (point) focus(point, true); + }, overview(bounds) { if (!bounds || indoors) return null; resize();if (!geometry) return null; @@ -118,16 +129,13 @@ export function createMapViewport({viewport, surface, canvas, referenceWidth, ge if (value) viewport.scrollTo({left: 0, top: 0, behavior: 'instant'}); else resize(); }, - setZoom(value) { - if (indoors || !ZOOM_LEVELS.includes(value) || value === zoom) return; - capture(); zoom = value; resize(); - }, pointAtEvent(event) { const rect = canvas.getBoundingClientRect(); return {x: (event.clientX - rect.left) * canvas.width / rect.width, y: (event.clientY - rect.top) * canvas.height / rect.height}; }, - get state() { return {zoom, baseScale, scale: baseScale === null ? null : baseScale * zoom / 100, + get state() { return {zoom, baseScale, scale: geometry ? baseScale * zoom / 100 : null, + view: Object.keys(CAMERA_VIEWS).find(view => CAMERA_VIEWS[view] === zoom) || 'custom', center: indoors ? pendingCenter : center(), indoors, geometry: geometry && {...geometry}}; }, }; } diff --git a/test/town-layout.test.mjs b/test/town-layout.test.mjs index 6fe330b..6ecf57c 100644 --- a/test/town-layout.test.mjs +++ b/test/town-layout.test.mjs @@ -51,10 +51,12 @@ test('the noticeboard square remains reachable when the original busy town empti assert.deepEqual(positions(after)['AppTown-0'],positions(first)['AppTown-0']); }); -test('width budget uses the normal cottage scale and full scene width once', () => { - assert.equal(townWidthBudget({windowWidth:2077,standardWidth:1084}),1455); - assert.equal(townWidthBudget({windowWidth:390,standardWidth:340}),827); +test('width budget uses native pixels and full scene width once, independent of panel width', () => { + assert.equal(townWidthBudget({windowWidth:2077,standardWidth:1084}),1920); + assert.equal(townWidthBudget({windowWidth:390,standardWidth:340}),768); assert.equal(townWidthBudget({windowWidth:8000,standardWidth:800}),1920); + assert.equal(townWidthBudget({windowWidth:1422,standardWidth:1007}),1398); + assert.equal(townWidthBudget({windowWidth:1422,standardWidth:340}),1398); }); test('a desktop fleet can place large districts abreast without shrinking cottages',()=>{ diff --git a/test/viewport.test.mjs b/test/viewport.test.mjs index 514bec8..9fa3cb3 100644 --- a/test/viewport.test.mjs +++ b/test/viewport.test.mjs @@ -68,12 +68,13 @@ test('zoom steps stop at the limits and include the default scale', () => { test('map zoom shortcuts leave typing, browser zoom, composition, and held keys alone', () => { for (const key of ['+','=']) assert.equal(zoomKey({key}), 'in'); assert.equal(zoomKey({key:'-'}),'out'); assert.equal(zoomKey({key:'0'}),'reset'); + assert.equal(zoomKey({key:'v'}),'view'); assert.equal(zoomKey({key:'V'}),'view'); assert.equal(zoomKey({key:'f'}),null); for (const modifier of ['ctrlKey','metaKey','altKey','isComposing','repeat']) assert.equal(zoomKey({key:'+', [modifier]:true}),null); assert.equal(zoomKey({key:'-',target:{closest:()=>({})}}),null); }); -test('camera retains its initial scale through delayed feeds, room visits, and world growth', t => { +test('camera retains its native scale through delayed feeds, room visits, and world growth', t => { const original = globalThis.ResizeObserver; globalThis.ResizeObserver = class {observe() {}}; t.after(() => {if(original)globalThis.ResizeObserver=original;else delete globalThis.ResizeObserver;}); @@ -82,7 +83,7 @@ test('camera retains its initial scale through delayed feeds, room visits, and w const camera = createMapViewport({viewport,surface:{style:{}},canvas:{style:{}}}); viewport.clientWidth=2077;viewport.clientHeight=1100; // Fullscreen before the first snapshot arrives. camera.setWorld(768,2400); - near(camera.state.scale,1084/768); + near(camera.state.scale,1); viewport.scrollTop=900; const center=camera.state.center; camera.setZoom(150); @@ -94,11 +95,48 @@ test('camera retains its initial scale through delayed feeds, room visits, and w camera.setWorld(768,3200); camera.setIndoors(false); near(camera.state.center.x,center.x);near(camera.state.center.y,center.y); - near(camera.state.scale,1084/768*1.5); + near(camera.state.scale,1.5); camera.setWorld(768,3600); near(camera.state.center.y,center.y); }); +test('100% is one CSS pixel per world pixel at every startup width, even when initially hidden', t => { + const original=globalThis.ResizeObserver; + globalThis.ResizeObserver=class{observe(){}}; + t.after(()=>{if(original)globalThis.ResizeObserver=original;else delete globalThis.ResizeObserver;}); + for(const initialWidth of [0,340,1007,2077]){ + const viewport={clientWidth:initialWidth,clientHeight:520,scrollLeft:0,scrollTop:0,classList:{toggle(){}}, + scrollTo({left=0,top=0}){this.scrollLeft=left;this.scrollTop=top;}}; + const canvas={width:1600,height:2400,style:{},getBoundingClientRect(){return { + left:35-viewport.scrollLeft,top:200-viewport.scrollTop, + width:parseFloat(this.style.width),height:parseFloat(this.style.height)};}}; + const camera=createMapViewport({viewport,surface:{style:{}},canvas}); + assert.equal(camera.state.scale,null,'A camera without geometry is not ready'); + camera.setWorld(1600,2400); + viewport.clientWidth=1007;camera.resize(); + assert.equal(camera.state.scale,1);assert.equal(canvas.style.width,'1600px'); + camera.focus({x:800,y:1200},true); + const center=camera.state.center; + camera.setView('walk'); + assert.equal(camera.state.scale,2);assert.equal(camera.state.view,'walk'); + assert.deepEqual(camera.state.center,center); + // DOM hit targets still refer to the same artwork coordinates after zooming. + for(const view of ['town','walk']){ + camera.setView(view); + const rect=canvas.getBoundingClientRect(),point={x:540,y:660},scale=camera.state.scale; + assert.deepEqual(camera.pointAtEvent({clientX:rect.left+point.x*scale,clientY:rect.top+point.y*scale}),point); + } + const jack={x:720,y:980}; + camera.setView('walk',jack); + const screen=screenPoint(jack,camera.state.geometry,{left:viewport.scrollLeft,top:viewport.scrollTop}); + near(screen.x,viewport.clientWidth/2);near(screen.y,viewport.clientHeight*.45); + assert.deepEqual(jack,{x:720,y:980},'Framing Jack must not move him'); + camera.setIndoors(true);camera.setView('town');assert.equal(camera.state.view,'walk'); + camera.setIndoors(false);camera.setView('town');assert.equal(camera.state.scale,1); + camera.setZoom(125);assert.equal(camera.state.view,'custom'); + } +}); + test('Overview fits active bounds around desktop and mobile details', () => { const bounds={x:0,y:0,w:1000,h:500}; assert.equal(overviewZoom(bounds,{width:1100,height:700,baseScale:1}),100);