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:
Sameh Khamis authored and GitHub committed 2026-10-23 15:05:23 -07:00
1 parent b2fb4a261e
commit 8d454bf7f1
14 files changed
+759 -12

No files matched your search

+28
View File
@@ -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' });
+4 -1
View File
@@ -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);
+35
View 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;
}
+120
View File
@@ -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,
);
}
});