From 8d454bf7f189bbd1264569a6b024f907a89c6017 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 23 Oct 2026 15:05:23 -0700 Subject: [PATCH] fix: improve mobile contact selection and context drawers (#1338) Improve coarse-pointer contact selection, reveal tracked aircraft through Contacts, support downward drawer dismissal, and enlarge the mobile logo. --- changelog.d/1338-mobile-contact-context.md | 4 + docs/CURRENT-STATE.md | 17 +- scripts/package-boundaries.json | 1 + src/data/aisLiveVessels.test.mjs | 28 ++ src/data/inputOwnership.test.mjs | 5 +- src/data/pickRegistry.js | 35 ++ src/data/pickRegistry.test.mjs | 120 +++++++ src/layers/flights/tracking.js | 6 +- src/layers/military/tracking.js | 6 +- src/layers/vessels/selection.js | 6 +- src/ui/applicationShell.js | 29 ++ src/ui/mobileShell.js | 120 +++++++ src/ui/mobileShell.test.mjs | 388 ++++++++++++++++++++- src/ui/styles/mobile-shell.css | 6 +- 14 files changed, 759 insertions(+), 12 deletions(-) create mode 100644 changelog.d/1338-mobile-contact-context.md diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md new file mode 100644 index 000000000..e240dc82a --- /dev/null +++ b/changelog.d/1338-mobile-contact-context.md @@ -0,0 +1,4 @@ +- Improve touch selection of aircraft and vessels through the shared contact + picker, reveal tracked aircraft details when opening Context, support downward + swipe dismissal of phone drawers while preserving scrolling and controls, and + enlarge the phone logo (samehkhamis, #1338). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 994f9a623..b0b696b96 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1399,11 +1399,26 @@ shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below), Cameras (CCTV and Street Level), Context (Global Context, plus Weather and Recent Imagery when shown), Search (Location) and More (Visual Presets and Map Source above Display). A tab opens one bottom sheet; the same tab, the backdrop -or Escape closes it, and focus moves into the sheet and back to its tab. Panels +or Escape closes it, and focus moves into the sheet and back to its tab. A +downward swipe of at least 64px on non-control sheet content also closes it, +when every scroll container under the finger starts at the top. Ordinary +vertical scrolling, horizontal gestures, controls and multitouch stay native; +a cancelled gesture or a sheet/viewport change cannot dismiss another sheet. Panels are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock as the sheet, and the dock's Location and Presets trays render inline. The voice control floats above the tab bar while no sheet is open. +The phone logo is 58px wide (about 39px high), aligned with the top controls. +The shared contact picker preserves exact picks. For a coarse primary pointer +that misses onto the map surface, it searches at most eight hits in a 14px square +and accepts the first registered layer hit, stopping at non-map objects. Civil +and military aircraft and vessels share this rule; fine pointers stay exact. + +Manually opening Context with a tracked civilian or military aircraft enters +the existing Contacts mode when Context is off. Contacts adopts that aircraft +without restarting its tracking; an active or entering Space Missions mode is +left alone. Programmatic sheet changes and closing the tab do not activate a mode. + Sheets borrow panels. Opening one expands its panels without persisting them or claiming a user choice, and share links keep reporting each borrowed panel's state from before the sheet; closing puts back what opening changed, while a diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4a756f656..3970a1ee2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3672,6 +3672,7 @@ "src/data/labelArbiter.js", "src/data/meshFloorSampler.js", "src/data/pickRegistry.js", + "src/data/scenePick.js", "src/data/spriteOrder.js", "src/data/terrainHeights.js", "src/data/transit.js", diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index b5a2c8de9..96a5cd105 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', ( } }); +test('vessel touch interaction yields to the first nearby flight behind map tiles', () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: () => ({ matches: true }), + }); + registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3'); + const tile = { primitive: { isCesium3DTileset: true } }; + const harness = installWireHarness(tile); + let drills = 0; + harness.viewer.scene.drillPick = (_position, limit, width, height) => { + drills++; + assert.deepEqual([limit, width, height], [8, 14, 14]); + return [tile, { id: 'a1b2c3' }]; + }; + try { + harness.handler.click({ position: { x: 10, y: 20 } }); + assert.equal(drills, 1); + assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal(harness.trail.clearCalls, 0); + } finally { + harness.cleanup(); + unregisterPickOwner('flights'); + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + } +}); + test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => { registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3'); const harness = installWireHarness({ id: 'atx-cam-3' }); diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 9b20f3ad4..718ef7068 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => { 'src/layers/submarineCables/interaction.js', 'viewer.scene.pick(click.position)', ], - ['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'], + [ + 'src/layers/vessels/selection.js', + 'pickContact(viewer.scene, click.position)', + ], ]; for (const [file, firstPick] of guarded) { const source = read(file); diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index e54ef6b29..ef3d8074e 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -11,6 +11,8 @@ * that belong to someone else and leave them alone. */ +import { isSurfacePick } from './scenePick.js'; + /** @type {Map boolean>} layerId -> ownership predicate */ const _owners = new Map(); @@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) { } return false; } + +/** + * Resolve a moving-contact click without letting map tiles hide a nearby touch + * target. Exact picks always win; only a coarse primary pointer may expand a + * surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers + * do not each choose their own contact under one tap. + * Unowned non-surface objects remain barriers, not permission to pick through. + * + * @param {object} scene Cesium scene with pick and optional drillPick. + * @param {{x: number, y: number}} position Screen position in CSS pixels. + * @returns {object|null|undefined} Original pick or the first registered touch hit. + */ +export function pickContact(scene, position) { + const picked = scene.pick(position); + if ( + !globalThis.matchMedia?.('(pointer: coarse)').matches || + findPickOwner(resolvePickId(picked)) || + !isSurfacePick(picked) || + typeof scene.drillPick !== 'function' + ) + return picked; + let hits; + try { + hits = scene.drillPick(position, 8, 14, 14) || []; + } catch { + return picked; + } + for (const hit of hits.slice(0, 8)) { + if (findPickOwner(resolvePickId(hit))) return hit; + if (!isSurfacePick(hit)) break; + } + return picked; +} diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index f522ec072..43f11260f 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -16,6 +16,7 @@ import { unregisterPickOwner, isOwnedByOtherLayer, resolvePickId, + pickContact, } from './pickRegistry.js'; // --------------------------------------------------------------------------- @@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => { unregisterPickOwner('bikeshare'); assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false); }); + +function primaryPointer(t, coarse) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }), + }); + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + }); +} + +function contactOwners(t) { + registerPickOwner('flights', (id) => id === 'civil'); + registerPickOwner('military', (id) => id === 'military'); + registerPickOwner('ais-live-vessels', (id) => id === '123456789'); + t.after(() => { + for (const id of ['flights', 'military', 'ais-live-vessels']) + unregisterPickOwner(id); + }); +} + +const mapTile = { primitive: { isCesium3DTileset: true } }; + +test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => { + primaryPointer(t, true); + contactOwners(t); + const position = { x: 30, y: 50 }; + const contact = { primitive: { id: 'civil' } }; + let drills = 0; + const scene = { + pick(point) { + assert.equal(point, position); + return mapTile; + }, + drillPick(...args) { + drills++; + assert.deepEqual(args, [position, 8, 14, 14]); + return [mapTile, contact]; + }, + }; + assert.equal(pickContact(scene, position), contact); + assert.equal(drills, 1); +}); + +test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) { + const scene = { + pick: () => mapTile, + drillPick: () => [mapTile, first, { id: 'civil' }], + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), first); + } +}); + +test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const exact of [ + { id: 'military' }, + { id: 'custom-annotation' }, + { primitive: { id: 'custom-gizmo' } }, + { primitive: { id: 'civil', isCesium3DTileset: true } }, + ]) { + const scene = { + pick: () => exact, + drillPick: () => assert.fail('must keep exact interactive hit'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), exact); + } +}); + +test('touch expansion stops at a non-surface object and respects the drill bound', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const hits of [ + [mapTile, { id: 'custom-annotation' }, { id: 'civil' }], + [...Array(8).fill(mapTile), { id: 'civil' }], + [mapTile], + ]) { + assert.equal( + pickContact( + { pick: () => mapTile, drillPick: () => hits }, + { x: 1, y: 1 }, + ), + mapTile, + ); + } +}); + +test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => { + primaryPointer(t, false); + contactOwners(t); + const scene = { + pick: () => mapTile, + drillPick: () => assert.fail('mouse must not expand'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile); +}); + +test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const drillPick of [ + undefined, + () => { + throw new Error('context lost'); + }, + () => [], + ]) { + assert.equal( + pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }), + undefined, + ); + } +}); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index cef9e1563..dde8f4f95 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const flightsLayer = layer; function _emitAwarenessEvent(type, detail) { @@ -1174,7 +1174,9 @@ export function createTracking({ // first-person reference. A globe click must not fall through to the // normal empty-space deselection path; cockpit has explicit exit controls. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself — ignore (don't deselect) diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 3530a4877..8c9723176 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { ensureGeoidReady, geoidHeight } = services.geoid; const { clearFocusTarget } = services.focus; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; function _emitAwarenessEvent(type, detail) { if ( @@ -1079,7 +1079,9 @@ export function createTracking({ // first-person reference. Empty globe clicks are inert until the user // exits with C, Escape, or the dedicated button. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself -- ignore (don't deselect) diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 88e5aa2ec..96c5df6eb 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -10,7 +10,7 @@ export function createSelection({ options, }) { const { state } = vesselState; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const { requestWorldFocus } = services.worldFocus; const { selectEntityContext, @@ -37,7 +37,9 @@ export function createSelection({ // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!state.feed.enabled) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); const pickedId = resolvePickId(picked); let record = pickedId ? state.records.byMmsi.get(pickedId) : null; const rawId = picked?.id ?? picked?.primitive?.id; diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 7c866bc36..4552da8d4 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -864,9 +864,38 @@ export class StyleManager extends ShellFacade { setPanelCollapsed: (panelId, collapsed, options) => this._panelChrome.setPanelCollapsed(panelId, collapsed, options), onSheetsClosed: () => this._syncShareState(), + onTabOpened: (sheet) => { + void this._onMobileTabOpened(sheet); + }, }); } + /** Reveal a tracked aircraft through Contacts when its phone tab is opened. */ + async _onMobileTabOpened(sheet) { + const context = this.getContextModeState(); + if ( + sheet !== 'context' || + this._disposed || + !context || + context.mode || + context.changing || + context.entering || + !this.getAircraftTrackingTarget() + ) + return false; + try { + const result = await this.setContextMode('contacts'); + if (result?.ok) return true; + if (result?.cancelled || this._disposed) return false; + } catch { + if (this._disposed) return false; + } + this._showToast( + t('Contacts could not complete the requested transition; try again'), + ); + return false; + } + /** * Renders the owner-approved map stack chip row from the matching controller * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable, diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index a2b8638b2..2363fd9ed 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -125,6 +125,8 @@ function buildChrome(documentRef) { * @param {(callback: () => void) => number} [input.requestFrame] * @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes * and its panels are put back (e.g. to refresh the share link). + * @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user + * opens a sheet from its tab, never on close or programmatic presentation. * @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }} */ export function initMobileShell({ @@ -135,6 +137,7 @@ export function initMobileShell({ ResizeObserverImpl = globalThis.ResizeObserver, requestFrame = globalThis.requestAnimationFrame?.bind(globalThis), onSheetsClosed = () => {}, + onTabOpened = () => {}, } = {}) { const body = documentRef.body; const { backdrop, tabBar } = buildChrome(documentRef); @@ -159,6 +162,7 @@ export function initMobileShell({ */ let borrowed = null; let returnFocus = null; + let swipe = null; const written = new Map(); const writeVar = (name, value) => { @@ -259,6 +263,7 @@ export function initMobileShell({ }; const setSheet = (requested) => { + swipe = null; if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; const sheet = sheetBlocked(body) ? null : requested; const previous = currentSheetOf(body); @@ -348,8 +353,123 @@ export function initMobileShell({ // Closing from the tab bar returns focus to the tab that was pressed. if (!sheet) returnFocus = button; setSheet(sheet); + if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet); }); listen(backdrop, 'click', () => setSheet(null)); + // Touch events let native scrolling continue until a downward pull at the + // top is intentional. Pointer capture/touch-action:none would also seize + // ordinary vertical scrolling inside the borrowed panels. + const sheetRoot = (target, sheet) => { + const roots = + sheet === 'layers' + ? [element('left-panel-stack')] + : sheet === 'search' + ? [dock] + : sheet === 'more' + ? [element('right-context-rail'), dock] + : [element('right-context-rail')]; + return roots.find((root) => root?.contains(target)); + }; + const scrolled = (target, root) => { + for (let node = target; node; node = node.parentElement) { + if (node.scrollTop > 0) return true; + if (node === root) break; + } + return false; + }; + listen( + documentRef, + 'touchstart', + (event) => { + swipe = null; + const sheet = currentSheetOf(body); + const target = event.target; + if ( + !sheet || + !body.classList.contains(MOBILE_SHELL_CLASS) || + event.defaultPrevented || + event.touches.length !== 1 || + target?.closest?.( + 'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]', + ) + ) + return; + const root = sheetRoot(target, sheet); + if (!root || scrolled(target, root)) return; + const touch = event.touches[0]; + swipe = { + sheet, + target, + root, + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + claimed: false, + }; + }, + { passive: true }, + ); + listen( + documentRef, + 'touchmove', + (event) => { + if (!swipe) return; + const touch = event.touches[0]; + if ( + event.defaultPrevented || + event.touches.length !== 1 || + touch.identifier !== swipe.id || + currentSheetOf(body) !== swipe.sheet || + scrolled(swipe.target, swipe.root) + ) { + swipe = null; + return; + } + const dx = touch.clientX - swipe.x; + const dy = touch.clientY - swipe.y; + if (!swipe.claimed) { + // Claim direction before the browser crosses its native scroll slop. + // Android can make the next move non-cancelable after a ~9px first move. + if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return; + if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) { + swipe = null; + return; + } + swipe.claimed = true; + } + // Cancelling only the claimed pull leaves taps, sliders and normal scroll + // native, and suppresses the synthetic click at the end of a dismissal. + event.preventDefault(); + }, + { passive: false }, + ); + listen( + documentRef, + 'touchend', + (event) => { + const pull = swipe; + swipe = null; + if (!pull?.claimed || event.touches.length) return; + const touch = [...event.changedTouches].find( + (item) => item.identifier === pull.id, + ); + if (!touch || currentSheetOf(body) !== pull.sheet) return; + const dx = Math.abs(touch.clientX - pull.x); + const dy = touch.clientY - pull.y; + if (dy < 64 || dy <= dx * 1.25) return; + returnFocus = tabFor(pull.sheet) ?? returnFocus; + setSheet(null); + }, + { passive: true }, + ); + listen( + documentRef, + 'touchcancel', + () => { + swipe = null; + }, + { passive: true }, + ); // Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools // take Escape first; panels yield it to the sheet (PanelChrome), and an open // dialog keeps its own native close. diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index 751333c9a..bb7599547 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; +import { ContextControls } from './contextControls.js'; import { MOBILE_SHEET_PANELS, MOBILE_TABS, @@ -21,6 +23,8 @@ function element(tag, document) { textContent: '', className: '', parent: null, + scrollTop: 0, + parentElement: null, classList: { add: (...names) => names.forEach((name) => classes.add(name)), remove: (...names) => names.forEach((name) => classes.delete(name)), @@ -41,6 +45,7 @@ function element(tag, document) { append(...children) { for (const child of children) { child.parent = node; + child.parentElement = node; node.children.push(child); if (child.id) document.ids.set(child.id, child); } @@ -56,9 +61,20 @@ function element(tag, document) { return false; }, closest(selector) { - for (let current = node; current; current = current.parent) + for (let current = node; current; current = current.parent) { + if ( + selector.startsWith('button, a') && + (['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes( + current.tagName, + ) || + ['slider', 'button'].includes(current.getAttribute('role')) || + (current.getAttribute('contenteditable') !== null && + current.getAttribute('contenteditable') !== 'false')) + ) + return current; if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet) return current; + } return null; }, querySelectorAll(selector) { @@ -94,7 +110,11 @@ function element(tag, document) { const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())]; -function fixture({ mobile = true, expanded = [] } = {}) { +function fixture({ + mobile = true, + expanded = [], + onTabOpened = () => {}, +} = {}) { const document = new EventTarget(); document.ids = new Map(); document.createElement = (tag) => element(tag, document); @@ -112,6 +132,12 @@ function fixture({ mobile = true, expanded = [] } = {}) { const search = element('input', document); search.id = 'location-search'; document.body.append(search); + const rails = {}; + for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) { + rails[id] = element('div', document); + rails[id].id = id; + document.body.append(rails[id]); + } for (const id of PANEL_IDS) { const panel = element('section', document); panel.id = id; @@ -123,7 +149,13 @@ function fixture({ mobile = true, expanded = [] } = {}) { const collapse = element('button', document); collapse.className = 'panel-collapse-btn'; panel.append(toggle, collapse, element('button', document)); - document.body.append(panel); + const root = + id === 'data-panel' + ? 'left-panel-stack' + : ['location-bar', 'control-panel'].includes(id) + ? 'command-dock' + : 'right-context-rail'; + rails[root].append(panel); } const media = new EventTarget(); @@ -143,6 +175,7 @@ function fixture({ mobile = true, expanded = [] } = {}) { } const shell = initMobileShell({ documentRef: document, + onTabOpened, matchMedia: () => media, MutationObserverImpl: FakeMutationObserver, ResizeObserverImpl: null, @@ -481,3 +514,352 @@ test('the phone shell keeps the required attribution visible', () => { assert.ok(z(sheetCredit) > z(openDock)); assert.ok(z(sheetCredit) > z(railSheet)); }); + +function touchEvent( + document, + type, + target, + { x = 10, y = 10, count = 1, id = 1, cancelable = true } = {}, +) { + const event = new Event(type, { cancelable }); + const touch = { identifier: id, clientX: x, clientY: y }; + Object.defineProperty(event, 'target', { value: target }); + event.touches = + type === 'touchend' || type === 'touchcancel' + ? [] + : Array.from({ length: count }, (_, i) => ({ + ...touch, + identifier: id + i, + })); + event.changedTouches = [touch]; + document.dispatchEvent(event); + return event; +} + +test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => { + for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) { + const phone = fixture(); + const { document, shell, press, tab } = phone; + press(sheet); + const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]); + touchEvent(document, 'touchstart', target); + const move = touchEvent(document, 'touchmove', target, { y: 90 }); + assert.equal(move.defaultPrevented, true); + assert.equal(shell.getSheet(), sheet, 'waits until finger lifts'); + touchEvent(document, 'touchend', target, { y: 90 }); + assert.equal(shell.getSheet(), null); + assert.equal(phone.closedCount(), 1); + assert.equal(document.activeElement, tab(sheet)); + for (const id of MOBILE_SHEET_PANELS[sheet]) + assert.equal(phone.collapsed(id), true); + assert.ok( + phone.calls.every( + (call) => call.persist === false && call.syncShare === false, + ), + ); + shell.destroy(); + } +}); + +test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => { + for (const nested of [false, true]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + const scroller = nested + ? panel + : document.getElementById('left-panel-stack'); + scroller.scrollTop = 40; + touchEvent(document, 'touchstart', panel); + scroller.scrollTop = 0; + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('taps, short pulls, upward and horizontal gestures never dismiss', () => { + for (const path of [ + [], + [{ x: 12, y: 14 }], + [{ y: 40 }], + [{ y: -30 }, { y: 120 }], + [{ x: 60, y: 12 }, { y: 120 }], + ]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + for (const point of path) touchEvent(document, 'touchmove', panel, point); + touchEvent(document, 'touchend', panel, path.at(-1) ?? {}); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('native controls and custom sliders keep touch gestures', () => { + for (const tag of [ + 'button', + 'a', + 'input', + 'select', + 'textarea', + 'label', + 'slider', + 'editable', + ]) { + const { document, shell, press } = fixture(); + press('more'); + const control = document.createElement( + ['slider', 'editable'].includes(tag) ? 'div' : tag, + ); + if (tag === 'slider') control.setAttribute('role', 'slider'); + if (tag === 'editable') control.setAttribute('contenteditable', 'true'); + const child = document.createElement('span'); + control.append(child); + document.getElementById('control-panel').append(control); + touchEvent(document, 'touchstart', child); + assert.equal( + touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', child, { y: 120 }); + assert.equal(shell.getSheet(), 'more'); + shell.destroy(); + } +}); + +test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => { + for (const cancel of [ + 'multitouch', + 'cancel', + 'sheet', + 'desktop', + 'noncancelable', + ]) { + const { document, shell, press, resize } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + if (cancel === 'multitouch') + touchEvent(document, 'touchstart', document.body, { count: 2 }); + if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel); + if (cancel === 'sheet') press('context'); + if (cancel === 'desktop') { + resize(false); + resize(true); + press('layers'); + } + if (cancel === 'noncancelable') + touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers'); + shell.destroy(); + } +}); + +test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => { + const { document, shell, press } = fixture(); + press('layers'); + const hiddenPanel = document.getElementById('global-context-panel'); + touchEvent(document, 'touchstart', hiddenPanel); + assert.equal( + touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', hiddenPanel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + shell.destroy(); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), null); +}); + +test('the tab-open callback reports only a successful manual opening', () => { + const opened = []; + const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) }); + phone.shell.setSheet('context'); + assert.deepEqual(opened, []); + phone.press('context'); + assert.deepEqual(opened, [], 'closing is not a new context request'); + phone.press('context'); + assert.deepEqual(opened, ['context']); + phone.setBodyClass('cockpit-mode'); + phone.press('context'); + assert.deepEqual( + opened, + ['context'], + 'blocked sheets cannot activate a mode', + ); + phone.shell.destroy(); +}); + +test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => { + const calls = []; + const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' }; + const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } }; + const entity = viewer.trackedEntity; + const enabled = new Set(['flights', 'weather']); + const controls = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => null, + claimVisualAuthority: () => calls.push('claim'), + syncDetection() {}, + scheduleLayout() {}, + showToast() {}, + }, + }); + controls.connect({ + layers: new Map( + ['flights', 'weather', 'military-awareness'].map((id) => [id, {}]), + ), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => ({}), + isEffectivelyEnabled: (id) => enabled.has(id), + isEnabled: (id) => enabled.has(id), + setLayerParams: (id, params) => calls.push({ id, params }), + setEnabled: async (id, value) => { + calls.push({ id, value }); + if (!value) enabled.delete(id); + if (id === 'flights' && !value) viewer.trackedEntity = null; + return true; + }, + _setEnabledWithIntent: (id, value) => { + calls.push({ id, value }); + enabled.add(id); + return { intentEpoch: 1, promise: Promise.resolve(true) }; + }, + }); + const shell = { + cockpitView: { + readAircraftInfo: () => (viewer.trackedEntity ? tracked : null), + }, + getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget, + getContextModeState: () => controls.getContextModeState(), + setContextMode: (mode) => controls.setContextMode(mode), + _showToast: () => assert.fail('successful entry must not report a failure'), + }; + try { + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + true, + ); + assert.equal(controls.getContextModeState().mode, 'flights'); + assert.equal(viewer.trackedEntity, entity); + assert.deepEqual(shell.getAircraftTrackingTarget(), { + layerId: 'flights', + id: 'abc123', + }); + assert.ok(enabled.has('flights')); + assert.ok( + calls.some( + (call) => call.id === 'military-awareness' && call.value === true, + ), + ); + assert.ok(!calls.some((call) => call.id === 'flights')); + const count = calls.length; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + calls.length, + count, + 'an already active Contacts session is not restarted', + ); + } finally { + controls.stop(); + controls.disconnect(); + } +}); + +test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => { + for (const state of [ + { mode: 'space-missions' }, + { mode: 'flights' }, + { changing: true }, + { entering: 'space-missions' }, + { noTrack: true }, + { disposed: true }, + { sheet: 'layers' }, + ]) { + const shell = { + _disposed: state.disposed, + getContextModeState: () => state, + getAircraftTrackingTarget: () => + state.noTrack ? null : { layerId: 'flights', id: 'abc123' }, + setContextMode: () => + assert.fail('guarded tab must not mutate Context or tracking'), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call( + shell, + state.sheet || 'context', + ), + false, + ); + } +}); + +test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => { + for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) { + const notices = []; + const shell = { + getContextModeState: () => ({ mode: null }), + getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }), + setContextMode: async () => { + if (outcome === 'throw') throw new Error('source unavailable'); + if (outcome === 'disposed') shell._disposed = true; + return { ok: false, cancelled: outcome === 'cancelled' }; + }, + _showToast: (message) => notices.push(message), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + notices.length, + ['failure', 'throw'].includes(outcome) ? 1 : 0, + ); + } +}); + +test('a slow downward pull is claimed before Android native scroll takes ownership', () => { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel, { y: 100 }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented, + false, + 'tap jitter is left native', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented, + true, + 'cancel the first directional move while Android still allows it', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented, + true, + ); + touchEvent(document, 'touchend', panel, { y: 175 }); + assert.equal(shell.getSheet(), null); + shell.destroy(); +}); diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index 4cf32513a..aecfdf8d4 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -86,10 +86,14 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) { /* ── Top bar: logo left, globe actions right ───────────────────────────── */ body.mobile-shell #title-bar { - top: calc(var(--mobile-top-inset) + 12px); + top: calc(var(--mobile-top-inset) + 10px); left: 14px; } +body.mobile-shell #title-bar .title-logo { + width: 58px; +} + body.mobile-shell #title-bar h1 > :not(.title-logo), body.mobile-shell #title-bar .subtitle { display: none;