From 1f5848c5ec3b38ca05acacae9bb6da9578f5518d Mon Sep 17 00:00:00 2001 From: blearymoth <237650870+blearymoth@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:31:16 -0700 Subject: [PATCH] feat: add phone tab-shell for narrow viewports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At ≤720px, restyle existing rails as exclusive bottom sheets driven by a tab bar. Desktop layout stays unchanged; Clean View and Cockpit hide the chrome. Co-authored-by: Cursor --- CHANGELOG.md | 7 + docs/CURRENT-STATE.md | 2 +- index.html | 33 +- src/main.js | 21 +- src/mobileShell.js | 294 +++++++++++++++++ src/mobileShell.test.mjs | 216 +++++++++++++ src/ui.js | 8 + style.css | 673 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 1248 insertions(+), 6 deletions(-) create mode 100644 src/mobileShell.js create mode 100644 src/mobileShell.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa..d095c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +### Added + +- Phone layout shell (`src/mobileShell.js`): at ≤720px, a bottom tab bar opens + Search / Layers / Context / Display / Presets / More as exclusive sheets over + the existing rails. Desktop layout is unchanged; Clean View and Cockpit hide + the tab chrome. + ### Fixed - Mapped-site outages show their scheduled retry countdown and distinguish diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f4d861..fdc1961 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2317,7 +2317,7 @@ silently demoting every later lookup for the session. - Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip. - A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a ` + +
diff --git a/src/main.js b/src/main.js index 84fbf12..21935c8 100644 --- a/src/main.js +++ b/src/main.js @@ -81,6 +81,8 @@ async function init() { const googleApiKey = import.meta.env.GOOGLE_MAPS_API_KEY; if (googleApiKey) window.__GOOGLE_MAPS_API_KEY__ = googleApiKey; + const phoneLayout = window.matchMedia('(max-width: 720px)').matches; + // Create the Cesium viewer with minimal chrome const viewer = new Cesium.Viewer('cesiumContainer', { timeline: false, @@ -108,7 +110,8 @@ async function init() { document.body.appendChild(el); return el; })(), - msaaSamples: 4, + // 4x MSAA is a known WebGL kill on phones; desktop keeps the current look. + msaaSamples: phoneLayout ? 1 : 4, contextOptions: { webgl: { preserveDrawingBuffer: true, @@ -148,10 +151,24 @@ async function init() { loaderStatus.textContent = googleApiKey || cesiumToken ? 'Loading Google 3D Tiles...' : 'Loading the keyless globe...'; - const photoreal = await loadPhotorealisticTileset(Cesium, { + // On phones a hung tileset request used to sit on the splash until the + // browser showed a timeout page — race a bounded deadline against startup. + const createTileset = loadPhotorealisticTileset(Cesium, { googleApiKey, cesiumToken, }); + const photoreal = phoneLayout + ? await Promise.race([ + createTileset, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Google 3D Tiles timed out on this device')), 25_000); + }), + ]).catch((tileError) => ({ + tileset: null, + route: null, + errors: [tileError], + })) + : await createTileset; const tileset = photoreal.tileset; if (tileset) { viewer.scene.primitives.add(tileset); diff --git a/src/mobileShell.js b/src/mobileShell.js new file mode 100644 index 0000000..a5e0b74 --- /dev/null +++ b/src/mobileShell.js @@ -0,0 +1,294 @@ +/** + * Phone tab-shell. Desktop (721px+) never sees this class or sheet state. + * Panel DOM stays in the existing rails; CSS restyles the active sheet. + */ + +export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)'; +export const MOBILE_SHELL_CLASS = 'mobile-shell'; +export const MOBILE_SHEET_ATTR = 'data-mobile-sheet'; + +export const MOBILE_SHEET_PANELS = Object.freeze({ + layers: Object.freeze(['data-panel']), + context: Object.freeze(['global-context-panel']), + display: Object.freeze(['pp-toggles']), + more: Object.freeze(['cctv-panel', 'scene-panel', 'geo-status-panel']), + search: Object.freeze(['location-bar']), + presets: Object.freeze(['control-panel']), +}); + +export const MOBILE_TAB_SHEETS = Object.freeze(['search', 'layers', 'context', 'display', 'presets', 'more']); + +const EXCLUSIVE_BODY_CLASSES = Object.freeze([ + 'cockpit-mode', + 'recording-mode', + 'scene-playback-mode', + 'tour-playback-mode', + 'ui-clean-view', +]); + +/** + * @param {string} panelId + * @returns {string|null} + */ +export function sheetForPanelId(panelId) { + for (const [sheet, ids] of Object.entries(MOBILE_SHEET_PANELS)) { + if (ids.includes(panelId)) return sheet; + } + return null; +} + +/** + * Decide whether a panel collapse/expand should change the open sheet. + * Restored desktop prefs never open a sheet. Missing mobile-shell is a no-op. + * + * @param {object} input + * @param {boolean} input.mobileShellActive + * @param {string|null} input.currentSheet + * @param {string} input.panelId + * @param {boolean} input.collapsed + * @param {boolean} [input.restore=false] + * @param {boolean} [input.explicit=false] + * @returns {{ changed: boolean, sheet: string|null }} + */ +export function nextMobileSheetState({ + mobileShellActive, + currentSheet, + panelId, + collapsed, + restore = false, + explicit = false, +}) { + if (!mobileShellActive) return { changed: false, sheet: currentSheet ?? null }; + if (restore) return { changed: false, sheet: currentSheet ?? null }; + const sheet = sheetForPanelId(panelId); + if (!sheet) return { changed: false, sheet: currentSheet ?? null }; + if (!collapsed) { + return { changed: currentSheet !== sheet, sheet }; + } + if (explicit && currentSheet === sheet) return { changed: true, sheet: null }; + return { changed: false, sheet: currentSheet ?? null }; +} + +/** + * Apply or clear the mobile-shell class from a matchMedia result. + * Crossing 721px always drops the open sheet so desktop rails resume cleanly. + * + * @param {object} input + * @param {boolean} input.matches + * @param {Element|null} input.body + * @returns {boolean} Whether mobile-shell is now active. + */ +export function applyMobileShellMatch({ matches, body }) { + if (!body) return false; + if (matches) { + body.classList.add(MOBILE_SHELL_CLASS); + return true; + } + body.classList.remove(MOBILE_SHELL_CLASS); + body.removeAttribute(MOBILE_SHEET_ATTR); + return false; +} + +function exclusiveSurfaceActive(body) { + return EXCLUSIVE_BODY_CLASSES.some((name) => body.classList.contains(name)); +} + +function currentSheetOf(body) { + const value = body.getAttribute(MOBILE_SHEET_ATTR); + return value && value in MOBILE_SHEET_PANELS ? value : null; +} + +/** + * Sync sheet state from setPanelCollapsed. No-op without body.mobile-shell. + * + * @param {string} panelId + * @param {object} [options] + * @param {boolean} [options.collapsed] + * @param {boolean} [options.restore=false] + * @param {Document} [options.documentRef] + */ +export function syncMobileSheetFromPanel(panelId, { + collapsed, + restore = false, + explicit = false, + documentRef = globalThis.document, +} = {}) { + const body = documentRef?.body; + if (!body?.classList.contains(MOBILE_SHELL_CLASS)) return; + const next = nextMobileSheetState({ + mobileShellActive: true, + currentSheet: currentSheetOf(body), + panelId, + collapsed: Boolean(collapsed), + restore, + explicit, + }); + if (!next.changed) return; + applySheet(body, next.sheet, documentRef); +} + +function resetSearchField(documentRef) { + const input = documentRef.getElementById('location-search'); + const toggle = documentRef.getElementById('search-toggle'); + input?.classList.remove('expanded', 'searching'); + toggle?.classList.remove('is-open'); + toggle?.setAttribute('aria-expanded', 'false'); + if (toggle) toggle.title = 'Search any location'; +} + +function applySheet(body, sheet, documentRef) { + const tabBar = documentRef.getElementById('mobile-tab-bar'); + const backdrop = documentRef.getElementById('mobile-sheet-backdrop'); + const closeBtn = documentRef.getElementById('mobile-modal-close'); + if (sheet) { + body.setAttribute(MOBILE_SHEET_ATTR, sheet); + backdrop?.removeAttribute('hidden'); + closeBtn?.removeAttribute('hidden'); + } else { + body.removeAttribute(MOBILE_SHEET_ATTR); + backdrop?.setAttribute('hidden', ''); + closeBtn?.setAttribute('hidden', ''); + resetSearchField(documentRef); + } + tabBar?.querySelectorAll('[data-mobile-sheet]').forEach((button) => { + const pressed = button.dataset.mobileSheet === sheet; + button.setAttribute('aria-pressed', String(pressed)); + }); +} + +function expandSheetPanels(sheet, setPanelCollapsed, documentRef = globalThis.document) { + for (const panelId of MOBILE_SHEET_PANELS[sheet] || []) { + setPanelCollapsed(panelId, false, { + explicit: true, + persist: false, + syncShare: false, + }); + } + if (sheet === 'search') { + const input = documentRef.getElementById('location-search'); + const toggle = documentRef.getElementById('search-toggle'); + input?.classList.add('expanded'); + toggle?.classList.add('is-open'); + toggle?.setAttribute('aria-expanded', 'true'); + if (toggle) toggle.title = 'Close search'; + } +} + +function collapseSheetPanels(sheet, setPanelCollapsed) { + for (const panelId of MOBILE_SHEET_PANELS[sheet] || []) { + setPanelCollapsed(panelId, true, { + persist: false, + syncShare: false, + }); + } +} + +/** + * Wire the tab bar, matchMedia gate, and exclusive-surface close. + * + * @param {object} input + * @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed + * @param {Document} [input.documentRef] + * @param {(query: string) => MediaQueryList} [input.matchMedia] + * @returns {{ dispose: () => void, setSheet: (sheet: string|null) => void }} + */ +export function initMobileShell({ + setPanelCollapsed, + documentRef = globalThis.document, + matchMedia = globalThis.matchMedia?.bind(globalThis), +} = {}) { + const body = documentRef.body; + const tabBar = documentRef.getElementById('mobile-tab-bar'); + const backdrop = documentRef.getElementById('mobile-sheet-backdrop'); + const removers = []; + + const setSheet = (sheet) => { + if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; + if (exclusiveSurfaceActive(body)) sheet = null; + const previous = currentSheetOf(body); + if (previous && previous !== sheet) { + if (previous === 'search') resetSearchField(documentRef); + collapseSheetPanels(previous, setPanelCollapsed); + } + applySheet(body, sheet, documentRef); + if (sheet) expandSheetPanels(sheet, setPanelCollapsed, documentRef); + }; + + const onMedia = (matches) => { + const active = applyMobileShellMatch({ matches, body }); + if (tabBar) tabBar.setAttribute('aria-hidden', active ? 'false' : 'true'); + if (!active) { + applySheet(body, null, documentRef); + } + }; + + const media = typeof matchMedia === 'function' ? matchMedia(MOBILE_SHELL_MEDIA_QUERY) : null; + onMedia(Boolean(media?.matches)); + const mediaListener = (event) => onMedia(event.matches); + media?.addEventListener?.('change', mediaListener); + if (media && !media.addEventListener && media.addListener) media.addListener(mediaListener); + removers.push(() => { + media?.removeEventListener?.('change', mediaListener); + media?.removeListener?.(mediaListener); + }); + + tabBar?.querySelectorAll('[data-mobile-sheet]').forEach((button) => { + const onClick = (event) => { + event.preventDefault(); + const next = button.dataset.mobileSheet; + setSheet(currentSheetOf(body) === next ? null : next); + }; + button.addEventListener('click', onClick); + removers.push(() => button.removeEventListener('click', onClick)); + }); + + const closeSheet = () => setSheet(null); + backdrop?.addEventListener('click', closeSheet); + removers.push(() => backdrop?.removeEventListener('click', closeSheet)); + + const closeBtn = documentRef.getElementById('mobile-modal-close'); + closeBtn?.addEventListener('click', closeSheet); + removers.push(() => closeBtn?.removeEventListener('click', closeSheet)); + + const onKeydown = (event) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; + if (!currentSheetOf(body)) return; + if (exclusiveSurfaceActive(body)) return; + event.preventDefault(); + closeSheet(); + }; + documentRef.addEventListener('keydown', onKeydown); + removers.push(() => documentRef.removeEventListener('keydown', onKeydown)); + + let touchStartY = null; + const onTouchStart = (event) => { + touchStartY = event.changedTouches?.[0]?.clientY ?? null; + }; + const onTouchEnd = (event) => { + const start = touchStartY; + touchStartY = null; + if (start == null) return; + const endY = event.changedTouches?.[0]?.clientY; + if (endY != null && endY - start > 56) closeSheet(); + }; + backdrop?.addEventListener('touchstart', onTouchStart, { passive: true }); + backdrop?.addEventListener('touchend', onTouchEnd, { passive: true }); + removers.push(() => { + backdrop?.removeEventListener('touchstart', onTouchStart); + backdrop?.removeEventListener('touchend', onTouchEnd); + }); + + const observer = new MutationObserver(() => { + if (exclusiveSurfaceActive(body) && currentSheetOf(body)) closeSheet(); + }); + observer.observe(body, { attributes: true, attributeFilter: ['class'] }); + removers.push(() => observer.disconnect()); + + return { + dispose() { + for (const remove of removers.splice(0)) remove(); + }, + setSheet, + }; +} diff --git a/src/mobileShell.test.mjs b/src/mobileShell.test.mjs new file mode 100644 index 0000000..07ab047 --- /dev/null +++ b/src/mobileShell.test.mjs @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + MOBILE_SHELL_CLASS, + MOBILE_SHELL_MEDIA_QUERY, + MOBILE_SHEET_ATTR, + MOBILE_SHEET_PANELS, + MOBILE_TAB_SHEETS, + applyMobileShellMatch, + nextMobileSheetState, + sheetForPanelId, + syncMobileSheetFromPanel, +} from './mobileShell.js'; + +const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); +const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + +test('tab bar markup maps Search, Layers, Context, Display, Presets, and More', () => { + const match = html.match(/