// mobile.mjs — the viewer on a phone screen: controls fit without overlapping, a tap opens the inspector sheet, // the ☰ menu holds the layer picker, map buttons are finger-sized. SHOTS= (default /tmp): screenshots. // LANDSCAPE=1: the phone on its side. const dir = process.env.SHOTS || '/tmp'; const land = !!process.env.LANDSCAPE; const tag = land ? 'landscape' : 'portrait'; export const viewport = land ? { width: 844, height: 390 } : { width: 390, height: 844 }; const overlap = `const vis = id => { const e = document.getElementById(id); if (!e || e.hidden) return null; const b = e.getBoundingClientRect(); return b.width && b.height && getComputedStyle(e).display !== 'none' ? b : null; }; const ids = ['toolbar', 'panel', 'nav', 'status', 'legend', 'exag-box', 'skybar'].filter(vis); const bad = []; for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) { const a = vis(ids[i]), b = vis(ids[j]); if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) bad.push(ids[i] + 'x' + ids[j]); } const off = ids.filter(id => { const b = vis(id); return b.left < 0 || b.right > innerWidth + 1 || b.top < 0 || b.bottom > innerHeight + 1; }); const covered = ids.reduce((s, id) => { const b = vis(id); return s + b.width * b.height; }, 0) / (innerWidth * innerHeight); return { bad, off, covered: +covered.toFixed(2) };`; const tap = `const c = document.querySelector('#map canvas'), b = c.getBoundingClientRect(), x = b.left + b.width * 0.3, y = b.top + b.height * 0.35; for (const t of ['pointerdown', 'pointerup']) c.dispatchEvent(new PointerEvent(t, { clientX: x, clientY: y, bubbles: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0 })); c.dispatchEvent(new MouseEvent('click', { clientX: x, clientY: y, bubbles: true })); await new Promise(r => setTimeout(r, 1500));`; export const steps = [ { name: 'boots on a phone', wait: 5000, expect: v => v.ok && !v.toast && v.compact, js: `return { ok: !!window.mapViewer?.texture, toast: document.getElementById('toast').textContent, compact: document.body.classList.contains('compact') };` }, { name: `${tag}: controls do not overlap or leave the screen, the map stays visible`, shot: `${dir}/phone-${tag}.png`, expect: v => !v.bad.length && !v.off.length && v.covered < 0.35, js: overlap }, { name: 'tap the map: the inspector shows', shot: `${dir}/phone-${tag}-inspect.png`, expect: v => v.visible, js: `${tap} const s = document.getElementById('tab-inspector'), r = s.getBoundingClientRect(); return { visible: !s.hidden && r.height > 40 && r.top < innerHeight - 40, text: s.textContent.slice(0, 40) };` }, { name: 'panel open: still no overlaps', expect: v => !v.bad.length && !v.off.length, js: overlap }, { name: 'the menu holds the layer picker and fits the screen', shot: `${dir}/phone-${tag}-menu.png`, expect: v => v.layer && v.fits, js: `document.getElementById('menu-toggle').click(); await new Promise(r => setTimeout(r, 300)); const l = document.getElementById('layer').getBoundingClientRect(), t = document.getElementById('toolbar').getBoundingClientRect(); return { layer: l.width > 150 && l.height >= 36, fits: t.bottom <= innerHeight };` }, { name: 'a tap on the map closes the menu', expect: v => v === false, js: `const c = document.querySelector('#map canvas'); c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 200, clientY: 300, bubbles: true, pointerId: 2, pointerType: 'touch' })); c.dispatchEvent(new PointerEvent('pointerup', { clientX: 200, clientY: 300, bubbles: true, pointerId: 2, pointerType: 'touch' })); return document.body.classList.contains('menu-open');` }, { name: 'layer textures fit a phone GPU (≤ 4096 px wide)', expect: v => v > 0 && v <= 4096, js: `return window.mapViewer.texture?.image?.width ?? 0;` }, { name: 'map buttons are finger-sized', expect: v => v.length >= 4 && v.every(b => b.w >= 40 && b.h >= 40), js: `return [...document.querySelectorAll('#nav button')].filter(b => b.offsetParent).map(b => ({ w: b.offsetWidth, h: b.offsetHeight }));` }, ];