mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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.
This commit is contained in:
1 parent
b2fb4a261e
commit
8d454bf7f1
14 files changed
+759
-12
No files matched your search
@@ -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).
|
||||
+16
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* that belong to someone else and leave them alone.
|
||||
*/
|
||||
|
||||
import { isSurfacePick } from './scenePick.js';
|
||||
|
||||
/** @type {Map<string, (pickedId: string) => 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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+385
-3
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user