mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
This commit is contained in:
commit
47ea4aff1d
1889 files changed
+215884
-41914
No files matched your search
@@ -0,0 +1,170 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
} from '../data/layerSnapshot.js';
|
||||
import { contextModeWord } from '../contextModePolicy.js';
|
||||
|
||||
export const TRACKABLE_FAMILIES = Object.freeze([
|
||||
{ layerId: 'flights', kind: 'aircraft' },
|
||||
{ layerId: 'military', kind: 'aircraft' },
|
||||
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
||||
{ layerId: 'satellites', kind: 'satellite' },
|
||||
]);
|
||||
|
||||
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
|
||||
{ field: 'mode', emptyAs: 'off' },
|
||||
{ field: 'entering', emptyAs: null },
|
||||
{ field: 'priorMode', emptyAs: null },
|
||||
]);
|
||||
|
||||
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
||||
'context',
|
||||
'contextRollback',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Translate context-mode payloads into the public tool vocabulary while
|
||||
* retaining the internal id beside each translated field.
|
||||
*/
|
||||
export function withContextModeVocabulary(state) {
|
||||
if (!state || typeof state !== 'object') return state;
|
||||
let out = state;
|
||||
const mutable = () => {
|
||||
if (out === state) out = { ...state };
|
||||
return out;
|
||||
};
|
||||
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
|
||||
if (!(field in state)) continue;
|
||||
const internal = state[field] ?? null;
|
||||
const target = mutable();
|
||||
target[field] = contextModeWord(internal, { emptyAs });
|
||||
target[`${field}Internal`] = internal;
|
||||
}
|
||||
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
|
||||
const nested = state[field];
|
||||
if (!nested || typeof nested !== 'object') continue;
|
||||
const translated = withContextModeVocabulary(nested);
|
||||
if (translated !== nested) mutable()[field] = translated;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read tracked entities from their canonical layer owners. */
|
||||
export function collectTrackedEntities(dataManager) {
|
||||
const tracked = [];
|
||||
for (const family of TRACKABLE_FAMILIES) {
|
||||
const module = dataManager?.layers?.get(family.layerId)?.module;
|
||||
if (!module) continue;
|
||||
try {
|
||||
const info =
|
||||
family.kind === 'vessel'
|
||||
? module.getSelectedInfo?.()
|
||||
: module.getTrackedInfo?.();
|
||||
if (info)
|
||||
tracked.push({
|
||||
kind: family.kind,
|
||||
layerId: family.layerId,
|
||||
...info,
|
||||
});
|
||||
} catch {
|
||||
// A not-yet-ready layer contributes no tracked observation.
|
||||
}
|
||||
}
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/** Read the active Contacts window from its owning awareness layer. */
|
||||
export function activeContactsWindow(dataManager) {
|
||||
try {
|
||||
const layer = dataManager?.layers?.get('military-awareness')?.module;
|
||||
return (
|
||||
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the model-free fast World Packet from existing application owners.
|
||||
*
|
||||
* This performs no network requests and intentionally mirrors the historical
|
||||
* get_current_view_state payload so voice can delegate without behavior drift.
|
||||
*/
|
||||
export function getWorldPacket(options = {}) {
|
||||
const {
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager,
|
||||
sceneDirector = null,
|
||||
now: requestedNow,
|
||||
} = options ?? {};
|
||||
// A default parameter only covers undefined; null or NaN would age every
|
||||
// feed against the epoch and report stale data as "just now".
|
||||
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
|
||||
if (!viewer?.camera?.positionWC)
|
||||
throw new TypeError('World Packet requires a viewer camera');
|
||||
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||
throw new TypeError('World Packet requires a data manager');
|
||||
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
||||
// current enabled-only view keeps the model's context budget bounded.
|
||||
const rows = dataManager.getAll();
|
||||
const enabledRows = rows.filter((layer) => layer.enabled);
|
||||
const contactsWindow = activeContactsWindow(dataManager);
|
||||
const contextState =
|
||||
typeof styleManager?.getContextModeState === 'function'
|
||||
? withContextModeVocabulary(styleManager.getContextModeState())
|
||||
: null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
heightM: cartographic.height,
|
||||
},
|
||||
style: styleManager?.activeStyle || 'normal',
|
||||
context: contextState
|
||||
? {
|
||||
...contextState,
|
||||
...(contactsWindow ? { contactsWindow } : {}),
|
||||
}
|
||||
: null,
|
||||
cockpit:
|
||||
typeof styleManager?.getCockpitState === 'function'
|
||||
? styleManager.getCockpitState()
|
||||
: null,
|
||||
controls:
|
||||
typeof styleManager?.getControlState === 'function'
|
||||
? styleManager.getControlState()
|
||||
: null,
|
||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||
tracked: collectTrackedEntities(dataManager),
|
||||
disabledLayerCount: rows.length - enabledRows.length,
|
||||
// Preserve get_current_view_state's existing compact enabled-only shape.
|
||||
layers: enabledRows.map((layer) => {
|
||||
const snapshot = layerSnapshot(layer, { now });
|
||||
return {
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
enabled: layer.enabled,
|
||||
count: layer.stats?.count || 0,
|
||||
error: layer.stats?.error || null,
|
||||
feedState: snapshot.feedState,
|
||||
source: snapshot.source,
|
||||
lastUpdate: snapshot.lastUpdate,
|
||||
};
|
||||
}),
|
||||
feedProvenance: feedProvenanceEnvelope(
|
||||
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
|
||||
{ now },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
import { getWorldPacket } from './worldPacket.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
|
||||
function withoutVoicePresentation(result) {
|
||||
const packet = { ...result };
|
||||
delete packet.say;
|
||||
delete packet.display;
|
||||
delete packet.schedule;
|
||||
return packet;
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
|
||||
const viewer = {
|
||||
trackedEntity: undefined,
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: {
|
||||
canvas: {
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
globe: { getHeight: () => 0 },
|
||||
tweens: [],
|
||||
},
|
||||
camera: {
|
||||
positionWC: position,
|
||||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(-45),
|
||||
moveEnd: { addEventListener: () => () => {} },
|
||||
cancelFlight() {},
|
||||
lookAtTransform() {},
|
||||
},
|
||||
};
|
||||
const rows = [
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 3,
|
||||
source: 'fixture',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
const dataManager = {
|
||||
layers: new Map([['traffic', { module: null }]]),
|
||||
getAll: () => rows,
|
||||
};
|
||||
const styleManager = {
|
||||
activeStyle: 'normal',
|
||||
getContextModeState: () => ({ mode: 'flights' }),
|
||||
getCockpitState: () => null,
|
||||
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||
};
|
||||
return { viewer, dataManager, styleManager };
|
||||
}
|
||||
|
||||
test('World Packet preserves get_current_view_state parity', async () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||
const voice = await runner('get_current_view_state');
|
||||
|
||||
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||
assert.equal(packet.context.mode, 'contacts');
|
||||
assert.equal(packet.context.modeInternal, 'flights');
|
||||
assert.equal(packet.layers[0].feedState, 'nominal');
|
||||
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||
});
|
||||
|
||||
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
dataManager.getAll = () => [
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
source: 'fixture',
|
||||
error: 'fixture unavailable',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
assert.equal(packet.layers[0].count, 0);
|
||||
assert.notEqual(packet.layers[0].feedState, 'nominal');
|
||||
assert.notEqual(packet.feedProvenance.overall, 'nominal');
|
||||
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
|
||||
});
|
||||
|
||||
// The 2026-10 main voice contract omits disabled layers from the model payload.
|
||||
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
const rows = dataManager.getAll();
|
||||
rows.push({
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes',
|
||||
enabled: false,
|
||||
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||
});
|
||||
let reads = 0;
|
||||
dataManager.getAll = () => {
|
||||
reads += 1;
|
||||
return rows;
|
||||
};
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
|
||||
assert.equal(packet.disabledLayerCount, 1);
|
||||
assert.deepEqual(
|
||||
packet.layers.map((layer) => layer.id),
|
||||
['traffic'],
|
||||
);
|
||||
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||
const voice = await runner('get_current_view_state');
|
||||
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||
});
|
||||
|
||||
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
|
||||
|
||||
// Exercises every packet field: context vocabulary and Contacts window,
|
||||
// cockpit, controls, scene playback, tracked families (one not ready),
|
||||
// enabled and disabled layers, and an aged and an unavailable feed.
|
||||
function pinnedHarness() {
|
||||
const { viewer } = harness();
|
||||
const rows = [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 12,
|
||||
source: 'fixture-adsb',
|
||||
lastUpdate: PINNED_NOW - 90_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
source: 'fixture',
|
||||
error: 'fixture unavailable',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes',
|
||||
enabled: false,
|
||||
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||
},
|
||||
];
|
||||
const module = (methods) => ({ module: methods });
|
||||
const dataManager = {
|
||||
layers: new Map([
|
||||
[
|
||||
'flights',
|
||||
module({
|
||||
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
|
||||
}),
|
||||
],
|
||||
[
|
||||
'ais-live-vessels',
|
||||
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
|
||||
],
|
||||
[
|
||||
'satellites',
|
||||
module({
|
||||
getTrackedInfo: () => {
|
||||
throw new Error('not ready');
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'military-awareness',
|
||||
module({
|
||||
getContextSnapshot: () => ({ snapshot: true }),
|
||||
contactsWindowFromSnapshot: (snapshot) =>
|
||||
snapshot ? { windowMin: 30, count: 4 } : null,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
getAll: () => rows,
|
||||
};
|
||||
const styleManager = {
|
||||
activeStyle: 'night-vision',
|
||||
getContextModeState: () => ({
|
||||
mode: 'flights',
|
||||
entering: null,
|
||||
priorMode: null,
|
||||
}),
|
||||
getCockpitState: () => ({ active: false }),
|
||||
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||
};
|
||||
const sceneDirector = {
|
||||
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
|
||||
};
|
||||
return { viewer, dataManager, styleManager, sceneDirector };
|
||||
}
|
||||
|
||||
// Captured from main's get_current_view_state before the extraction
|
||||
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
|
||||
// minus the speech envelope. Key order is part of the contract.
|
||||
const PRE_EXTRACTION_PAYLOAD = {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: 30.259999999999994,
|
||||
longitude: -97.74,
|
||||
heightM: 499.99999999901877,
|
||||
},
|
||||
style: 'night-vision',
|
||||
context: {
|
||||
mode: 'contacts',
|
||||
entering: null,
|
||||
priorMode: null,
|
||||
modeInternal: 'flights',
|
||||
enteringInternal: null,
|
||||
priorModeInternal: null,
|
||||
contactsWindow: { windowMin: 30, count: 4 },
|
||||
},
|
||||
cockpit: { active: false },
|
||||
controls: { hud: { visible: 'auto' } },
|
||||
scenePlayback: { playing: false, sceneId: 'fixture' },
|
||||
tracked: [
|
||||
{
|
||||
kind: 'aircraft',
|
||||
layerId: 'flights',
|
||||
icao24: 'abc123',
|
||||
callsign: 'TEST1',
|
||||
},
|
||||
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
|
||||
],
|
||||
disabledLayerCount: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
count: 12,
|
||||
error: null,
|
||||
feedState: 'nominal',
|
||||
source: 'fixture-adsb',
|
||||
lastUpdate: 1792238310000,
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
count: 0,
|
||||
error: 'fixture unavailable',
|
||||
feedState: 'unavailable',
|
||||
source: 'fixture',
|
||||
lastUpdate: 0,
|
||||
},
|
||||
],
|
||||
feedProvenance: {
|
||||
overall: 'unavailable',
|
||||
layers: [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
feedState: 'nominal',
|
||||
source: 'fixture-adsb',
|
||||
count: 12,
|
||||
lastUpdate: 1792238310000,
|
||||
ageSec: 90,
|
||||
ageLabel: '1m ago',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
feedState: 'unavailable',
|
||||
source: 'fixture',
|
||||
count: 0,
|
||||
lastUpdate: 0,
|
||||
ageSec: null,
|
||||
ageLabel: null,
|
||||
error: 'fixture unavailable',
|
||||
},
|
||||
],
|
||||
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
|
||||
},
|
||||
};
|
||||
|
||||
function assertPinnedPayload(actual) {
|
||||
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
|
||||
// deepEqual ignores key order; the serialized form does not.
|
||||
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
|
||||
}
|
||||
|
||||
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
|
||||
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
|
||||
assertPinnedPayload(packet);
|
||||
});
|
||||
|
||||
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
|
||||
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||
const runner = createGevActionRunner(pinnedHarness());
|
||||
const voice = await runner('get_current_view_state');
|
||||
assertPinnedPayload(withoutVoicePresentation(voice));
|
||||
});
|
||||
|
||||
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
|
||||
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||
for (const now of [null, Number.NaN, '2026-10-17']) {
|
||||
const packet = getWorldPacket({ ...pinnedHarness(), now });
|
||||
const flights = packet.feedProvenance.layers.find(
|
||||
(layer) => layer.id === 'flights',
|
||||
);
|
||||
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
|
||||
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('World Packet rejects null options with the camera requirement', () => {
|
||||
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
|
||||
});
|
||||
@@ -120,6 +120,93 @@ test('Georgia resolves from camera geography without a geocoder', async () => {
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => {
|
||||
for (const types of [['locality', 'political'], ['natural_feature']]) {
|
||||
const { resolve, geocodes } = harness({
|
||||
lat: -28.0167,
|
||||
lng: 153.4,
|
||||
label: 'Gold Coast, Australia',
|
||||
name: 'Gold Coast',
|
||||
types,
|
||||
});
|
||||
const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4));
|
||||
assert.equal(resolved?.source, 'geocode');
|
||||
assert.equal(resolved.lat, -28.0167);
|
||||
assert.equal(resolved.lon, 153.4);
|
||||
assert.equal(resolved.naturalRegion, undefined);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.equal(outline?.naturalRegion, undefined);
|
||||
assert.deepEqual(geocodes, ['Gold Coast']);
|
||||
}
|
||||
});
|
||||
|
||||
test('natural annotation outlines require a geocoded anchor', async () => {
|
||||
for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) {
|
||||
const miss = harness();
|
||||
assert.equal(await miss.resolve('the Alps', viewer), null);
|
||||
assert.ok(miss.geocodes.includes('the Alps'));
|
||||
}
|
||||
const anchored = harness({
|
||||
lat: 46.5,
|
||||
lng: 10,
|
||||
label: 'Alps',
|
||||
name: 'Alps',
|
||||
types: ['natural_feature'],
|
||||
});
|
||||
const resolved = await anchored.resolve('the Alps', {});
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.equal(outline.naturalRegion, 'Alps');
|
||||
assert.ok(outline.ring.length >= 8);
|
||||
});
|
||||
|
||||
test('overlapping natural candidates continue through geocoding without choosing a ring', async () => {
|
||||
const { resolve, geocodes } = harness({
|
||||
lat: 45.6336,
|
||||
lng: -74.86493,
|
||||
label: 'Canadian Shield',
|
||||
name: 'Canadian Shield',
|
||||
types: ['natural_feature'],
|
||||
});
|
||||
const resolved = await resolve(
|
||||
'Canadian Shield',
|
||||
viewerAt(45.6336, -74.86493),
|
||||
);
|
||||
assert.equal(resolved?.source, 'geocode');
|
||||
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
|
||||
assert.deepEqual(geocodes, ['Canadian Shield']);
|
||||
});
|
||||
|
||||
test('geocoded natural outlines respect district and around intent', async () => {
|
||||
for (const extra of [
|
||||
{ entityKind: 'district' },
|
||||
{ intent: 'around_the_thing' },
|
||||
]) {
|
||||
const { resolve, geocodes } = harness({
|
||||
lat: 46.5,
|
||||
lng: 10,
|
||||
label: 'Alps',
|
||||
name: 'Alps',
|
||||
types:
|
||||
extra.entityKind === 'district' ? ['locality'] : ['natural_feature'],
|
||||
});
|
||||
const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra);
|
||||
assert.ok(geocodes.includes('the Alps'));
|
||||
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => {
|
||||
const controller = new AbortController();
|
||||
const { resolve, geocodes } = harness();
|
||||
const pending = resolve('the Alps', viewerAt(46.5, 10), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('country asks and geocoded country scopes use the bundled outline', async () => {
|
||||
const direct = harness();
|
||||
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The shapes drawn by hand, as UTILITIES ▸ Draw lists them: one row per shape
|
||||
* with its colour, its name, and show/hide, fly-to and remove, the way Import
|
||||
* lists its files. The board is the annotation engine's; voice marks are not
|
||||
* listed, only `manual` ones.
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const DRAW_COLORS = Object.freeze([
|
||||
'primary',
|
||||
'amber',
|
||||
'cyan',
|
||||
'green',
|
||||
'red',
|
||||
]);
|
||||
/** Each colour as drawn: the preview, the swatches and the drawn shape. */
|
||||
export const DRAW_COLOR_CSS = Object.freeze({
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
});
|
||||
export const DRAW_COLOR_NAMES = Object.freeze({
|
||||
primary: msg('Primary'),
|
||||
amber: msg('Amber'),
|
||||
cyan: msg('Cyan'),
|
||||
green: msg('Green'),
|
||||
red: msg('Red'),
|
||||
});
|
||||
const SHAPE_NAMES = Object.freeze({
|
||||
area: msg('Area'),
|
||||
route: msg('Line'),
|
||||
pin: msg('Pin'),
|
||||
});
|
||||
|
||||
/** What a row calls a shape that has no name of its own. */
|
||||
export function drawMarkFallbackName(mark) {
|
||||
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
|
||||
// An unnamed line's label is its length; keep it, it is the useful part.
|
||||
return mark?.type === 'route' && !mark.name && mark.label
|
||||
? `${shape} · ${mark.label}`
|
||||
: shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for the hand-drawn marks on the board, in board order. Text is
|
||||
* worded here, in the current language; the view only lays it out.
|
||||
* @param {object[]} marks The engine's `list()`.
|
||||
*/
|
||||
export function presentDrawMarks(marks = []) {
|
||||
return marks
|
||||
.filter((mark) => mark?.manual === true)
|
||||
.map((mark) => {
|
||||
const fallback = drawMarkFallbackName(mark);
|
||||
const title = mark.name || fallback;
|
||||
const visible = !mark.hidden;
|
||||
return {
|
||||
id: mark.id,
|
||||
name: mark.name || '',
|
||||
placeholder: fallback,
|
||||
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
|
||||
hidden: !visible,
|
||||
labels: {
|
||||
name: t('Name of {name}', { name: title }),
|
||||
swatch: t('Change the colour of {name}', { name: title }),
|
||||
colors: t('Colour of {name}', { name: title }),
|
||||
toggle: visible
|
||||
? t('Hide {name}', { name: title })
|
||||
: t('Show {name}', { name: title }),
|
||||
fly: t('Fly to {name}', { name: title }),
|
||||
remove: t('Remove {name}', { name: title }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkFallbackName,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
test('an unnamed shape is called by its kind, and an unnamed line by its length too', () => {
|
||||
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
|
||||
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
|
||||
assert.equal(
|
||||
drawMarkFallbackName({ type: 'route', label: '300 m' }),
|
||||
'Line · 300 m',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkFallbackName({
|
||||
type: 'route',
|
||||
name: 'Walk',
|
||||
label: 'Walk — 300 m',
|
||||
}),
|
||||
'Line',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows list only hand-drawn marks, in board order, with their words', () => {
|
||||
const rows = presentDrawMarks([
|
||||
{
|
||||
id: 'a1',
|
||||
type: 'route',
|
||||
manual: true,
|
||||
name: null,
|
||||
label: '950 m',
|
||||
color: 'amber',
|
||||
},
|
||||
{ id: 'a2', type: 'area', manual: false, label: 'Texas', color: 'primary' },
|
||||
{
|
||||
id: 'a3',
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
name: 'Camp',
|
||||
color: 'teal',
|
||||
hidden: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
rows.map(({ id, name, placeholder, color, hidden }) => ({
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
color,
|
||||
hidden,
|
||||
})),
|
||||
[
|
||||
{
|
||||
id: 'a1',
|
||||
name: '',
|
||||
placeholder: 'Line · 950 m',
|
||||
color: 'amber',
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
name: 'Camp',
|
||||
placeholder: 'Pin',
|
||||
color: 'primary',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(rows[0].labels.toggle, 'Hide Line · 950 m');
|
||||
assert.equal(rows[1].labels.toggle, 'Show Camp');
|
||||
assert.equal(rows[1].labels.fly, 'Fly to Camp');
|
||||
assert.equal(rows[1].labels.colors, 'Colour of Camp');
|
||||
});
|
||||
|
||||
test('every colour has its swatch', () => {
|
||||
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
|
||||
});
|
||||
+12
-11
@@ -21,6 +21,7 @@ import { t, tn } from '../i18n/index.js';
|
||||
*/
|
||||
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
import { formatDistanceText } from '../data/distanceFormat.js';
|
||||
|
||||
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
|
||||
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
|
||||
@@ -31,10 +32,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
|
||||
*/
|
||||
export const MAX_VERTICES = 512;
|
||||
/** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */
|
||||
export const MIN_VERTEX_SEPARATION_M = 0.5;
|
||||
const MIN_VERTEX_SEPARATION_M = 0.5;
|
||||
/** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */
|
||||
export const MIN_PATH_LENGTH_M = 1;
|
||||
export const MIN_AREA_M2 = 1;
|
||||
const MIN_PATH_LENGTH_M = 1;
|
||||
const MIN_AREA_M2 = 1;
|
||||
|
||||
/** @param {string} shape @returns {'area'|'line'|'pin'} */
|
||||
export function normalizeShape(shape) {
|
||||
@@ -50,7 +51,7 @@ export function createDrawSession(shape = 'area') {
|
||||
}
|
||||
|
||||
/** Great-circle distance in metres between two {lon, lat} points. */
|
||||
export function greatCircleM(a, b) {
|
||||
function greatCircleM(a, b) {
|
||||
return greatCircleMeters(a, b);
|
||||
}
|
||||
|
||||
@@ -207,16 +208,16 @@ export function ringCentroid(vertices) {
|
||||
export function formatMeasure(session) {
|
||||
if (!session) return '';
|
||||
if (session.shape === 'line') {
|
||||
const m = pathLengthM(session.vertices);
|
||||
return m >= 1000
|
||||
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
|
||||
: `${Math.round(m)} m`;
|
||||
return formatDistanceText(pathLengthM(session.vertices)) ?? '';
|
||||
}
|
||||
if (session.shape === 'area') {
|
||||
const m2 = ringAreaM2(session.vertices);
|
||||
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`;
|
||||
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`;
|
||||
return `${Math.round(m2)} m²`;
|
||||
// Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
|
||||
const whole = Math.round(m2);
|
||||
const ha = (m2 / 1e4).toFixed(1);
|
||||
if (whole < 1e4) return `${whole} m²`;
|
||||
if (Number(ha) < 100) return `${ha} ha`;
|
||||
return `${(m2 / 1e6).toFixed(2)} km²`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -330,3 +330,66 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', (
|
||||
assert.equal(wrapLongitude(180), -180);
|
||||
assert.equal(unwrapLongitudes([]).length, 0);
|
||||
});
|
||||
|
||||
test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
|
||||
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||
const lat = 999.6 / metresPerDegree;
|
||||
const line = createDrawSession('line');
|
||||
addVertex(line, { lon: 0, lat: 0 });
|
||||
addVertex(line, { lon: 0, lat });
|
||||
const metres = pathLengthM(line.vertices);
|
||||
assert.ok(
|
||||
metres >= 999.5 && metres < 1000,
|
||||
`length should round to 1000 m, got ${metres}`,
|
||||
);
|
||||
assert.equal(formatMeasure(line), '1.0 km');
|
||||
assert.match(drawHint(line), /^1\.0 km · /);
|
||||
});
|
||||
|
||||
test('a line that rounds to 10.0 km reads in whole kilometres', () => {
|
||||
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
|
||||
const line = createDrawSession('line');
|
||||
addVertex(line, { lon: 0, lat: 0 });
|
||||
addVertex(line, { lon: 0, lat: 9960 / metresPerDegree });
|
||||
assert.equal(formatMeasure(line), '10 km');
|
||||
});
|
||||
|
||||
test('an area that rounds up to the next unit reads in that unit', () => {
|
||||
// A square on the equator whose shoelace area is about `m2`.
|
||||
const square = (m2) => {
|
||||
const side = Math.sqrt(m2) / 111320;
|
||||
const area = createDrawSession('area');
|
||||
for (const [lon, lat] of [
|
||||
[0, 0],
|
||||
[side, 0],
|
||||
[side, side],
|
||||
[0, side],
|
||||
])
|
||||
addVertex(area, { lon, lat });
|
||||
return area;
|
||||
};
|
||||
const almostHectare = square(9999.6);
|
||||
const metres = ringAreaM2(almostHectare.vertices);
|
||||
assert.ok(
|
||||
metres >= 9999.5 && metres < 1e4,
|
||||
`area should round to 10000 m², got ${metres}`,
|
||||
);
|
||||
assert.equal(formatMeasure(almostHectare), '1.0 ha');
|
||||
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
|
||||
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
|
||||
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
|
||||
const almostKm2 = square(999_960);
|
||||
const nearKm2 = ringAreaM2(almostKm2.vertices);
|
||||
assert.ok(
|
||||
nearKm2 >= 999_500 && nearKm2 < 1e6,
|
||||
`area should round to 100.0 ha, got ${nearKm2}`,
|
||||
);
|
||||
assert.equal(formatMeasure(almostKm2), '1.00 km²');
|
||||
const under = square(999_400);
|
||||
const underM2 = ringAreaM2(under.vertices);
|
||||
assert.ok(
|
||||
underM2 >= 998_500 && underM2 < 999_500,
|
||||
`area should round to 99.9 ha, got ${underM2}`,
|
||||
);
|
||||
assert.equal(formatMeasure(under), '99.9 ha');
|
||||
});
|
||||
+129
-101
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Manual whiteboard drawing: the Cesium + DOM half.
|
||||
*
|
||||
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
||||
* shape (area, line or pin), click the vertices on the real world, double-click
|
||||
* or press Enter to finish, type a label. Each finished shape goes through the
|
||||
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
|
||||
* shape (area, line or pin) and a colour, click the vertices on the real world,
|
||||
* double-click or press Enter to finish, then name, recolour, hide, fly to or
|
||||
* remove the shape from its row (drawMarks.js). Each finished shape goes through the
|
||||
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
|
||||
* supplied and `manual: true`, so it renders with the whiteboard look,
|
||||
* persists, de-dups, shows up in `.list()`, and clears with the board.
|
||||
@@ -49,41 +50,44 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
|
||||
|
||||
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const DRAW_POINTER_OWNER = 'draw';
|
||||
const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
|
||||
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
|
||||
const PREVIEW = {
|
||||
primary: '#8be9ff',
|
||||
amber: '#ffb547',
|
||||
cyan: '#39d0ff',
|
||||
green: '#5dff9f',
|
||||
red: '#ff6b6b',
|
||||
};
|
||||
const PREVIEW = DRAW_COLOR_CSS;
|
||||
|
||||
/**
|
||||
* Wire the Draw control. Returns a handle with a `destroy()` the application
|
||||
* Run the Draw control. Returns a handle with a `destroy()` the application
|
||||
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
|
||||
* The tool hands what the controls show (`{ active, shape, color, hint,
|
||||
* marks }`) to `publish`, which the view renders, and answers the controls
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* A row's fly-to is an immediate navigation through `navigation`, like
|
||||
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
|
||||
* @returns {{destroy: Function}|null}
|
||||
*/
|
||||
export function initDrawTool({ viewer, annotations }) {
|
||||
const toggle = document.getElementById('draw-toggle');
|
||||
const modeRow = document.getElementById('draw-mode-row');
|
||||
const labelRow = document.getElementById('draw-label-row');
|
||||
const labelInput = document.getElementById('draw-label-input');
|
||||
const colorSelect = document.getElementById('draw-color-select');
|
||||
const clearButton = document.getElementById('draw-clear');
|
||||
const hint = document.getElementById('draw-hint');
|
||||
if (!viewer || !annotations || !toggle) return null;
|
||||
export function initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation = null,
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
}) {
|
||||
// A page without the Draw controls (the Display controls view omitted) has
|
||||
// no draw tool.
|
||||
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
|
||||
return null;
|
||||
|
||||
let active = false;
|
||||
let destroyed = false;
|
||||
let session = null;
|
||||
let shape = 'area';
|
||||
let color = 'primary';
|
||||
let hint = '';
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
let savedDoubleClick = null;
|
||||
@@ -94,25 +98,13 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// one of those must not write its outcome over the newer state.
|
||||
let generation = 0;
|
||||
const previewEntities = [];
|
||||
// Every DOM listener this tool adds, so teardown is one loop rather than a
|
||||
// list that can fall out of step with the bindings below.
|
||||
// Every DOM listener this tool adds (the session's keydown), so teardown is
|
||||
// one loop rather than a list that can fall out of step with the bindings.
|
||||
const domListeners = [];
|
||||
const listen = (target, type, listener, options) => {
|
||||
if (!target) return;
|
||||
target.addEventListener(type, listener, options);
|
||||
domListeners.push([target, type, listener, options]);
|
||||
};
|
||||
// The board lives only in memory: switching language (a reload) asks first
|
||||
// while it holds a lasting mark or a shape is half drawn.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() => {
|
||||
if (destroyed) return null;
|
||||
const lasting = (annotations.list?.() || []).some(
|
||||
(mark) => mark && mark.ttlMs == null && !mark.expiring,
|
||||
);
|
||||
return lasting || session?.vertices?.length > 0
|
||||
? msg('drawings on the map')
|
||||
: null;
|
||||
});
|
||||
|
||||
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
|
||||
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
|
||||
@@ -123,9 +115,25 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
() => null,
|
||||
);
|
||||
|
||||
const present = () =>
|
||||
publish({
|
||||
active,
|
||||
shape,
|
||||
color,
|
||||
hint,
|
||||
marks: presentDrawMarks(annotations.list?.() || []),
|
||||
});
|
||||
// The hint is kept as the call that words it, so a language switch can
|
||||
// word it again.
|
||||
let hintText = () => '';
|
||||
const setHint = (text) => {
|
||||
if (hint) hint.textContent = text;
|
||||
hintText = text;
|
||||
hint = text();
|
||||
present();
|
||||
};
|
||||
const offLocale = onLocaleChange(() => {
|
||||
if (!destroyed) setHint(hintText);
|
||||
});
|
||||
|
||||
// ---- preview ---------------------------------------------------------
|
||||
const vertexPositions = () =>
|
||||
@@ -160,7 +168,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
previewEntities.length = 0;
|
||||
if (!session) {
|
||||
previewLine.show = false;
|
||||
setHint(drawHint(null));
|
||||
setHint(() => drawHint(null));
|
||||
viewer.scene.requestRender();
|
||||
return;
|
||||
}
|
||||
@@ -186,7 +194,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
setHint(drawHint(session));
|
||||
setHint(() => drawHint(session));
|
||||
viewer.scene.requestRender();
|
||||
};
|
||||
|
||||
@@ -209,14 +217,14 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// A click that changed nothing still deserves an answer when the reason is
|
||||
// a limit rather than the harmless tail of a double-click.
|
||||
if (reason === 'full')
|
||||
setHint(
|
||||
setHint(() =>
|
||||
t(
|
||||
'That shape already has {count} points — finish it or press Backspace.',
|
||||
{ count: MAX_VERTICES },
|
||||
),
|
||||
);
|
||||
else if (reason === 'invalid')
|
||||
setHint(t('That point is off the globe — click on the world.'));
|
||||
setHint(() => t('That point is off the globe — click on the world.'));
|
||||
};
|
||||
const onMove = (event) => {
|
||||
if (!session || session.shape === 'pin' || destroyed) return;
|
||||
@@ -234,17 +242,17 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
if (reason !== 'ok') {
|
||||
// 'too-few' is just "keep clicking" and the hint already says so;
|
||||
// a degenerate or off-globe shape needs to be told why it was refused.
|
||||
if (reason !== 'too-few') setHint(drawHint(session));
|
||||
if (reason !== 'too-few') setHint(() => drawHint(session));
|
||||
return null;
|
||||
}
|
||||
const spec = finishSpec(session, { label: labelInput?.value || '', color });
|
||||
// Named afterwards, in the shape's row.
|
||||
const spec = finishSpec(session, { label: '', color });
|
||||
if (!spec) return null;
|
||||
// Start a fresh session BEFORE awaiting, so a second Enter or the second
|
||||
// half of a double-click cannot submit the same shape twice.
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
if (labelInput) labelInput.value = '';
|
||||
const attempt = generation;
|
||||
try {
|
||||
const result = await annotations.annotate([spec], {
|
||||
@@ -252,11 +260,12 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
flyTo: false,
|
||||
});
|
||||
if (destroyed || attempt !== generation) return result;
|
||||
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
|
||||
if (result?.drawn === 0)
|
||||
setHint(() => t('That shape could not be placed.'));
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (destroyed || attempt !== generation) return null;
|
||||
setHint(
|
||||
setHint(() =>
|
||||
t('Could not place the shape: {error}', {
|
||||
error: error?.message || error,
|
||||
}),
|
||||
@@ -278,13 +287,13 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
if (session) session = createDrawSession(shape);
|
||||
annotations.clear();
|
||||
syncPreview();
|
||||
if (!destroyed && !active) setHint(t('Board cleared.'));
|
||||
if (!destroyed && !active) setHint(() => t('Board cleared.'));
|
||||
};
|
||||
|
||||
// ---- keys: only while drawing, never while typing in another field ----
|
||||
const typingElsewhere = (event) => {
|
||||
const t = event.target;
|
||||
if (!t || t === labelInput) return false;
|
||||
if (!t) return false;
|
||||
return (
|
||||
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
|
||||
);
|
||||
@@ -292,12 +301,12 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
/**
|
||||
* Enter is the finish key, but on a focused control Enter means "press this
|
||||
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
|
||||
* not finish the shape — so the finish key is only taken from the canvas,
|
||||
* the page body, or the label field this tool owns.
|
||||
* not finish the shape — so the finish key is only taken from the canvas or
|
||||
* the page body.
|
||||
*/
|
||||
const enterBelongsToDrawing = (event) => {
|
||||
const t = event.target;
|
||||
if (!t || t === labelInput) return true;
|
||||
if (!t) return true;
|
||||
if (
|
||||
typeof t.closest === 'function' &&
|
||||
t.closest('button, a, select, [role="button"], [role="radio"]')
|
||||
@@ -309,6 +318,9 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
};
|
||||
const onKey = (event) => {
|
||||
if (!active || destroyed || typingElsewhere(event)) return;
|
||||
// Keys on the shape list's own controls are the list's (Esc closes a
|
||||
// colour picker there), not the drawing's.
|
||||
if (event.target?.closest?.('#draw-mark-list')) return;
|
||||
// Enter goes to finish() once a shape has been started; finish() owns the
|
||||
// decision and says WHY it refused. Swallowing the key on a degenerate
|
||||
// shape left the person pressing Enter at a silent panel. A focused button
|
||||
@@ -327,7 +339,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
else setActive(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Backspace' && event.target !== labelInput) {
|
||||
if (event.key === 'Backspace') {
|
||||
if (removeLastVertex(session)) {
|
||||
event.preventDefault();
|
||||
syncPreview();
|
||||
@@ -344,7 +356,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// Somebody else is using the pointer — possibly an older instance of
|
||||
// this same tool that has not finished tearing down. Say so instead of
|
||||
// half-starting.
|
||||
setHint(
|
||||
setHint(() =>
|
||||
t('{owner} is using the pointer — close it first.', {
|
||||
owner: translateKnown(pointerOwnerName(pointerOwner())),
|
||||
}),
|
||||
@@ -353,10 +365,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}
|
||||
}
|
||||
active = next;
|
||||
toggle.classList.toggle('active', active);
|
||||
toggle.setAttribute('aria-pressed', String(active));
|
||||
modeRow?.classList.toggle('visible', active);
|
||||
labelRow?.classList.toggle('visible', active);
|
||||
present();
|
||||
document.body.classList.toggle('gev-drawing', active);
|
||||
if (active) {
|
||||
session = createDrawSession(shape);
|
||||
@@ -418,8 +427,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
);
|
||||
savedDoubleClick = null;
|
||||
}
|
||||
// Drop just the keydown listener bound alongside this handler; the control
|
||||
// listeners below outlive a session and come off in destroy().
|
||||
// Drop the keydown listener bound alongside this handler.
|
||||
for (let i = domListeners.length - 1; i >= 0; i -= 1) {
|
||||
const [target, type, listener, options] = domListeners[i];
|
||||
if (listener !== onKey) continue;
|
||||
@@ -428,38 +436,56 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}
|
||||
}
|
||||
|
||||
listen(toggle, 'click', () => setActive(!active));
|
||||
const shapeButtons = [
|
||||
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
|
||||
];
|
||||
for (const btn of shapeButtons) {
|
||||
listen(btn, 'click', () => {
|
||||
shape = normalizeShape(btn.dataset.shape);
|
||||
for (const other of shapeButtons) {
|
||||
const on = other === btn;
|
||||
other.classList.toggle('active', on);
|
||||
other.setAttribute('aria-checked', String(on));
|
||||
}
|
||||
if (active) {
|
||||
generation += 1;
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
|
||||
* and the current orbit, follow or track lets go first.
|
||||
*/
|
||||
function flyToMark(id) {
|
||||
if (typeof navigation?.runImmediateNavigation === 'function')
|
||||
return (
|
||||
navigation.runImmediateNavigation('drawing', () =>
|
||||
annotations.frame?.(id),
|
||||
) !== false
|
||||
);
|
||||
return annotations.frame?.(id) ?? false;
|
||||
}
|
||||
listen(colorSelect, 'change', () => {
|
||||
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
|
||||
syncPreview();
|
||||
});
|
||||
listen(clearButton, 'click', clearAll);
|
||||
listen(labelInput, 'keydown', (event) => {
|
||||
if (event.key === 'Enter' && session?.vertices?.length) {
|
||||
event.preventDefault();
|
||||
void finish();
|
||||
|
||||
/** Draw `next` from now on; an open shape starts over in it. */
|
||||
function chooseShape(next) {
|
||||
shape = normalizeShape(next);
|
||||
present();
|
||||
if (active) {
|
||||
generation += 1;
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
}
|
||||
}
|
||||
const disconnect = connect({
|
||||
toggleDraw: () => setActive(!active),
|
||||
setDrawShape: chooseShape,
|
||||
setDrawColor: (value) => {
|
||||
color = DRAW_COLORS.includes(value) ? value : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
clearDraw: clearAll,
|
||||
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
|
||||
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
|
||||
toggleDrawMark: (id) => {
|
||||
const mark = annotations.list?.().find((entry) => entry.id === id);
|
||||
if (mark) annotations.setHidden?.(id, !mark.hidden);
|
||||
},
|
||||
flyToDrawMark: flyToMark,
|
||||
removeDrawMark: (id) => annotations.remove?.(id),
|
||||
});
|
||||
setHint(drawHint(null));
|
||||
// The rows follow the board: a shape drawn, renamed, hidden or removed
|
||||
// here, by voice or by a clear.
|
||||
const offBoard =
|
||||
annotations.onChange?.(() => {
|
||||
if (!destroyed) present();
|
||||
}) || (() => {});
|
||||
setHint(() => drawHint(null));
|
||||
|
||||
const api = {
|
||||
get active() {
|
||||
@@ -472,12 +498,15 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
return session;
|
||||
},
|
||||
setActive,
|
||||
setShape(next) {
|
||||
const btn = modeRow?.querySelector(
|
||||
`.pp-mode-btn[data-shape="${normalizeShape(next)}"]`,
|
||||
);
|
||||
btn?.click();
|
||||
setShape: chooseShape,
|
||||
/** The hand-drawn marks the list shows, as its rows. */
|
||||
marks: () => presentDrawMarks(annotations.list?.() || []),
|
||||
setColor(next) {
|
||||
color = DRAW_COLORS.includes(next) ? next : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
flyToMark,
|
||||
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
||||
addVertex(lon, lat, height = 0) {
|
||||
if (!session) return false;
|
||||
@@ -526,7 +555,9 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
generation += 1;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
unregisterUnsavedWork();
|
||||
offLocale();
|
||||
offBoard();
|
||||
disconnect();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
@@ -546,11 +577,8 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
}
|
||||
});
|
||||
document.body.classList.remove('gev-drawing');
|
||||
toggle.classList.remove('active');
|
||||
toggle.setAttribute('aria-pressed', 'false');
|
||||
modeRow?.classList.remove('visible');
|
||||
labelRow?.classList.remove('visible');
|
||||
if (hint) hint.textContent = '';
|
||||
hint = '';
|
||||
present();
|
||||
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
|
||||
return attaching;
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
|
||||
const tools = read('src/app/tools.js');
|
||||
const engineAt = tools.indexOf('const annotations = initAnnotations({');
|
||||
const drawAt = tools.indexOf(
|
||||
'const drawTool = initDrawTool({ viewer, annotations });',
|
||||
'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
|
||||
);
|
||||
assert.ok(engineAt >= 0, 'initAnnotations call missing');
|
||||
assert.ok(
|
||||
@@ -62,37 +62,67 @@ test('the engine resolves manual geometry before any name resolution', () => {
|
||||
});
|
||||
|
||||
test('the Draw control markup carries the ids the tool binds to', () => {
|
||||
// The DISPLAY rail is a component template now, assembled into index.html at
|
||||
// build time — the markup is not in index.html any more.
|
||||
const html = read('src/ui/templates/display-controls.html');
|
||||
// UTILITIES ▸ Draw is a Vue view (DrawControls, with DrawMarkList for the
|
||||
// drawn shapes' rows), rendered into the Utilities panel.
|
||||
const html = [
|
||||
read('src/ui/views/panels/DrawControls.vue'),
|
||||
read('src/ui/views/panels/DrawMarkList.vue'),
|
||||
].join('\n');
|
||||
const ids = [
|
||||
'draw-toggle',
|
||||
'draw-mode-row',
|
||||
'draw-colors',
|
||||
'draw-label-row',
|
||||
'draw-label-input',
|
||||
'draw-color-select',
|
||||
'draw-clear',
|
||||
'draw-hint',
|
||||
'draw-mark-list',
|
||||
];
|
||||
for (const id of ids) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`id="${id}"`),
|
||||
`#${id} missing from index.html`,
|
||||
`#${id} missing from the Draw views`,
|
||||
);
|
||||
}
|
||||
for (const shape of ['area', 'line', 'pin']) {
|
||||
// Shapes are icons, named for screen readers and tooltips.
|
||||
for (const [shape, icon] of [
|
||||
['area', 'pentagon'],
|
||||
['line', 'timeline'],
|
||||
['pin', 'location_on'],
|
||||
]) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`data-shape="${shape}"`),
|
||||
new RegExp(`icon: '${icon}', attrs: \\{ 'data-shape': '${shape}' \\}`),
|
||||
`shape button ${shape} missing`,
|
||||
);
|
||||
}
|
||||
for (const id of ids) {
|
||||
assert.ok(
|
||||
read('src/ui/views/LayerPanelsView.vue').includes('<DrawControls />'),
|
||||
'the Utilities panel renders Draw',
|
||||
);
|
||||
assert.ok(
|
||||
!read('src/ui/views/panels/DisplayPanel.vue').includes('draw-toggle'),
|
||||
'Draw is no longer in DISPLAY',
|
||||
);
|
||||
// The rows and the hint render what the tool publishes; the controls
|
||||
// call the actions the tool connects to the display store.
|
||||
for (const action of [
|
||||
"act('toggleDraw')",
|
||||
"act('setDrawShape', $event)",
|
||||
"act('setDrawColor', $event)",
|
||||
"act('clearDraw')",
|
||||
"act('renameDrawMark', row.id, next)",
|
||||
"act('recolorDrawMark', row.id, color)",
|
||||
"act('toggleDrawMark', row.id)",
|
||||
"act('flyToDrawMark', row.id)",
|
||||
"act('removeDrawMark', row.id)",
|
||||
]) {
|
||||
assert.ok(html.includes(action), `the Draw views must call ${action}`);
|
||||
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
|
||||
assert.match(
|
||||
tool(),
|
||||
new RegExp(`getElementById\\('${id}'\\)`),
|
||||
`drawTool must bind #${id}`,
|
||||
new RegExp(`\\b${name}: `),
|
||||
`drawTool must answer ${name}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -102,20 +132,22 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
|
||||
// these rules lived in the root style.css. That file is an import shim now,
|
||||
// and putting rules back into it would silently undo the split.
|
||||
const shim = read('style.css');
|
||||
assert.ok(
|
||||
shim.split('\n').length < 40,
|
||||
'style.css is an import shim, not a stylesheet',
|
||||
);
|
||||
const rules = shim
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() && !/^@import '[^']+';$/.test(line.trim()));
|
||||
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
|
||||
assert.doesNotMatch(
|
||||
shim,
|
||||
/draw-hint|pp-text-input/,
|
||||
/draw-hint|draw-color/,
|
||||
'draw styles belong to a component stylesheet',
|
||||
);
|
||||
const controls = read('src/ui/styles/controls.css');
|
||||
for (const rule of [
|
||||
'#draw-mode-row',
|
||||
'#draw-label-row',
|
||||
'.pp-text-input',
|
||||
'.draw-color',
|
||||
'.draw-mark-name',
|
||||
'.draw-clear-btn',
|
||||
'.draw-hint',
|
||||
'body.gev-drawing',
|
||||
@@ -138,10 +170,14 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
|
||||
`${file} still claims a GeoJSON export`,
|
||||
);
|
||||
}
|
||||
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
|
||||
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
|
||||
for (const [file, heading] of [
|
||||
['README.md', 'UTILITIES ▸ **Draw**'],
|
||||
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
|
||||
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
|
||||
]) {
|
||||
const source = read(file);
|
||||
const at = source.indexOf('DISPLAY ▸ **Draw**');
|
||||
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
|
||||
const start = source.indexOf(heading);
|
||||
assert.ok(start >= 0, `${file} should describe the Draw control`);
|
||||
const section = source.slice(start, start + 1200);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -6,7 +6,6 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { initDrawTool } from './drawTool.js';
|
||||
import { describeUnsavedWork } from '../i18n/index.js';
|
||||
import {
|
||||
isPointerFree,
|
||||
pointerOwner,
|
||||
@@ -47,7 +46,6 @@ class FakeElement {
|
||||
this.attributes = new Map();
|
||||
this.textContent = '';
|
||||
this.value = '';
|
||||
this.children = [];
|
||||
this.listeners = [];
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
@@ -65,16 +63,6 @@ class FakeElement {
|
||||
);
|
||||
if (at >= 0) this.listeners.splice(at, 1);
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.pp-mode-btn[data-shape]')
|
||||
return this.children.filter((child) => child.dataset.shape);
|
||||
const shape = /\.pp-mode-btn\[data-shape="([a-z]+)"\]/.exec(selector)?.[1];
|
||||
if (shape) return this.children.filter((child) => child.dataset.shape === shape);
|
||||
return [];
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] ?? null;
|
||||
}
|
||||
click() {
|
||||
return this.emit('click');
|
||||
}
|
||||
@@ -108,18 +96,10 @@ function fakeDom() {
|
||||
byId.set(id, element);
|
||||
return element;
|
||||
};
|
||||
// The control whose presence means the page has Draw, and a focused button
|
||||
// whose Enter is its own.
|
||||
make('draw-toggle', 'BUTTON');
|
||||
const modeRow = make('draw-mode-row');
|
||||
for (const shape of ['area', 'line', 'pin']) {
|
||||
const button = new FakeElement('BUTTON');
|
||||
button.dataset.shape = shape;
|
||||
modeRow.children.push(button);
|
||||
}
|
||||
make('draw-label-row');
|
||||
make('draw-label-input', 'INPUT');
|
||||
make('draw-color-select', 'SELECT');
|
||||
make('draw-clear', 'BUTTON');
|
||||
make('draw-hint', 'SPAN');
|
||||
|
||||
const body = new FakeElement('BODY');
|
||||
const documentDouble = {
|
||||
@@ -195,9 +175,16 @@ function fakeViewer() {
|
||||
}
|
||||
|
||||
function fakeAnnotations() {
|
||||
const calls = { annotate: [], clear: 0 };
|
||||
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
|
||||
const marks = [];
|
||||
const listeners = new Set();
|
||||
const changed = () => listeners.forEach((listener) => listener());
|
||||
const find = (id) => marks.find((mark) => mark.id === id);
|
||||
return {
|
||||
calls,
|
||||
marks,
|
||||
listeners,
|
||||
changed,
|
||||
async annotate(specs, options) {
|
||||
calls.annotate.push({ specs, options });
|
||||
return { drawn: specs.length };
|
||||
@@ -205,11 +192,38 @@ function fakeAnnotations() {
|
||||
clear() {
|
||||
calls.clear += 1;
|
||||
},
|
||||
list: () => marks,
|
||||
onChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
restyle(id, change) {
|
||||
calls.restyle.push([id, change]);
|
||||
Object.assign(find(id), change);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
setHidden(id, hidden) {
|
||||
calls.setHidden.push([id, hidden]);
|
||||
find(id).hidden = hidden;
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
frame(id) {
|
||||
calls.frame.push(id);
|
||||
return true;
|
||||
},
|
||||
remove(id) {
|
||||
calls.remove.push(id);
|
||||
marks.splice(marks.indexOf(find(id)), 1);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness() {
|
||||
function harness({ navigation = null } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
const viewer = fakeViewer();
|
||||
@@ -218,14 +232,30 @@ function harness() {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.document = documentDouble;
|
||||
globalThis.window = globalThis.window || {};
|
||||
const tool = initDrawTool({ viewer, annotations });
|
||||
const shown = { view: null };
|
||||
// The controls' actions, as the display store holds them.
|
||||
const controls = { actions: null };
|
||||
const tool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation,
|
||||
publish: (view) => (shown.view = view),
|
||||
connect: (actions) => {
|
||||
controls.actions = actions;
|
||||
return () => (controls.actions = null);
|
||||
},
|
||||
});
|
||||
return {
|
||||
tool,
|
||||
shown,
|
||||
viewer,
|
||||
annotations,
|
||||
byId,
|
||||
documentDouble,
|
||||
element: (id) => byId.get(id),
|
||||
controls,
|
||||
/** A control's change, as the view sends it. */
|
||||
act: (name, ...args) => controls.actions?.[name]?.(...args),
|
||||
restore() {
|
||||
resetPointerOwnership();
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
@@ -241,38 +271,15 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
|
||||
|
||||
/* ── the tests ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// First in the file: tools left by later tests would answer the same registry.
|
||||
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
let marks = [];
|
||||
h.annotations.list = () => marks;
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
|
||||
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
|
||||
marks = [{ id: 'b', ttlMs: null, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
|
||||
marks = [];
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
|
||||
marks = [{ id: 'c', ttlMs: null, expiring: false }];
|
||||
await h.tool.destroy();
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('turning Draw on takes a lease and turning it off gives it back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
assert.equal(isPointerFree(), true);
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(pointerOwner(), 'draw', 'the session owns the pointer');
|
||||
assert.equal(h.tool.active, true);
|
||||
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(isPointerFree(), true, 'leaving draw mode frees the pointer');
|
||||
assert.equal(h.tool.active, false);
|
||||
} finally {
|
||||
@@ -284,18 +291,18 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
const h = harness();
|
||||
try {
|
||||
const other = claimPointer('directions');
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(h.tool.active, false, 'it must not half-start');
|
||||
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
||||
// Named by its tool, not by the internal owner id.
|
||||
assert.equal(
|
||||
h.element('draw-hint').textContent,
|
||||
h.shown.view.hint,
|
||||
'Directions is using the pointer — close it first.',
|
||||
);
|
||||
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
||||
|
||||
releasePointer(other);
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(h.tool.active, true, 'and it starts once the pointer is free');
|
||||
} finally {
|
||||
h.restore();
|
||||
@@ -305,13 +312,13 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
test('a destroyed instance cannot free the pointer its replacement holds', async () => {
|
||||
const first = harness();
|
||||
try {
|
||||
first.element('draw-toggle').emit('click');
|
||||
first.act('toggleDraw');
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
// The replacement takes over before the old one finishes tearing down.
|
||||
first.tool.setActive(false);
|
||||
const second = harness();
|
||||
try {
|
||||
second.element('draw-toggle').emit('click');
|
||||
second.act('toggleDraw');
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
await first.tool.destroy();
|
||||
assert.equal(
|
||||
@@ -336,7 +343,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
|
||||
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
undefined,
|
||||
@@ -347,7 +354,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
||||
undefined,
|
||||
);
|
||||
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
stockClick,
|
||||
@@ -365,7 +372,7 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
||||
test('Enter finishes from the canvas and the label field, never from a focused button', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.setShape('area');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
@@ -395,7 +402,7 @@ test('Enter finishes from the canvas and the label field, never from a focused b
|
||||
test('the live preview hangs at the height the click landed on', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.setShape('line');
|
||||
h.tool.addVertex(-97.75, 30.26, 187.5);
|
||||
h.tool.addVertex(-97.74, 30.26, 212.25);
|
||||
@@ -449,10 +456,11 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
try {
|
||||
const stockClick = () => 'viewer picks and selects';
|
||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.equal(pointerOwner(), 'draw');
|
||||
assert.ok(h.tool.diagnostics().domListeners > 0);
|
||||
assert.equal(h.annotations.listeners.size, 1, 'the rows follow the board');
|
||||
|
||||
await h.tool.destroy();
|
||||
await Promise.resolve();
|
||||
@@ -463,13 +471,14 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
assert.equal(after.sceneHandler, false, 'the Cesium handler is gone');
|
||||
assert.equal(after.domListeners, 0, 'every DOM listener came off');
|
||||
assert.equal(h.documentDouble.listeners.length, 0, 'including the keydown');
|
||||
assert.equal(h.element('draw-toggle').listeners.length, 0);
|
||||
assert.equal(h.controls.actions, null, 'and the controls are disconnected');
|
||||
assert.equal(
|
||||
h.viewer.screenSpaceEventHandler.getInputAction(LEFT_CLICK),
|
||||
stockClick,
|
||||
'the viewer got its selecting click back',
|
||||
);
|
||||
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
|
||||
assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board');
|
||||
|
||||
// Idempotent: a second destroy is harmless.
|
||||
await h.tool.destroy();
|
||||
@@ -481,15 +490,145 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
||||
test('Clear wipes the board and the shape in progress', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
assert.equal(h.tool.session.vertices.length, 2);
|
||||
|
||||
h.element('draw-clear').emit('click');
|
||||
h.act('clearDraw');
|
||||
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the controls choose the shape and colour the next shape is drawn with; it is named in its row', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.act('setDrawShape', 'line');
|
||||
h.act('setDrawColor', 'amber');
|
||||
assert.deepEqual(
|
||||
{ ...h.shown.view, hint: undefined },
|
||||
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
|
||||
'the view shows what the controls chose',
|
||||
);
|
||||
h.act('setDrawColor', 'purple');
|
||||
assert.equal(h.shown.view.color, 'primary', 'an unknown colour falls back');
|
||||
h.act('setDrawColor', 'cyan');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
// Choosing a shape again starts the open one over.
|
||||
h.act('setDrawShape', 'line');
|
||||
assert.equal(h.tool.session.vertices.length, 0);
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const spec = h.annotations.calls.annotate[0].specs[0];
|
||||
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
|
||||
assert.equal(spec.color, 'cyan');
|
||||
assert.ok(!spec.label, 'a shape is named afterwards, in its row');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('each drawn shape is a row whose actions reach the board, and fly-to is a navigation', async () => {
|
||||
const navigations = [];
|
||||
const h = harness({
|
||||
navigation: {
|
||||
runImmediateNavigation: (noun, navigate) => {
|
||||
navigations.push(noun);
|
||||
return navigate();
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const board = h.annotations;
|
||||
board.marks.push(
|
||||
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
|
||||
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
|
||||
);
|
||||
board.changed();
|
||||
assert.deepEqual(
|
||||
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
|
||||
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
|
||||
'only hand-drawn marks are listed, an unnamed line by its length',
|
||||
);
|
||||
h.act('renameDrawMark', 'a1', 'Ridge walk');
|
||||
h.act('recolorDrawMark', 'a1', 'green');
|
||||
h.act('toggleDrawMark', 'a1');
|
||||
h.act('flyToDrawMark', 'a1');
|
||||
h.act('removeDrawMark', 'a1');
|
||||
assert.deepEqual(board.calls.restyle, [
|
||||
['a1', { name: 'Ridge walk' }],
|
||||
['a1', { color: 'green' }],
|
||||
]);
|
||||
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
|
||||
assert.deepEqual(board.calls.frame, ['a1']);
|
||||
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
|
||||
assert.deepEqual(board.calls.remove, ['a1']);
|
||||
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Backspace on the page removes the last vertex', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const listButton = new FakeElement('BUTTON');
|
||||
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
|
||||
const elsewhere = new FakeElement('INPUT', 'location-search');
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
|
||||
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
|
||||
assert.equal(h.tool.active, true, 'and keeps Draw on');
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
|
||||
h.documentDouble.emit('keydown', { key: 'Escape', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a page without the Draw controls gets no draw tool', () => {
|
||||
resetPointerOwnership();
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
try {
|
||||
let connected = false;
|
||||
const tool = initDrawTool({
|
||||
viewer: fakeViewer(),
|
||||
annotations: fakeAnnotations(),
|
||||
connect: () => {
|
||||
connected = true;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
assert.equal(tool, null);
|
||||
assert.equal(connected, false);
|
||||
} finally {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recordWorkSlice } from '../sources/phaseTiming.js';
|
||||
import { createWorldAnnotationRenderer } from './worldAnnotationRenderer.js';
|
||||
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
|
||||
@@ -16,15 +17,19 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
* label-less) so every caption shares one consistent style.
|
||||
*
|
||||
* The hybrid surface honors add/update/remove/sync/destroy; update coordinates the
|
||||
* one route change that spans both sub-renderers.
|
||||
* one route change that spans both sub-renderers. `screen` passes options to the
|
||||
* screen renderer (its store, in tests).
|
||||
*/
|
||||
export function createHybridAnnotationRenderer(viewer) {
|
||||
export function createHybridAnnotationRenderer(
|
||||
viewer,
|
||||
{ screen: screenOptions } = {},
|
||||
) {
|
||||
const world = createWorldAnnotationRenderer(viewer);
|
||||
const screen = createScreenAnnotationRenderer(viewer);
|
||||
const screen = createScreenAnnotationRenderer(viewer, screenOptions);
|
||||
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
|
||||
const routed = new Map();
|
||||
|
||||
function add(anno) {
|
||||
function add(anno, options = {}) {
|
||||
const entry = {};
|
||||
// Route the entry FIRST, then fill it in. A sub-renderer throw (WebGL loss
|
||||
// mid-add) otherwise stranded whatever the earlier sub-renderer had already
|
||||
@@ -36,7 +41,20 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
|
||||
// Drape the footprint in world space, but without its own label...
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
world.add(entry.worldProxy);
|
||||
const completion = world.add(
|
||||
entry.worldProxy,
|
||||
worldOptions(anno, entry, options),
|
||||
);
|
||||
if (completion?.then) {
|
||||
if (options.pendingVisible !== false) {
|
||||
entry.screenProxy = liveProxy(anno, {
|
||||
ring: null,
|
||||
pendingOutline: true,
|
||||
});
|
||||
screen.add(entry.screenProxy);
|
||||
}
|
||||
return finishBuilding(anno, entry, completion, options);
|
||||
}
|
||||
// ...and place the caption as a screen-space callout at the centroid.
|
||||
if (anno.label) {
|
||||
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
||||
@@ -71,27 +89,45 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
* World geometry is new, but the reticle's SVG group is converted in place to
|
||||
* the centroid callout so there is no overlapping fade-out/fade-in pair.
|
||||
*/
|
||||
function update(anno) {
|
||||
function update(anno, options = {}) {
|
||||
const entry = routed.get(anno.id);
|
||||
if (!entry) {
|
||||
add(anno);
|
||||
return;
|
||||
return add(anno, options);
|
||||
}
|
||||
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
|
||||
if (anno.outlineUnavailable && entry.screenProxy)
|
||||
if (anno.outlineUnavailable && entry.screenProxy) {
|
||||
entry.screenProxy = liveProxy(anno, { ring: null });
|
||||
screen.update(entry.screenProxy);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.worldProxy) {
|
||||
entry.worldProxy = liveProxy(anno, { label: null });
|
||||
world.add(entry.worldProxy);
|
||||
const completion = world.add(
|
||||
entry.worldProxy,
|
||||
worldOptions(anno, entry, options),
|
||||
);
|
||||
if (completion?.then) {
|
||||
if (entry.screenProxy) {
|
||||
const pending = liveProxy(anno, { ring: null, pendingOutline: true });
|
||||
screen.update(pending);
|
||||
entry.screenProxy = pending;
|
||||
}
|
||||
return finishBuilding(anno, entry, completion, options);
|
||||
}
|
||||
}
|
||||
|
||||
updateCaption(anno, entry);
|
||||
}
|
||||
|
||||
function updateCaption(anno, entry) {
|
||||
const screenProxy = liveProxy(anno, { type: 'label', ring: null });
|
||||
if (entry.screenProxy) {
|
||||
screen.update(screenProxy, { empty: !anno.label });
|
||||
} else if (anno.label) {
|
||||
// Publish before add: a partially created caption must remain removable.
|
||||
entry.screenProxy = screenProxy;
|
||||
screen.add(screenProxy);
|
||||
}
|
||||
entry.screenProxy = entry.screenProxy
|
||||
@@ -101,6 +137,106 @@ export function createHybridAnnotationRenderer(viewer) {
|
||||
: null;
|
||||
}
|
||||
|
||||
function finishBuilding(anno, entry, completion, options) {
|
||||
const workTrace = options.workTrace;
|
||||
const proxy = entry.worldProxy;
|
||||
return completion
|
||||
.then((result) => {
|
||||
const workStart = workTrace ? performance.now() : 0;
|
||||
try {
|
||||
if (
|
||||
routed.get(anno.id) !== entry ||
|
||||
entry.worldProxy !== proxy ||
|
||||
options.signal?.aborted ||
|
||||
!(options.isCurrent?.() ?? true)
|
||||
) {
|
||||
world.remove(proxy);
|
||||
if (routed.get(anno.id) === entry) remove(anno);
|
||||
if (workTrace) workTrace.rendererStatus = 'cancelled';
|
||||
return { ...result, status: 'cancelled' };
|
||||
}
|
||||
if (result.status !== 'installed') {
|
||||
world.remove(proxy);
|
||||
entry.worldProxy = null;
|
||||
if (workTrace) workTrace.rendererStatus = result.status;
|
||||
return result;
|
||||
}
|
||||
if (performance.now() >= result.timing.deadlineMs) {
|
||||
world.remove(proxy);
|
||||
entry.worldProxy = null;
|
||||
if (workTrace) workTrace.rendererStatus = 'timeout';
|
||||
return { ...result, status: 'timeout' };
|
||||
}
|
||||
const at = performance.now();
|
||||
try {
|
||||
updateCaption(anno, entry);
|
||||
} finally {
|
||||
const ms = performance.now() - at;
|
||||
result.timing.workMs += ms;
|
||||
result.timing.lastSliceMs += ms;
|
||||
result.timing.maxSliceMs = Math.max(
|
||||
result.timing.maxSliceMs,
|
||||
result.timing.lastSliceMs,
|
||||
);
|
||||
}
|
||||
if (performance.now() >= result.timing.deadlineMs) {
|
||||
world.remove(proxy);
|
||||
entry.worldProxy = null;
|
||||
if (options.pendingVisible === false) {
|
||||
if (entry.screenProxy) screen.remove(entry.screenProxy);
|
||||
entry.screenProxy = null;
|
||||
} else {
|
||||
// The caption conversion already reshaped the group; redraw the pending point.
|
||||
const pending = liveProxy(anno, {
|
||||
ring: null,
|
||||
pendingOutline: true,
|
||||
});
|
||||
if (entry.screenProxy) screen.remove(entry.screenProxy);
|
||||
entry.screenProxy = pending;
|
||||
screen.add(pending);
|
||||
}
|
||||
if (workTrace) workTrace.rendererStatus = 'timeout';
|
||||
return { ...result, status: 'timeout' };
|
||||
}
|
||||
if (workTrace) workTrace.rendererStatus = result.status;
|
||||
return result;
|
||||
} finally {
|
||||
if (workTrace)
|
||||
recordWorkSlice(workTrace, 'hybrid-complete', workStart);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const workStart = workTrace ? performance.now() : 0;
|
||||
try {
|
||||
// A later caption throw must roll back the already installed world parts too.
|
||||
if (routed.get(anno.id) === entry) {
|
||||
try {
|
||||
remove(anno);
|
||||
} catch {
|
||||
/* original error wins; failed routes stay addressable */
|
||||
}
|
||||
} else world.remove(proxy);
|
||||
throw error;
|
||||
} finally {
|
||||
if (workTrace) {
|
||||
workTrace.rendererStatus = 'failed';
|
||||
recordWorkSlice(workTrace, 'hybrid-rollback', workStart);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function worldOptions(anno, entry, options) {
|
||||
const proxy = entry.worldProxy;
|
||||
return {
|
||||
...options,
|
||||
isCurrent: () =>
|
||||
routed.get(anno.id) === entry &&
|
||||
entry.worldProxy === proxy &&
|
||||
(options.isCurrent?.() ?? true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Release both routes. Tolerates PARTIAL state (only one sub-renderer got
|
||||
* its content) and an absent id, so it doubles as the rollback path for a
|
||||
|
||||
@@ -1,143 +1,25 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor(owner) {
|
||||
this.owner = owner;
|
||||
this.names = new Set();
|
||||
}
|
||||
|
||||
reset(value) {
|
||||
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
for (const name of names) this.names.add(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
for (const name of names) this.names.delete(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.names.has(name);
|
||||
}
|
||||
/** A hybrid renderer whose screen board is presented to its own store. */
|
||||
function hybridWithBoard(viewer) {
|
||||
const store = useScreenAnnotationsStore(createPinia());
|
||||
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
|
||||
return { store, renderer };
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.style = {};
|
||||
this.classList = new FakeClassList(this);
|
||||
this.textContent = '';
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'class') this.classList.reset(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.setAttribute('class', value);
|
||||
}
|
||||
|
||||
get className() {
|
||||
return this.getAttribute('class') || '';
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
this.setAttribute('id', value);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.getAttribute('id') || '';
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child);
|
||||
if (index >= 0) this.children.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.parentNode?.removeChild(this);
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : null;
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (className && child.classList.contains(className)) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
|
||||
getBBox() {
|
||||
return { x: 0, y: 0, width: 40, height: 16 };
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
}
|
||||
/** The screen marks the board shows, fading ones included. */
|
||||
function annotationGroups(store) {
|
||||
return store.board?.marks ?? [];
|
||||
}
|
||||
|
||||
function fakeDocument() {
|
||||
const head = new FakeElement('head');
|
||||
const body = new FakeElement('body');
|
||||
const roots = [head, body];
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createElementNS: (_namespace, tag) => new FakeElement(tag),
|
||||
getElementById(id) {
|
||||
let found = null;
|
||||
const visit = (node) => {
|
||||
if (node.getAttribute('id') === id) found = node;
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findAnnotationGroup(document) {
|
||||
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
|
||||
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
|
||||
return {
|
||||
svg,
|
||||
group: svg.children.find((child) => child.classList.contains('gev-anno')),
|
||||
};
|
||||
}
|
||||
const childrenWithClass = (mark, name) =>
|
||||
mark.children.filter((child) => child.attrs?.class?.split(' ').includes(name));
|
||||
|
||||
function cameraLookingAt(lon, lat) {
|
||||
const target = Cesium.Cartesian3.fromDegrees(lon, lat);
|
||||
@@ -184,7 +66,6 @@ function cameraLookingAt(lon, lat) {
|
||||
|
||||
/** Install the browser globals both renderers touch, restored after the test. */
|
||||
function installBrowserGlobals(t) {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalBrowserConstructors = new Map([
|
||||
@@ -193,13 +74,10 @@ function installBrowserGlobals(t) {
|
||||
['ImageBitmap', globalThis.ImageBitmap],
|
||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||
]);
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
|
||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
@@ -213,19 +91,31 @@ function installBrowserGlobals(t) {
|
||||
|
||||
/** A viewer whose scene/camera is just enough for both sub-renderers. */
|
||||
function fakeViewer(lon, lat) {
|
||||
const credits = new Set();
|
||||
const camera = cameraLookingAt(lon, lat);
|
||||
const dataSources = [];
|
||||
const frames = controlledFrames();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight: (world) => world,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
postRender: frames.event,
|
||||
renderRequests: 0,
|
||||
requestRender() {
|
||||
scene.renderRequests += 1;
|
||||
},
|
||||
};
|
||||
return {
|
||||
dataSources,
|
||||
frames,
|
||||
viewer: {
|
||||
credits,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.add(credit),
|
||||
removeStaticCredit: (credit) => credits.delete(credit),
|
||||
},
|
||||
scene,
|
||||
camera,
|
||||
trackedEntity: null,
|
||||
@@ -244,8 +134,30 @@ function fakeViewer(lon, lat) {
|
||||
};
|
||||
}
|
||||
|
||||
function annotationGroups(svg) {
|
||||
return svg.children.filter((child) => child.classList.contains('gev-anno'));
|
||||
function controlledFrames() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
event: {
|
||||
addEventListener(listener) { listeners.add(listener); },
|
||||
removeEventListener(listener) { listeners.delete(listener); },
|
||||
},
|
||||
emit() { for (const listener of [...listeners]) listener(); },
|
||||
snapshot() { return [...listeners]; },
|
||||
get size() { return listeners.size; },
|
||||
};
|
||||
}
|
||||
|
||||
function buildingAnnotation(id, offset = 0) {
|
||||
const ring = [
|
||||
[-99 + offset, 31], [-98.999 + offset, 31],
|
||||
[-98.999 + offset, 31.001], [-99 + offset, 31.001],
|
||||
[-99 + offset, 31],
|
||||
];
|
||||
return {
|
||||
id, type: 'area', color: 'primary', label: null, alpha: 1,
|
||||
anchor: { ...naiveRingCentroid(ring), height: 100 },
|
||||
ring, footprintKind: 'building', buildingHeight: 20, outlineCredit: 'tiles',
|
||||
};
|
||||
}
|
||||
|
||||
function naiveRingCentroid(ring) {
|
||||
@@ -256,23 +168,23 @@ function naiveRingCentroid(ring) {
|
||||
return { lon: totals.lon / ring.length, lat: totals.lat / ring.length, height: 0 };
|
||||
}
|
||||
|
||||
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
test('hybrid outline upgrade preserves the screen group and adds world geometry', async (t) => {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
|
||||
const originalBrowserConstructors = new Map([
|
||||
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
|
||||
['HTMLImageElement', globalThis.HTMLImageElement],
|
||||
['ImageBitmap', globalThis.ImageBitmap],
|
||||
['OffscreenCanvas', globalThis.OffscreenCanvas],
|
||||
]);
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
for (const name of originalBrowserConstructors.keys()) globalThis[name] = class {};
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
for (const [name, original] of originalBrowserConstructors) {
|
||||
if (original === undefined) delete globalThis[name];
|
||||
else globalThis[name] = original;
|
||||
@@ -281,13 +193,15 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
|
||||
const camera = cameraLookingAt(-99, 31);
|
||||
const dataSources = [];
|
||||
const frames = controlledFrames();
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight: (world) => world,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
postRender: frames.event,
|
||||
requestRender() {},
|
||||
};
|
||||
const viewer = {
|
||||
scene,
|
||||
@@ -305,7 +219,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const anno = {
|
||||
id: 'anno-hybrid-fb3',
|
||||
type: 'area',
|
||||
@@ -319,10 +233,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
};
|
||||
|
||||
renderer.add(anno);
|
||||
const before = findAnnotationGroup(globalThis.document);
|
||||
const originalCallout = before.group.querySelector('.gev-anno-callout');
|
||||
const originalDot = before.group.querySelector('.gev-anno-dot');
|
||||
assert.equal(before.group.querySelectorAll('.gev-anno-ring').length, 2);
|
||||
const [before] = annotationGroups(store);
|
||||
assert.equal(childrenWithClass(before, 'gev-anno-ring').length, 2);
|
||||
assert.equal(dataSources.length, 1);
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
|
||||
@@ -339,23 +251,83 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
anno.footprintKind = 'area';
|
||||
renderer.update(anno);
|
||||
|
||||
const after = findAnnotationGroup(globalThis.document);
|
||||
const [after, ...others] = annotationGroups(store);
|
||||
const expectedWindow = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
Cesium.Cartesian3.fromDegrees(centroid.lon, centroid.lat, centroid.height),
|
||||
);
|
||||
assert.equal(after.group, before.group, 'the real hybrid keeps the existing SVG group');
|
||||
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
||||
assert.equal(after.group.querySelectorAll('.gev-anno-ring').length, 0, 'screen reticle rings are removed');
|
||||
assert.equal(after.group.querySelector('.gev-anno-callout'), originalCallout, 'the callout node is retained');
|
||||
assert.equal(after.group.querySelector('.gev-anno-dot'), originalDot, 'the anchor dot is retained');
|
||||
assert.equal(originalDot.getAttribute('cx'), expectedWindow.x.toFixed(1));
|
||||
assert.equal(originalDot.getAttribute('cy'), expectedWindow.y.toFixed(1));
|
||||
assert.equal(after.key, before.key, 'the real hybrid keeps the existing screen mark');
|
||||
assert.equal(others.length, 0);
|
||||
assert.equal(childrenWithClass(after, 'gev-anno-ring').length, 0, 'screen reticle rings are removed');
|
||||
assert.deepEqual(
|
||||
after.children.map((child) => child.key),
|
||||
['dot', 'leader', 'callout'],
|
||||
'the dot, leader and callout are retained',
|
||||
);
|
||||
const dot = after.children.find((child) => child.key === 'dot');
|
||||
assert.equal(dot.attrs.cx, expectedWindow.x.toFixed(1));
|
||||
assert.equal(dot.attrs.cy, expectedWindow.y.toFixed(1));
|
||||
assert.deepEqual(centroid, { lon: -100.8, lat: 29.4, height: 0 }, 'closed-ring vertex mean stays pinned');
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'world area adds one fill and one outline');
|
||||
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
|
||||
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polyline).length, 1);
|
||||
assert.equal(Object.hasOwn(anno, '_entities'), false, 'world entity state stays on the inherited proxy');
|
||||
const outer = [
|
||||
[-99, 31],
|
||||
[-98.999, 31],
|
||||
[-98.999, 31.001],
|
||||
[-99, 31.001],
|
||||
[-99, 31],
|
||||
];
|
||||
const hole = [
|
||||
[-98.9998, 31.0002],
|
||||
[-98.9996, 31.0002],
|
||||
[-98.9996, 31.0004],
|
||||
[-98.9998, 31.0004],
|
||||
[-98.9998, 31.0002],
|
||||
];
|
||||
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
|
||||
const building = {
|
||||
...anno,
|
||||
id: 'anno-multipart-building',
|
||||
label: 'Building',
|
||||
anchor: { lon: -99, lat: 31, height: 0 },
|
||||
ring: outer,
|
||||
polygons: [[outer, hole], [island]],
|
||||
footprintKind: 'building',
|
||||
buildingHeight: 20,
|
||||
};
|
||||
const completion = renderer.add(building);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'building geometry waits for all probes');
|
||||
for (let frame = 0; frame < 8; frame += 1) frames.emit();
|
||||
assert.equal((await completion).status, 'installed');
|
||||
const added = dataSources[0].entities.values.slice(2);
|
||||
const fills = added.filter(
|
||||
(entity) => entity.polygon && entity.polygon.fill?.getValue() !== false,
|
||||
);
|
||||
assert.equal(
|
||||
fills.length,
|
||||
2,
|
||||
'every building part gets a classification volume',
|
||||
);
|
||||
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
|
||||
assert.equal(fills[1].polygon.hierarchy.getValue().holes.length, 0);
|
||||
assert.equal(
|
||||
fills[0].polygon.material,
|
||||
fills[1].polygon.material,
|
||||
'parts share the fill material',
|
||||
);
|
||||
assert.equal(
|
||||
added.filter((entity) => entity.polyline).length,
|
||||
3,
|
||||
'all outer and hole outlines are retained',
|
||||
);
|
||||
renderer.remove(building);
|
||||
assert.equal(
|
||||
dataSources[0].entities.values.length,
|
||||
2,
|
||||
'multipart removal leaves only the earlier area',
|
||||
);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
@@ -370,7 +342,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
test('a sub-renderer throw mid-add leaves state the rollback can still remove', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-partial-add',
|
||||
@@ -386,20 +358,19 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
|
||||
|
||||
// The world drape lands first; the screen caption then fails the way a lost
|
||||
// context does, AFTER the world geometry is already on the board.
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
const appendChild = svg.appendChild.bind(svg);
|
||||
const publish = store.publish;
|
||||
let failNextInsert = true;
|
||||
svg.appendChild = (child) => {
|
||||
store.publish = (board) => {
|
||||
if (failNextInsert) {
|
||||
failNextInsert = false;
|
||||
throw new Error('screen insert failed');
|
||||
}
|
||||
return appendChild(child);
|
||||
return publish(board);
|
||||
};
|
||||
|
||||
assert.throws(() => renderer.add(anno), /screen insert failed/);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'the world drape is already live');
|
||||
assert.equal(annotationGroups(svg).length, 0, 'the half-built screen group detached itself');
|
||||
assert.equal(annotationGroups(store).length, 0, 'the half-built screen group detached itself');
|
||||
|
||||
// The engine's rollback path — it must reach the partial state by id.
|
||||
renderer.remove(anno);
|
||||
@@ -409,7 +380,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
|
||||
// an orphan nothing owns.
|
||||
renderer.add(anno);
|
||||
assert.equal(dataSources[0].entities.values.length, 2, 'the retry draws exactly one world drape');
|
||||
assert.equal(annotationGroups(svg).length, 1, 'and exactly one screen caption');
|
||||
assert.equal(annotationGroups(store).length, 1, 'and exactly one screen caption');
|
||||
renderer.remove(anno);
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
renderer.destroy();
|
||||
@@ -418,7 +389,7 @@ test('a sub-renderer throw mid-add leaves state the rollback can still remove',
|
||||
test('a release that throws keeps the mark addressable for a retry cleanup', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-release-throw',
|
||||
@@ -433,35 +404,34 @@ test('a release that throws keeps the mark addressable for a retry cleanup', (t)
|
||||
};
|
||||
|
||||
renderer.add(anno);
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(dataSources[0].entities.values.length, 2);
|
||||
assert.equal(annotationGroups(svg).length, 1);
|
||||
assert.equal(annotationGroups(store).length, 1);
|
||||
|
||||
// The world route releases fine, then the screen release fails part-way
|
||||
// (its fade-out timer throws) — so its SVG group is still in the document.
|
||||
// (its fade-out timer throws) — so its mark is still on the board.
|
||||
const workingSetTimeout = globalThis.window.setTimeout;
|
||||
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
|
||||
assert.throws(() => renderer.remove(anno), /teardown scheduling failed/);
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'the world route did release');
|
||||
assert.equal(annotationGroups(svg).length, 1, 'the screen route did NOT — that is the orphan');
|
||||
assert.equal(annotationGroups(store).length, 1, 'the screen route did NOT — that is the orphan');
|
||||
|
||||
// Dropping the routing entry here would make that orphan permanently
|
||||
// unreachable. It must still be addressable, so a retry finishes the job.
|
||||
globalThis.window.setTimeout = workingSetTimeout;
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(svg).length, 0, 'the retry cleanup must reach the orphan');
|
||||
assert.equal(annotationGroups(store).length, 0, 'the retry cleanup must reach the orphan');
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'without double-releasing the world route');
|
||||
|
||||
// Fully released now — a third call is inert.
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(svg).length, 0);
|
||||
assert.equal(annotationGroups(store).length, 0);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('a world add that fails mid-way leaves its landed entities removable', (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const ring = [[-106, 25], [-93, 25], [-93, 36], [-106, 36], [-106, 25]];
|
||||
const anno = {
|
||||
id: 'anno-partial-world-add',
|
||||
@@ -493,9 +463,324 @@ test('a world add that fails mid-way leaves its landed entities removable', (t)
|
||||
assert.equal(entities.values.length, 0,
|
||||
'the rollback must see the entities that landed before the throw');
|
||||
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
renderer.add(anno);
|
||||
assert.equal(entities.values.length, 2, 'the retry draws a complete mark');
|
||||
assert.equal(annotationGroups(svg).length, 1);
|
||||
assert.equal(annotationGroups(store).length, 1);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
function workTrace() {
|
||||
return { timeOrigin: performance.timeOrigin, slices: [], dropped: 0 };
|
||||
}
|
||||
|
||||
test('buildings share one probe per frame and preserve probe geometry, filtering and fallback', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
t.after(() => renderer.destroy());
|
||||
const first = buildingAnnotation('sampled-building');
|
||||
const second = buildingAnnotation('fallback-building', 0.01);
|
||||
second.outlineCredit = 'osm';
|
||||
const otherOwner = {};
|
||||
showOsmCredit(viewer, otherOwner);
|
||||
const probes = [];
|
||||
const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90];
|
||||
viewer.scene.clampToHeight = (position) => {
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(position);
|
||||
const lon = Cesium.Math.toDegrees(cartographic.longitude);
|
||||
const lat = Cesium.Math.toDegrees(cartographic.latitude);
|
||||
probes.push([lon, lat]);
|
||||
const outcome = outcomes[probes.length - 1];
|
||||
if (outcome instanceof Error) throw outcome;
|
||||
return outcome == null ? undefined : Cesium.Cartesian3.fromDegrees(lon, lat, outcome);
|
||||
};
|
||||
// Suppress screen points: these counts concern the eight outside-building
|
||||
// probes, not the screen renderer's separate anchor-height sampling.
|
||||
const firstTrace = workTrace();
|
||||
const secondTrace = workTrace();
|
||||
let diagnosticWriteFaults = 0;
|
||||
Object.defineProperty(secondTrace, 'groundDetailDropped', {
|
||||
get: () => 0,
|
||||
set() {
|
||||
diagnosticWriteFaults += 1;
|
||||
throw new Error('diagnostic sink unavailable');
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
const firstCompletion = renderer.add(first, { pendingVisible: false, workTrace: firstTrace });
|
||||
const secondCompletion = renderer.add(second, { pendingVisible: false, workTrace: secondTrace });
|
||||
assert.equal(probes.length, 0, 'enqueue must not probe synchronously');
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
|
||||
'pending outlines add no tile credit to the other owner');
|
||||
for (let frame = 0; frame < 16; frame += 1) {
|
||||
const before = probes.length;
|
||||
frames.emit();
|
||||
assert.equal(probes.length, before + 1, 'the two buildings share one total probe budget');
|
||||
if (frame < 7) assert.equal(dataSources[0].entities.values.length, 0, 'no partial world parts');
|
||||
}
|
||||
const [installed, fallback] = await Promise.all([firstCompletion, secondCompletion]);
|
||||
assert.equal(installed.status, 'installed');
|
||||
assert.equal(fallback.status, 'installed');
|
||||
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 2);
|
||||
assert.equal(installed.timing.probes, 8);
|
||||
assert.equal(fallback.timing.probes, 8);
|
||||
assert.ok(diagnosticWriteFaults > 0, 'the diagnostic failure was exercised');
|
||||
for (const trace of [firstTrace, secondTrace]) {
|
||||
assert.equal(trace.probes, 8);
|
||||
assert.equal(trace.samplerStatus, 'installed');
|
||||
assert.equal(trace.rendererStatus, 'installed');
|
||||
assert.equal(trace.slices.filter(s => s.phase === 'ground-step').length, 8);
|
||||
assert.equal(trace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
|
||||
assert.equal(trace.dropped, 0);
|
||||
assert.ok(trace.slices.every(s => Number.isFinite(s.start) && Number.isFinite(s.end) && s.end >= s.start));
|
||||
assert.equal(trace.timeOrigin, performance.timeOrigin);
|
||||
const parents = trace.slices.filter(s => s.phase === 'ground-step');
|
||||
for (const [index, parent] of parents.entries()) {
|
||||
const detail = parent.detail;
|
||||
assert.equal(detail.index, index);
|
||||
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
|
||||
assert.equal(detail.clampResult,
|
||||
trace === firstTrace && index === 4 ? 'threw' : 'returned');
|
||||
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
|
||||
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
|
||||
if (index === 7) {
|
||||
assert.equal(detail.commitStatus, 'attempted');
|
||||
assert.ok(Number.isFinite(detail.commitStart) && Number.isFinite(detail.commitEnd));
|
||||
assert.ok(detail.clampEnd <= detail.commitStart && detail.commitStart <= detail.commitEnd && detail.commitEnd <= parent.end);
|
||||
} else {
|
||||
assert.equal(detail.commitStatus, 'not-run');
|
||||
assert.equal(detail.commitStart, null);
|
||||
assert.equal(detail.commitEnd, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.notEqual(firstTrace.slices, secondTrace.slices);
|
||||
assert.ok(firstTrace.slices.filter(s => s.phase === 'ground-step').at(-1).end <= secondTrace.slices.find(s => s.phase === 'ground-step').start, 'each queued task records only its own sampling frames');
|
||||
const centroid = naiveRingCentroid(first.ring);
|
||||
const lonScale = 111320 * Math.cos(Cesium.Math.toRadians(centroid.lat));
|
||||
const radius = Math.max(...first.ring.map(([lon, lat]) =>
|
||||
Math.hypot((lon - centroid.lon) * lonScale, (lat - centroid.lat) * 111320)));
|
||||
const distance = radius * 1.5 + 12;
|
||||
const directions = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const [dx, dy] = directions[index];
|
||||
assert.ok(Math.abs(probes[index][0] - (centroid.lon + dx * distance / lonScale)) < 1e-10);
|
||||
assert.ok(Math.abs(probes[index][1] - (centroid.lat + dy * distance / 111320)) < 1e-10);
|
||||
assert.ok(Math.abs(probes[index + 8][0] - probes[index][0] - 0.01) < 1e-10);
|
||||
assert.ok(Math.abs(probes[index + 8][1] - probes[index][1]) < 1e-10);
|
||||
}
|
||||
assert.equal(new Set(probes.slice(0, 8).map((point) => point.join(','))).size, 8);
|
||||
const fills = dataSources[0].entities.values.filter((entity) => entity.polygon?.fill?.getValue() !== false && entity.polygon);
|
||||
assert.equal(fills.length, 2);
|
||||
assert.ok(Math.abs(fills[0].polygon.height.getValue() - 57) < 1e-6, 'four accepted samples select floor(4 * 0.3), height 60 minus 3');
|
||||
assert.ok(Math.abs(fills[1].polygon.height.getValue() - 77) < 1e-6, 'all unavailable probes retain anchor minus building height fallback');
|
||||
const afterCompletion = probes.length;
|
||||
frames.emit();
|
||||
assert.equal(probes.length, afterCompletion, 'completion releases the sampler');
|
||||
renderer.remove(first);
|
||||
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
|
||||
'removing the last tile proxy retains the OSM building and other owner');
|
||||
renderer.remove(second);
|
||||
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1);
|
||||
hideOsmCredit(viewer, otherOwner);
|
||||
assert.equal(viewer.credits.size, 0);
|
||||
});
|
||||
|
||||
test('a building queued behind others gets its full budget on a slow device', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
t.after(() => t.mock.timers.reset());
|
||||
// The renderers' per-frame budget checks read performance.now(); it follows
|
||||
// the same mocked clock as the timers.
|
||||
let now = 0;
|
||||
t.mock.method(globalThis.performance, 'now', () => now);
|
||||
const { viewer, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
viewer.scene.clampToHeight = (position) => position;
|
||||
const traces = [workTrace(), workTrace(), workTrace()];
|
||||
const completions = [0, 0.01, 0.02].map((offset, index) =>
|
||||
renderer.add(buildingAnnotation(`queued-${index}`, offset), {
|
||||
pendingVisible: false,
|
||||
workTrace: traces[index],
|
||||
}));
|
||||
// Eight probes per building at one probe per frame, at about 5 fps: each
|
||||
// building samples for 1.5 s, so the third waits 3 s in line. Pending
|
||||
// promise work runs between frames, as it does in a browser.
|
||||
for (let building = 0; building < 3; building += 1) {
|
||||
for (let frame = 0; frame < 8; frame += 1) {
|
||||
now += 187.5;
|
||||
t.mock.timers.tick(187.5);
|
||||
frames.emit();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(completions);
|
||||
assert.deepEqual(results.map((result) => result.status), ['installed', 'installed', 'installed']);
|
||||
assert.deepEqual(traces.map((trace) => trace.samplerStatus), ['installed', 'installed', 'installed']);
|
||||
assert.ok(traces[2].samplerHeadAt - traces[2].samplerStart >= 2000, 'the third waited longer than one budget');
|
||||
assert.ok(traces[2].samplerHeadAt >= traces[1].samplerHeadAt);
|
||||
});
|
||||
|
||||
test('building cancellation, queued deadline and destroy prevent saved callbacks from committing', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
t.after(() => t.mock.timers.reset());
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
let probeCount = 0;
|
||||
viewer.scene.clampToHeight = (position) => { probeCount += 1; return position; };
|
||||
const cancelled = buildingAnnotation('cancelled-building');
|
||||
const cancelledTrace = workTrace();
|
||||
const cancelledCompletion = renderer.add(cancelled, { pendingVisible: false, workTrace: cancelledTrace });
|
||||
const savedCancelled = frames.snapshot();
|
||||
renderer.remove(cancelled);
|
||||
assert.equal((await cancelledCompletion).status, 'cancelled');
|
||||
assert.equal(cancelledTrace.samplerStatus, 'cancelled');
|
||||
assert.equal(cancelledTrace.rendererStatus, 'cancelled');
|
||||
assert.equal(cancelledTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
|
||||
for (const listener of savedCancelled) listener();
|
||||
assert.equal(probeCount, 0);
|
||||
assert.equal(viewer.credits.size, 0);
|
||||
|
||||
const head = buildingAnnotation('deadline-head');
|
||||
const tail = buildingAnnotation('deadline-tail', 0.01);
|
||||
const headCompletion = renderer.add(head, { pendingVisible: false });
|
||||
const tailTrace = workTrace();
|
||||
const tailCompletion = renderer.add(tail, { pendingVisible: false, workTrace: tailTrace });
|
||||
frames.emit();
|
||||
assert.equal(probeCount, 1);
|
||||
const savedTimeout = frames.snapshot();
|
||||
// No further renderer frames arrive: the head's deadline fires, then the
|
||||
// tail gets its own budget from the moment it reaches the head and still
|
||||
// times out without frames, even though it never had a chance to sample.
|
||||
const rendersBefore = viewer.scene.renderRequests;
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal((await headCompletion).status, 'timeout');
|
||||
assert.ok(viewer.scene.renderRequests > rendersBefore, 'a promoted tail asks for a frame');
|
||||
assert.equal(tailTrace.samplerStatus, undefined, 'queue wait does not spend the tail budget');
|
||||
assert.ok(Number.isFinite(tailTrace.samplerHeadAt), 'the tail starts its own budget at the head');
|
||||
t.mock.timers.tick(2000);
|
||||
const tailResult = await tailCompletion;
|
||||
assert.equal(tailResult.status, 'timeout');
|
||||
assert.equal(tailResult.timing.probes, 0);
|
||||
assert.equal(tailTrace.samplerStatus, 'timeout');
|
||||
assert.equal(tailTrace.rendererStatus, 'timeout');
|
||||
assert.equal(tailTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
|
||||
assert.equal(tailTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
|
||||
assert.equal(viewer.credits.size, 0);
|
||||
for (const listener of savedTimeout) listener();
|
||||
assert.equal(probeCount, 1, 'saved callbacks cannot revive timed-out tasks');
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
|
||||
const pending = buildingAnnotation('destroyed-building');
|
||||
const destruction = renderer.add(pending, { pendingVisible: false });
|
||||
const savedDestroy = frames.snapshot();
|
||||
renderer.destroy();
|
||||
assert.equal((await destruction).status, 'cancelled');
|
||||
for (const listener of savedDestroy) listener();
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal(probeCount, 1);
|
||||
assert.equal(dataSources.length, 0, 'destroy removes the owned data source');
|
||||
assert.equal(frames.size, 0, 'destroy removes frame listeners');
|
||||
assert.equal(viewer.credits.size, 0);
|
||||
});
|
||||
|
||||
test('a timed-out building upgrade keeps its existing pending screen group', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
t.after(() => t.mock.timers.reset());
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const anno = { ...buildingAnnotation('pending-upgrade'), ring: null, label: 'Building' };
|
||||
renderer.add(anno);
|
||||
const [before] = annotationGroups(store);
|
||||
anno.ring = buildingAnnotation('geometry').ring;
|
||||
const completion = renderer.update(anno);
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal((await completion).status, 'timeout');
|
||||
const [after, ...others] = annotationGroups(store);
|
||||
assert.equal(after.key, before.key);
|
||||
assert.equal(others.length, 0);
|
||||
assert.ok(childrenWithClass(after, 'gev-anno-ring').length > 0, 'the honest pending point is not converted');
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
frames.emit();
|
||||
assert.equal(dataSources[0].entities.values.length, 0);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('an asynchronous caption-conversion throw rolls back multipart world geometry and remains removable', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
const anno = { ...buildingAnnotation('caption-failure'), ring: null, label: 'Building' };
|
||||
renderer.add(anno);
|
||||
anno.ring = buildingAnnotation('geometry').ring;
|
||||
const hole = anno.ring.map(([lon, lat]) => [lon + 0.0002, lat + 0.0002]);
|
||||
const island = anno.ring.map(([lon, lat]) => [lon + 0.002, lat]);
|
||||
anno.polygons = [[anno.ring, hole], [island]];
|
||||
const captionTrace = workTrace();
|
||||
const completion = renderer.update(anno, { workTrace: captionTrace });
|
||||
// Frame projection is still ordinary pending-point work. Arm the failure
|
||||
// only after sampling completes, before its promise converts the caption.
|
||||
for (let frame = 0; frame < 8; frame += 1) frames.emit();
|
||||
assert.ok(dataSources[0].entities.values.length > 0, 'multipart world geometry landed before caption conversion');
|
||||
assert.equal(viewer.credits.size, 2, 'credit belongs to the committed world proxy');
|
||||
// The caption conversion fails when it presents the converted mark.
|
||||
const publish = store.publish;
|
||||
const failure = new Error('caption conversion failed');
|
||||
let armed = true;
|
||||
store.publish = (board) => {
|
||||
if (armed) {
|
||||
armed = false;
|
||||
throw failure;
|
||||
}
|
||||
return publish(board);
|
||||
};
|
||||
// Also fail screen teardown once: the routing entry must survive this second
|
||||
// failure so explicit cleanup can still reach the partial screen state.
|
||||
const workingSetTimeout = globalThis.window.setTimeout;
|
||||
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
|
||||
const rejected = assert.rejects(completion, (error) => error === failure);
|
||||
await rejected;
|
||||
assert.equal(captionTrace.rendererStatus, 'failed');
|
||||
assert.equal(captionTrace.slices.filter(s => s.phase === 'ground-step').length, 8);
|
||||
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
|
||||
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-rollback').length, 1);
|
||||
assert.equal(dataSources[0].entities.values.length, 0, 'all classification, cage and outline parts roll back');
|
||||
assert.equal(viewer.credits.size, 0, 'caption rollback releases that same proxy');
|
||||
assert.equal(annotationGroups(store).length, 1, 'failed screen cleanup remains addressable');
|
||||
globalThis.window.setTimeout = workingSetTimeout;
|
||||
store.publish = publish;
|
||||
renderer.remove(anno);
|
||||
assert.equal(annotationGroups(store).length, 0);
|
||||
renderer.remove(anno);
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('a deadline passed during caption conversion leaves a drawn pending point', async (t) => {
|
||||
installBrowserGlobals(t);
|
||||
let now = 0;
|
||||
t.mock.method(globalThis.performance, 'now', () => now);
|
||||
const { viewer, frames } = fakeViewer(-99, 31);
|
||||
const { store, renderer } = hybridWithBoard(viewer);
|
||||
// Unlabeled: the conversion empties the screen group rather than relabelling it.
|
||||
const anno = { ...buildingAnnotation('late-timeout'), ring: null };
|
||||
renderer.add(anno);
|
||||
anno.ring = buildingAnnotation('geometry').ring;
|
||||
const completion = renderer.update(anno);
|
||||
for (let frame = 0; frame < 8; frame += 1) frames.emit();
|
||||
// The clock passes the deadline while the conversion presents the board.
|
||||
const publish = store.publish;
|
||||
store.publish = (board) => {
|
||||
now = 1e9;
|
||||
return publish(board);
|
||||
};
|
||||
assert.equal((await completion).status, 'timeout');
|
||||
store.publish = publish;
|
||||
const live = annotationGroups(store).filter((mark) => !mark.className?.includes('gev-out'));
|
||||
assert.equal(live.length, 1);
|
||||
assert.ok(childrenWithClass(live[0], 'gev-anno-ring').length > 0, 'the pending point still draws');
|
||||
renderer.remove(anno);
|
||||
renderer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// Multipart outline identity and street highlights in the annotation engine.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createAnnotationEngine,
|
||||
promoteStreetHighlight,
|
||||
} from './annotationEngine.js';
|
||||
|
||||
function installAnimationFrameStubs(t) {
|
||||
const raf = globalThis.requestAnimationFrame;
|
||||
const caf = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = () => 1;
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
if (raf === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = raf;
|
||||
if (caf === undefined) delete globalThis.cancelAnimationFrame;
|
||||
else globalThis.cancelAnimationFrame = caf;
|
||||
});
|
||||
}
|
||||
|
||||
const renderer = { add() {}, update() {}, remove() {}, sync() {} };
|
||||
const outer = [
|
||||
[0, 0],
|
||||
[0.01, 0],
|
||||
[0.01, 0.01],
|
||||
[0, 0.01],
|
||||
[0, 0],
|
||||
];
|
||||
const hole = (d) => [
|
||||
[0.004, 0.004],
|
||||
[0.004 + d, 0.004],
|
||||
[0.004 + d, 0.004 + d],
|
||||
[0.004, 0.004],
|
||||
];
|
||||
|
||||
test('outlines sharing an outer ring but not their holes stay two marks', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const shapes = {
|
||||
a: [[outer, hole(0.001)]],
|
||||
b: [[outer, hole(0.002)]],
|
||||
same: [[outer, hole(0.001)]],
|
||||
};
|
||||
const resolveTarget = async ({ target }) => ({
|
||||
lon: 0.005,
|
||||
lat: 0.005,
|
||||
height: 0,
|
||||
label: target,
|
||||
source: 'fake',
|
||||
ring: outer,
|
||||
polygons: shapes[target],
|
||||
footprintKind: 'area',
|
||||
});
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
await engine.annotate({ type: 'area', target: 'a', label: 'A' });
|
||||
await engine.annotate({ type: 'area', target: 'b', label: 'B' });
|
||||
assert.equal(engine.list().length, 2, 'different holes: different marks');
|
||||
await engine.annotate({ type: 'area', target: 'same', label: 'Same' });
|
||||
assert.equal(engine.list().length, 2, 'identical parts and holes: one mark');
|
||||
});
|
||||
|
||||
test('a street highlight asks for the street outline', () => {
|
||||
assert.deepEqual(
|
||||
promoteStreetHighlight({ type: 'highlight', target: 'Congress Avenue' }),
|
||||
{ type: 'area', target: 'Congress Avenue', entityKind: 'street' },
|
||||
);
|
||||
assert.equal(
|
||||
promoteStreetHighlight({
|
||||
type: 'highlight',
|
||||
target: 'Lombard St, San Francisco',
|
||||
}).type,
|
||||
'area',
|
||||
);
|
||||
assert.equal(
|
||||
promoteStreetHighlight({
|
||||
type: 'highlight',
|
||||
target: 'the Alamo',
|
||||
entityKind: 'street',
|
||||
}).type,
|
||||
'area',
|
||||
);
|
||||
for (const spec of [
|
||||
{ type: 'highlight', target: "St Peter's Basilica" },
|
||||
{ type: 'highlight', target: 'Zilker Park' },
|
||||
{ type: 'highlight', target: 'Congress Avenue', footprint: false },
|
||||
{ type: 'highlight', target: 'Congress Avenue', entityKind: 'building' },
|
||||
{ type: 'pin', target: 'Congress Avenue' },
|
||||
])
|
||||
assert.equal(promoteStreetHighlight(spec), spec, JSON.stringify(spec));
|
||||
});
|
||||
|
||||
test('the engine resolves a bare street highlight with its footprint', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const seen = [];
|
||||
const resolveTarget = async (args) => {
|
||||
seen.push(args);
|
||||
return {
|
||||
lon: -97.74,
|
||||
lat: 30.27,
|
||||
height: 0,
|
||||
label: args.target,
|
||||
source: 'fake',
|
||||
ring: null,
|
||||
};
|
||||
};
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer,
|
||||
resolveTarget,
|
||||
});
|
||||
await engine.annotate({
|
||||
type: 'highlight',
|
||||
target: 'Congress Avenue',
|
||||
label: 'Congress',
|
||||
});
|
||||
assert.equal(seen[0].footprint, true);
|
||||
assert.equal(seen[0].entityKind, 'street');
|
||||
});
|
||||
|
||||
test('a duplicate that fails to clean up never costs the new outline', async (t) => {
|
||||
installAnimationFrameStubs(t);
|
||||
const polygons = [[outer]];
|
||||
const outline = { ring: outer, polygons, footprintKind: 'area' };
|
||||
const point = { lon: 0.005, lat: 0.005, height: 0, source: 'fake' };
|
||||
// "Old" draws at once; "New" resolves the same polygon later.
|
||||
const resolveTarget = async ({ target }) =>
|
||||
target === 'old'
|
||||
? { ...point, label: target, ...outline }
|
||||
: {
|
||||
...point,
|
||||
label: target,
|
||||
resolveOutline: async () => ({ ...point, ...outline }),
|
||||
};
|
||||
const stuck = new Set();
|
||||
const outcomes = [];
|
||||
const engine = createAnnotationEngine({
|
||||
viewer: {},
|
||||
renderer: {
|
||||
...renderer,
|
||||
add(anno) {
|
||||
if (anno.label === 'Old') stuck.add(anno);
|
||||
},
|
||||
remove(anno) {
|
||||
if (stuck.has(anno)) throw new Error('renderer refused');
|
||||
},
|
||||
},
|
||||
resolveTarget,
|
||||
});
|
||||
engine.onOutlineEvent((event) => outcomes.push(event));
|
||||
await engine.annotate({ type: 'area', target: 'old', label: 'Old' });
|
||||
await engine.annotate({ type: 'area', target: 'new', label: 'New' });
|
||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
const fresh = engine.list().find((mark) => mark.label === 'New');
|
||||
assert.equal(fresh?.footprintKind, 'area', 'the new mark keeps its outline');
|
||||
assert.notEqual(fresh.outlineUnavailable, true);
|
||||
assert.deepEqual(
|
||||
outcomes.filter((event) => event.label === 'New').map((e) => e.status),
|
||||
['resolved'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import {
|
||||
bufferCorridor,
|
||||
ringAreaM2,
|
||||
ringCentroid,
|
||||
} from '../sources/featureGeometry.js';
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { containsAnchor } from '../data/adminBoundaries.js';
|
||||
import { lonLatToTile } from '../data/tomtomTiles.js';
|
||||
import {
|
||||
BUILDING_MAX_DISTANCE_M,
|
||||
GROUNDS_WORDS_RE,
|
||||
OUTLINE_TILE_ZOOM,
|
||||
STREET_RADIUS_M,
|
||||
boxAround,
|
||||
buildingFromTiles,
|
||||
createOpenFreeMapOutlineSource,
|
||||
enclosingAreaFromTiles,
|
||||
groundsClassesFor,
|
||||
streetFromTiles,
|
||||
} from '../sources/openFreeMapOutlines.js';
|
||||
import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js';
|
||||
|
||||
export { containsAnchor };
|
||||
|
||||
/**
|
||||
* Outline rungs that need no Overpass, around the resolver's existing ladder:
|
||||
*
|
||||
* bundled rungs (inside the base ladder)
|
||||
* → OpenFreeMap tiles for streets and pointed-at buildings
|
||||
* → operator Overpass, when configured (the base ladder, unchanged)
|
||||
* → the server's guarded Nominatim outline (cities, landmarks, grounds,
|
||||
* parks, lakes, campuses, named buildings)
|
||||
* → OpenFreeMap building at the point, for a named building nobody outlined
|
||||
* → the base answer: an honest point marked "outline unavailable".
|
||||
*
|
||||
* Every rung returns the resolver's footprint shape
|
||||
* `{ ring, polygons?, kind, heightM, synthesized }` and hands it to the
|
||||
* resolver's `finish`, which applies the scope caps and drift bound and
|
||||
* re-centres the anchor. Nothing here fetches without an explicit ask.
|
||||
*/
|
||||
|
||||
/** Half-width (m) of the ribbon drawn along a street. */
|
||||
const STREET_HALF_WIDTH_M = 11;
|
||||
|
||||
/** A Nominatim "grounds" answer smaller than this is a building, not grounds. */
|
||||
const GROUNDS_MIN_AREA_M2 = 20_000;
|
||||
|
||||
/** Words that point at the thing rather than name it. */
|
||||
const DEICTIC_RE =
|
||||
/^(?:(?:this|that|the|my)\s+)?(?:building|house|structure|tower|place|one|thing|spot|here|there|it)$/i;
|
||||
|
||||
/** Whether the ask names a thing (vs. "this building" at a pointer). */
|
||||
export function isNamedAsk(target) {
|
||||
const text = String(target || '').trim();
|
||||
return Boolean(text) && !DEICTIC_RE.test(text) && /\p{L}/u.test(text);
|
||||
}
|
||||
|
||||
/** The words sent to Nominatim: the ask without grounds words or articles. */
|
||||
export function outlineQueryText(target) {
|
||||
return String(target || '')
|
||||
.replace(GROUNDS_WORDS_RE, ' ')
|
||||
.replace(/^\s*the\s+/i, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Nominatim ask kind (if any) suits a resolver scope.
|
||||
* Neighbourhoods and streets are not sent: bundled packs and tiles own them.
|
||||
*/
|
||||
export function nominatimKindFor({ scope, groundsLike, pointLike, around }) {
|
||||
if (pointLike) return null;
|
||||
// "Around" asks get a buffer, except grounds: the grounds ARE the thing.
|
||||
if (around && !groundsLike) return null;
|
||||
if (scope === 'city') return 'city';
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country')
|
||||
return 'admin';
|
||||
if (groundsLike) return 'landmark';
|
||||
if (scope === 'building') return 'building';
|
||||
if (scope === 'compound' || scope === 'auto') return 'landmark';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts with the one holding the anchor outside its holes first. A boundary
|
||||
* can be largest at sea (Tokyo's island municipalities), so size alone does
|
||||
* not pick the part the ask is about; with no part holding the anchor, the
|
||||
* order is kept.
|
||||
*/
|
||||
export function anchorPartFirst(polygons, points) {
|
||||
for (const { lat, lon } of points) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
const at = polygons.findIndex((polygon) =>
|
||||
containsAnchor([polygon], lat, lon),
|
||||
);
|
||||
if (at > 0) return [polygons[at], ...polygons.filter((_, i) => i !== at)];
|
||||
if (at === 0) return polygons;
|
||||
}
|
||||
return polygons;
|
||||
}
|
||||
|
||||
/** Whether a base answer is final (a real outline, or "try again later"). */
|
||||
function keepBase(result) {
|
||||
if (result === undefined) return true;
|
||||
if (result?.rateLimited === true) return true;
|
||||
if (isUnavailableCapability(result)) return false;
|
||||
return Boolean(result?.ring) && !result.synthesized;
|
||||
}
|
||||
|
||||
/** Street polylines → one ribbon polygon per chain (longest first). */
|
||||
export function streetFootprint(street) {
|
||||
const polygons = street.lines
|
||||
.filter((line) => line.length >= 2)
|
||||
.map((line) => [bufferCorridor(line, STREET_HALF_WIDTH_M)]);
|
||||
if (!polygons.length) return null;
|
||||
return {
|
||||
ring: polygons[0][0],
|
||||
polygons,
|
||||
lines: street.lines,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Rungs built without their own tiles share one outline source, so a second
|
||||
// resolver instance cannot add another outline cache to the tile budget.
|
||||
let defaultOutlineTiles = null;
|
||||
const sharedOutlineTiles = () =>
|
||||
(defaultOutlineTiles ??= createOpenFreeMapOutlineSource());
|
||||
|
||||
export function createOutlineRungs({
|
||||
tiles = sharedOutlineTiles(),
|
||||
nominatim = createNominatimOutlineClient(),
|
||||
} = {}) {
|
||||
/**
|
||||
* Decoded tiles around a point, or null when the tiles cannot be read. With
|
||||
* `needAnchor`, a partial load that lost the point's own tile is null too: a
|
||||
* neighbouring tile alone cannot show what is at the point, so a nearby
|
||||
* building or area would stand in for it.
|
||||
*/
|
||||
async function tilesAround(lat, lon, radiusM, signal, needAnchor = false) {
|
||||
try {
|
||||
const {
|
||||
tiles: decoded,
|
||||
partial,
|
||||
loadedTiles,
|
||||
} = await tiles.fetchBounds(boxAround(lat, lon, radiusM), {
|
||||
zoom: OUTLINE_TILE_ZOOM,
|
||||
signal,
|
||||
});
|
||||
if (needAnchor && partial && Array.isArray(loadedTiles)) {
|
||||
const anchor = lonLatToTile(lon, lat, OUTLINE_TILE_ZOOM);
|
||||
if (!loadedTiles.some((t) => t.x === anchor.x && t.y === anchor.y))
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (error?.name === 'AbortError') throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function streetRung(ctx) {
|
||||
const name = ctx.matchName || ctx.target;
|
||||
if (!isNamedAsk(name)) return null;
|
||||
const decoded = await tilesAround(
|
||||
ctx.lat,
|
||||
ctx.lon,
|
||||
STREET_RADIUS_M,
|
||||
ctx.signal,
|
||||
);
|
||||
if (!decoded) return null;
|
||||
const street = streetFromTiles(decoded, {
|
||||
name,
|
||||
lat: ctx.lat,
|
||||
lon: ctx.lon,
|
||||
});
|
||||
return street ? streetFootprint(street) : null;
|
||||
}
|
||||
|
||||
async function buildingRung(ctx, requireContainment = false) {
|
||||
const decoded = await tilesAround(
|
||||
ctx.lat,
|
||||
ctx.lon,
|
||||
BUILDING_MAX_DISTANCE_M * 2,
|
||||
ctx.signal,
|
||||
true,
|
||||
);
|
||||
const building = decoded && buildingFromTiles(decoded, ctx);
|
||||
if (!building || (requireContainment && building.contains !== true))
|
||||
return null;
|
||||
return {
|
||||
ring: building.ring,
|
||||
polygons: building.polygons,
|
||||
kind: 'building',
|
||||
heightM: building.heightM,
|
||||
synthesized: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The open area enclosing `seed` (the named feature) or the anchor. */
|
||||
async function groundsRung(ctx, seed = null) {
|
||||
const at = seed || { lat: ctx.lat, lon: ctx.lon };
|
||||
const decoded = await tilesAround(at.lat, at.lon, 600, ctx.signal, true);
|
||||
const area =
|
||||
decoded &&
|
||||
enclosingAreaFromTiles(decoded, {
|
||||
lat: at.lat,
|
||||
lon: at.lon,
|
||||
name: outlineQueryText(ctx.target || ctx.matchName),
|
||||
// The kind the ask names ("… School grounds") lets unnamed tile
|
||||
// landuse of that class answer; read before grounds words are cut.
|
||||
unnamedClasses: groundsClassesFor(ctx.target || ctx.matchName),
|
||||
minAreaM2: GROUNDS_MIN_AREA_M2,
|
||||
});
|
||||
if (!area || !containsAnchor(area.polygons, ctx.lat, ctx.lon)) return null;
|
||||
return {
|
||||
ring: area.ring,
|
||||
polygons: area.polygons,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function nominatimRung(ctx, kind) {
|
||||
const query = outlineQueryText(
|
||||
kind === 'city' || kind === 'admin'
|
||||
? ctx.matchName || ctx.target
|
||||
: ctx.target || ctx.matchName,
|
||||
);
|
||||
if (!isNamedAsk(query)) return null;
|
||||
const bias = ctx.view || { lat: ctx.lat, lon: ctx.lon };
|
||||
const outline = await nominatim.lookup(
|
||||
{
|
||||
query,
|
||||
kind,
|
||||
lat: bias.lat,
|
||||
lon: bias.lon,
|
||||
anchorLat: ctx.lat,
|
||||
anchorLon: ctx.lon,
|
||||
},
|
||||
{ signal: ctx.signal },
|
||||
);
|
||||
if (!outline?.polygons?.length) return outline; // null | undefined | unavailable | rateLimited
|
||||
// Named outlines must retain the resolved place's authority,
|
||||
// including holes; a nearby same-name polygon is not the selected place.
|
||||
if (!containsAnchor(outline.polygons, ctx.lat, ctx.lon)) return null;
|
||||
const polygons = anchorPartFirst(outline.polygons, [
|
||||
{ lat: ctx.lat, lon: ctx.lon },
|
||||
]);
|
||||
const ring = polygons[0][0];
|
||||
// A grounds ask answered by a building-sized polygon found the named
|
||||
// building, not its grounds: keep its position to find what encloses it.
|
||||
if (ctx.groundsLike && ringAreaM2(ring) < GROUNDS_MIN_AREA_M2)
|
||||
return { groundsSeed: ringCentroid(ring) };
|
||||
return {
|
||||
ring,
|
||||
polygons,
|
||||
kind:
|
||||
kind === 'building' || outline.class === 'building'
|
||||
? 'building'
|
||||
: 'area',
|
||||
heightM: null,
|
||||
synthesized: false,
|
||||
outlineName: outline.name,
|
||||
adminArea: outline.adminArea || null,
|
||||
adminLevel: outline.adminLevel || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the ladder for one resolved anchor.
|
||||
*
|
||||
* @param {object} ctx scope, target, matchName, lat, lon, named anchor facts,
|
||||
* view centre, signal, and `credit(kind)`.
|
||||
* @param {{base: () => Promise<any>, finish: (fp: object, scope: string) => any}} ladder
|
||||
*/
|
||||
async function resolve(ctx, { base, finish }) {
|
||||
const { scope } = ctx;
|
||||
const named = ctx.fromName && isNamedAsk(ctx.target);
|
||||
// A cancelled ask (board cleared, superseded) stops between rungs, so it
|
||||
// never spends a Nominatim request.
|
||||
const alive = () => ctx.signal?.throwIfAborted();
|
||||
const accept = (fp, asScope = scope, provider) => {
|
||||
alive();
|
||||
if (!fp) return null;
|
||||
const patch = finish(fp, asScope);
|
||||
if (patch?.ring) {
|
||||
patch.outlineCredit = provider === 'openfreemap' ? 'tiles' : 'osm';
|
||||
// Which source answered, so a stored area names it (OpenFreeMap or
|
||||
// Nominatim) instead of defaulting to the Overpass rungs.
|
||||
patch.outlineSource = provider;
|
||||
ctx.credit?.(patch.outlineCredit);
|
||||
// One line per answered outline, like the resolver's own trace.
|
||||
console.log(
|
||||
`[Outline] "${ctx.target || ''}": scope=${asScope} → ${provider}` +
|
||||
` (${patch.footprintKind}, ${patch.polygons?.length || 1} part(s))`,
|
||||
);
|
||||
return patch;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Tiles first for streets and for a building pointed at rather than named.
|
||||
if (scope === 'street') {
|
||||
const street = accept(await streetRung(ctx), 'street', 'openfreemap');
|
||||
if (street) return street;
|
||||
} else if (scope === 'building' && !named && !ctx.around) {
|
||||
const building = accept(
|
||||
await buildingRung(ctx),
|
||||
'building',
|
||||
'openfreemap',
|
||||
);
|
||||
if (building) return building;
|
||||
}
|
||||
|
||||
alive();
|
||||
const baseResult = await base();
|
||||
alive();
|
||||
if (keepBase(baseResult)) return baseResult;
|
||||
|
||||
// A transient or busy Nominatim answer is kept aside while the tile
|
||||
// fallbacks run; it is returned only if nothing else answers.
|
||||
let retryLater = null;
|
||||
let groundsSeed = null;
|
||||
const kind = named ? nominatimKindFor(ctx) : null;
|
||||
if (kind) {
|
||||
const found = await nominatimRung(ctx, kind);
|
||||
alive();
|
||||
if (found?.groundsSeed) {
|
||||
groundsSeed = found.groundsSeed;
|
||||
} else if (found?.ring) {
|
||||
const patch = accept(
|
||||
found,
|
||||
ctx.groundsLike ? 'compound' : scope,
|
||||
'nominatim',
|
||||
);
|
||||
if (patch) return patch;
|
||||
} else if (found === undefined || found?.rateLimited) {
|
||||
retryLater = { value: found };
|
||||
}
|
||||
}
|
||||
|
||||
// Grounds absent from the named lookup: a matching named tile area.
|
||||
if (ctx.groundsLike && named) {
|
||||
// Around the named building when Nominatim found it, else the anchor.
|
||||
let grounds = groundsSeed
|
||||
? accept(await groundsRung(ctx, groundsSeed), 'compound', 'openfreemap')
|
||||
: null;
|
||||
grounds ||= accept(await groundsRung(ctx), 'compound', 'openfreemap');
|
||||
if (grounds) return grounds;
|
||||
}
|
||||
|
||||
// A named building nobody outlined: the footprint holding its point. Never for
|
||||
// a grounds ask, where the building is the wrong shape.
|
||||
if (scope === 'building' && named && !ctx.around && !ctx.groundsLike) {
|
||||
const building = accept(
|
||||
await buildingRung(ctx, true),
|
||||
'building',
|
||||
'openfreemap',
|
||||
);
|
||||
if (building) return building;
|
||||
}
|
||||
// Keep the mark retryable only when nothing else could answer. A
|
||||
// definitive miss or an approximation from the configured ladder stands
|
||||
// rather than inviting repeated lookups.
|
||||
if (retryLater && isUnavailableCapability(baseResult))
|
||||
return retryLater.value;
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
return { resolve };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,216 @@
|
||||
// Tile fallbacks still run after a busy or failed Nominatim answer, and
|
||||
// grounds asks phrased "around" still reach their outline rungs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createOutlineRungs, nominatimKindFor } from './outlineRungs.js';
|
||||
import { decodeOpenFreeMapOutlineTile } from '../sources/openFreeMapOutlines.js';
|
||||
|
||||
const TILES = [6745, 6746].map((y) =>
|
||||
decodeOpenFreeMapOutlineTile(
|
||||
readFileSync(
|
||||
new URL(
|
||||
`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`,
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
14,
|
||||
3743,
|
||||
y,
|
||||
),
|
||||
);
|
||||
const UNAVAILABLE = { unavailable: true, retryable: false };
|
||||
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
|
||||
|
||||
function setup(answer, tiles = TILES) {
|
||||
const tileCalls = [];
|
||||
const asks = [];
|
||||
const rungs = createOutlineRungs({
|
||||
tiles: {
|
||||
fetchBounds: async (box) => {
|
||||
tileCalls.push(box);
|
||||
return { tiles };
|
||||
},
|
||||
},
|
||||
nominatim: {
|
||||
lookup: async (ask) => {
|
||||
asks.push(ask);
|
||||
return answer;
|
||||
},
|
||||
},
|
||||
});
|
||||
return { rungs, tileCalls, asks };
|
||||
}
|
||||
const ladder = {
|
||||
base: async () => UNAVAILABLE,
|
||||
finish: (fp) => ({ ring: fp.ring, footprintKind: fp.kind }),
|
||||
};
|
||||
const ctx = (overrides) => ({
|
||||
scope: 'building',
|
||||
target: 'Texas State Capitol',
|
||||
matchName: 'Texas State Capitol',
|
||||
fromName: true,
|
||||
...CAPITOL,
|
||||
view: CAPITOL,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
for (const [label, answer] of [
|
||||
['busy', { rateLimited: true, retryAfterMs: 5000 }],
|
||||
['transient', undefined],
|
||||
]) {
|
||||
test(`a ${label} Nominatim answer does not skip the tile fallbacks`, async () => {
|
||||
const building = setup(answer);
|
||||
const found = await building.rungs.resolve(ctx(), ladder);
|
||||
assert.equal(building.asks.length, 1);
|
||||
assert.ok(building.tileCalls.length > 0, 'tiles were consulted');
|
||||
assert.equal(found.footprintKind, 'building');
|
||||
|
||||
const grounds = setup(answer);
|
||||
const lawn = await grounds.rungs.resolve(
|
||||
ctx({ target: 'the Texas Capitol grounds', groundsLike: true }),
|
||||
ladder,
|
||||
);
|
||||
assert.ok(grounds.tileCalls.length > 0, 'grounds tiles were consulted');
|
||||
assert.equal(lawn, answer, 'unnamed grounds preserve the retryable answer');
|
||||
});
|
||||
}
|
||||
|
||||
test('with no tile answer either, the busy result keeps the mark retryable', async () => {
|
||||
const busy = { rateLimited: true, retryAfterMs: 5000 };
|
||||
const rungs = createOutlineRungs({
|
||||
tiles: { fetchBounds: async () => ({ tiles: [] }) },
|
||||
nominatim: { lookup: async () => busy },
|
||||
});
|
||||
assert.equal(await rungs.resolve(ctx(), ladder), busy);
|
||||
});
|
||||
|
||||
test('"around the Capitol grounds" is still a grounds ask', async () => {
|
||||
assert.equal(
|
||||
nominatimKindFor({ scope: 'building', groundsLike: true, around: true }),
|
||||
'landmark',
|
||||
);
|
||||
assert.equal(nominatimKindFor({ scope: 'compound', around: true }), null);
|
||||
const { rungs, asks } = setup(null);
|
||||
const lawn = await rungs.resolve(
|
||||
ctx({
|
||||
target: 'the Texas Capitol grounds',
|
||||
groundsLike: true,
|
||||
around: true,
|
||||
}),
|
||||
ladder,
|
||||
);
|
||||
assert.equal(asks.length, 1);
|
||||
});
|
||||
|
||||
test('Nominatim administrative identity survives the outline rung', async () => {
|
||||
const boundary = {
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[84.4, 26.9],
|
||||
[86.6, 26.9],
|
||||
[86.6, 28.4],
|
||||
[84.4, 28.4],
|
||||
[84.4, 26.9],
|
||||
],
|
||||
],
|
||||
],
|
||||
class: 'boundary',
|
||||
type: 'administrative',
|
||||
adminArea: 'Bagmati Province',
|
||||
adminLevel: 'admin1',
|
||||
};
|
||||
const { rungs } = setup(boundary);
|
||||
const result = await rungs.resolve(
|
||||
ctx({
|
||||
scope: 'state',
|
||||
target: 'Bagmati Province',
|
||||
matchName: 'Bagmati Province',
|
||||
lat: 27.7,
|
||||
lon: 85.3,
|
||||
view: { lat: 27.7, lon: 85.3 },
|
||||
}),
|
||||
{
|
||||
base: async () => UNAVAILABLE,
|
||||
finish: (fp) => ({
|
||||
ring: fp.ring,
|
||||
footprintKind: fp.kind,
|
||||
adminArea: fp.adminArea,
|
||||
adminLevel: fp.adminLevel,
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(result.adminArea, 'Bagmati Province');
|
||||
assert.equal(result.adminLevel, 'admin1');
|
||||
});
|
||||
|
||||
test('named grounds around a building seed still require the original anchor', async () => {
|
||||
// A place-search anchor ~250 m from the dome, outside the Capitol lawn.
|
||||
const offLawn = { lat: 30.27265, lon: -97.73925 };
|
||||
const d = 0.0003;
|
||||
const dome = {
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[CAPITOL.lon - d, CAPITOL.lat - d],
|
||||
[CAPITOL.lon + d, CAPITOL.lat - d],
|
||||
[CAPITOL.lon + d, CAPITOL.lat + d],
|
||||
[CAPITOL.lon - d, CAPITOL.lat + d],
|
||||
[CAPITOL.lon - d, CAPITOL.lat - d],
|
||||
],
|
||||
],
|
||||
],
|
||||
class: 'office',
|
||||
};
|
||||
const lawnRadius = 0.001;
|
||||
const namedTiles = [
|
||||
{
|
||||
areas: [
|
||||
{
|
||||
name: 'Texas Capitol',
|
||||
class: 'park',
|
||||
clipped: false,
|
||||
rings: [
|
||||
[
|
||||
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
|
||||
[CAPITOL.lon + lawnRadius, CAPITOL.lat - lawnRadius],
|
||||
[CAPITOL.lon + lawnRadius, CAPITOL.lat + lawnRadius],
|
||||
[CAPITOL.lon - lawnRadius, CAPITOL.lat + lawnRadius],
|
||||
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const withoutSeed = setup(null, namedTiles);
|
||||
assert.equal(
|
||||
await withoutSeed.rungs.resolve(
|
||||
ctx({
|
||||
...offLawn,
|
||||
target: 'the Texas Capitol grounds',
|
||||
groundsLike: true,
|
||||
}),
|
||||
ladder,
|
||||
),
|
||||
UNAVAILABLE,
|
||||
'the anchor alone is not inside the grounds',
|
||||
);
|
||||
const { rungs, tileCalls } = setup(dome, namedTiles);
|
||||
const lawn = await rungs.resolve(
|
||||
ctx({ ...offLawn, target: 'the Texas Capitol grounds', groundsLike: true }),
|
||||
ladder,
|
||||
);
|
||||
assert.ok(tileCalls.length > 0, 'the matching named enclosure was consulted');
|
||||
assert.deepEqual(
|
||||
tileCalls,
|
||||
withoutSeed.tileCalls,
|
||||
'a rejected polygon cannot add a shifted grounds-seed lookup',
|
||||
);
|
||||
assert.equal(
|
||||
lawn,
|
||||
UNAVAILABLE,
|
||||
'a building seed cannot move the original anchor into the named grounds',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,727 @@
|
||||
// Area annotations draw bundled US Census place and neighborhood outlines
|
||||
// (San Francisco DataSF first, then Who's On First) with no network lookup.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
import { polygonsContain } from '../data/adminBoundaries.js';
|
||||
import { streetFootprint } from './outlineRungs.js';
|
||||
|
||||
function viewerAt(lat, lon, height = 20_000) {
|
||||
return {
|
||||
camera: {
|
||||
positionCartographic: {
|
||||
latitude: (lat * Math.PI) / 180,
|
||||
longitude: (lon * Math.PI) / 180,
|
||||
height,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A resolver whose boundary lookups and geocoder record every call. */
|
||||
function harness(place = null, { outlineRungs } = {}) {
|
||||
const lookups = [];
|
||||
const featureSource = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
featureSource[method] = async () => {
|
||||
lookups.push(method);
|
||||
return null;
|
||||
};
|
||||
const geocodes = [];
|
||||
const placeSearch = {
|
||||
async geocode(query) {
|
||||
geocodes.push(query);
|
||||
return { place };
|
||||
},
|
||||
async textSearch() {
|
||||
geocodes.push('text-search');
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { resolveAnnotationTarget } = createAnnotationResolver({
|
||||
featureSource,
|
||||
...(outlineRungs ? { outlineRungs } : {}),
|
||||
});
|
||||
const resolve = (target, viewer, extra = {}) =>
|
||||
resolveAnnotationTarget({
|
||||
placeSearch,
|
||||
viewer,
|
||||
target,
|
||||
footprint: true,
|
||||
deferFootprint: true,
|
||||
...extra,
|
||||
});
|
||||
return { resolve, lookups, geocodes };
|
||||
}
|
||||
|
||||
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
|
||||
const SAN_FRANCISCO = viewerAt(37.76, -122.42, 3000);
|
||||
const LONDON = viewerAt(51.51, -0.16, 3000);
|
||||
|
||||
function assertBundledArea(resolved, label) {
|
||||
assert.equal(resolved?.source, 'bundled', label);
|
||||
assert.equal(resolved.label, label);
|
||||
assert.equal(resolved.footprintKind, 'area');
|
||||
assert.equal(resolved.synthesized, false);
|
||||
assert.equal(
|
||||
resolved.outlineCredit || null,
|
||||
null,
|
||||
'bundled data is not an OSM outline',
|
||||
);
|
||||
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
|
||||
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
|
||||
assert.ok(resolved.polygons.length >= 1);
|
||||
}
|
||||
|
||||
test('a city name that is only a neighborhood alias goes to the geocoder', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
// Sé lists "São Paulo" among its names; the ask means the city.
|
||||
const saoPaulo = viewerAt(-23.55, -46.633, 60_000);
|
||||
const city = await resolve('São Paulo', saoPaulo);
|
||||
assert.notEqual(city?.label, 'Se');
|
||||
assert.ok(geocodes.includes('São Paulo'));
|
||||
// A district ask still takes the alias.
|
||||
const district = await resolve('São Paulo', saoPaulo, {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assert.equal(district?.label, 'Se');
|
||||
});
|
||||
|
||||
test('"Austin" over Austin draws the Census place with no lookup', async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
for (const target of ['Austin', 'Austin, Texas', 'City of Austin']) {
|
||||
const resolved = await resolve(target, AUSTIN);
|
||||
assertBundledArea(resolved, 'Austin');
|
||||
assert.ok(resolved.polygons.length >= 4, 'every part of the city');
|
||||
}
|
||||
// A qualified name needs no view.
|
||||
const springfield = await resolve('Springfield, Illinois', AUSTIN);
|
||||
assertBundledArea(springfield, 'Springfield');
|
||||
assert.ok(Math.abs(springfield.lat - 39.78) < 0.2);
|
||||
assert.deepEqual(geocodes, []);
|
||||
assert.deepEqual(lookups, []);
|
||||
});
|
||||
|
||||
test("the SF DataSF pack answers before Who's On First in San Francisco", async () => {
|
||||
const { resolve, lookups, geocodes } = harness();
|
||||
const sf = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../data/local_data/neighborhoods/san-francisco.json',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
const mission = sf.features.find((f) => f.properties.name === 'Mission');
|
||||
const missionRing = mission.geometry.coordinates[0];
|
||||
for (const target of ['the Mission District', 'Mission', 'the Mission']) {
|
||||
const resolved = await resolve(target, SAN_FRANCISCO, {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assertBundledArea(resolved, 'Mission');
|
||||
assert.deepEqual(resolved.polygons[0][0], missionRing, target);
|
||||
}
|
||||
// A name only WOF has in San Francisco falls through to it.
|
||||
const dogpatch = await resolve('Dogpatch', SAN_FRANCISCO);
|
||||
assertBundledArea(dogpatch, 'Dogpatch');
|
||||
// A park asked for as a compound is not a neighborhood of its name.
|
||||
await resolve('Mission Dolores Park', SAN_FRANCISCO, {
|
||||
entityKind: 'compound',
|
||||
});
|
||||
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 1);
|
||||
assert.deepEqual(lookups, []);
|
||||
});
|
||||
|
||||
test("Who's On First neighborhoods resolve near the view", async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
assertBundledArea(await resolve('Notting Hill', LONDON), 'Notting Hill');
|
||||
assertBundledArea(
|
||||
await resolve('Le Marais', viewerAt(48.857, 2.352, 3000)),
|
||||
'Le Marais',
|
||||
);
|
||||
assertBundledArea(
|
||||
await resolve('Williamsburg', viewerAt(40.71, -73.96, 3000)),
|
||||
'Williamsburg',
|
||||
);
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('campuses, qualified hoods and far names still go to the geocoder', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
// Stanford is a CDP, but a campus ask wants the campus.
|
||||
const stanford = await resolve('Stanford', viewerAt(37.43, -122.17, 3000), {
|
||||
entityKind: 'compound',
|
||||
});
|
||||
assert.notEqual(stanford?.source, 'bundled');
|
||||
await resolve('the Capitol grounds', AUSTIN);
|
||||
await resolve('Notting Hill', AUSTIN);
|
||||
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 3);
|
||||
// A state settles a place name wherever the camera is: not Brooklyn's
|
||||
// Williamsburg, the Virginia city.
|
||||
const virginia = await resolve(
|
||||
'Williamsburg, Virginia',
|
||||
viewerAt(40.71, -73.96, 3000),
|
||||
);
|
||||
assertBundledArea(virginia, 'Williamsburg');
|
||||
assert.ok(Math.abs(virginia.lat - 37.27) < 0.1, 'Virginia');
|
||||
});
|
||||
|
||||
test('a geocoded city or neighborhood upgrades to the bundled outline', async () => {
|
||||
const city = harness({
|
||||
lat: 30.2672,
|
||||
lng: -97.7431,
|
||||
label: 'Austin, TX, USA',
|
||||
name: 'Austin',
|
||||
types: ['locality', 'political'],
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const credits = [];
|
||||
const cancelled = await city.resolve(
|
||||
'Austin',
|
||||
{
|
||||
...viewerAt(39, -100, 5_000_000),
|
||||
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const pending = cancelled.resolveOutline();
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.deepEqual(
|
||||
credits,
|
||||
[],
|
||||
'a cancelled bundled continuation cannot mutate viewer credits',
|
||||
);
|
||||
|
||||
// Seen from far away: the view does not settle the name, the geocoder does.
|
||||
const resolved = await city.resolve('Austin', viewerAt(39, -100, 5_000_000));
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.equal(outline.adminArea, 'Austin');
|
||||
assert.ok(outline.polygons.length >= 4);
|
||||
assert.deepEqual(city.lookups, [], 'no boundary lookup');
|
||||
|
||||
const hood = harness({
|
||||
lat: 51.5118,
|
||||
lng: -0.2046,
|
||||
label: 'Notting Hill, London, UK',
|
||||
name: 'Notting Hill',
|
||||
types: ['neighborhood', 'political'],
|
||||
});
|
||||
const nottingHill = await hood.resolve(
|
||||
'Notting Hill',
|
||||
viewerAt(48, 10, 5_000_000),
|
||||
);
|
||||
const hoodOutline = await nottingHill.resolveOutline();
|
||||
assert.equal(hoodOutline.adminArea, 'Notting Hill');
|
||||
assert.deepEqual(hood.lookups, []);
|
||||
});
|
||||
|
||||
test('a district ask prefers a neighborhood; a place must hold the view', async () => {
|
||||
const { resolve, geocodes } = harness();
|
||||
// "Richmond" over SF's Richmond District: never Richmond, California.
|
||||
const richmond = await resolve('Richmond', viewerAt(37.78, -122.48, 3000), {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assertBundledArea(richmond, 'Richmond District');
|
||||
assert.ok(Math.abs(richmond.lat - 37.78) < 0.05, 'not the East Bay city');
|
||||
// Omitted kind at a street-level view: the district in view, too.
|
||||
const bare = await resolve('Richmond', viewerAt(37.78, -122.48, 3000));
|
||||
assertBundledArea(bare, 'Richmond District');
|
||||
// Over the East Bay city, the city.
|
||||
const wide = await resolve('Richmond', viewerAt(37.94, -122.35, 40_000));
|
||||
assert.equal(wide?.source, 'bundled');
|
||||
assert.ok(wide.lat > 37.88, 'Richmond, California');
|
||||
assert.deepEqual(geocodes, []);
|
||||
});
|
||||
|
||||
test('landmark-named neighborhoods wait for the landmark ladder', async () => {
|
||||
const manhattan = viewerAt(40.78, -73.965, 3000);
|
||||
const cases = [
|
||||
['Central Park', {}],
|
||||
['Central Park', { entityKind: 'compound' }],
|
||||
['Central Park', { intent: 'around_the_thing' }],
|
||||
['Hyde Park', {}],
|
||||
['Notting Hill', { intent: 'around_the_thing' }],
|
||||
];
|
||||
for (const [target, extra] of cases) {
|
||||
const { resolve, geocodes } = harness();
|
||||
const viewer = target === 'Central Park' ? manhattan : LONDON;
|
||||
const resolved = await resolve(target, viewer, extra);
|
||||
assert.notEqual(
|
||||
resolved?.source,
|
||||
'bundled',
|
||||
`${target} ${JSON.stringify(extra)}`,
|
||||
);
|
||||
assert.ok(geocodes.length >= 1, `${target} is geocoded`);
|
||||
}
|
||||
// Named as a district, the WOF neighborhood answers.
|
||||
const { resolve } = harness();
|
||||
const park = await resolve('Central Park', manhattan, {
|
||||
entityKind: 'district',
|
||||
});
|
||||
assert.equal(park?.source, 'bundled');
|
||||
// "Park Slope" ends in no landmark word: still a neighborhood.
|
||||
const slope = await resolve('Park Slope', viewerAt(40.671, -73.98, 3000));
|
||||
assertBundledArea(slope, 'Park Slope');
|
||||
});
|
||||
|
||||
/** Outline rungs whose Nominatim answers `answer` (null: nothing found). */
|
||||
function rungsAnswering(answer) {
|
||||
const asked = [];
|
||||
return {
|
||||
asked,
|
||||
resolve: async (ctx, { base, finish }) => {
|
||||
asked.push({ scope: ctx.scope, target: ctx.target });
|
||||
const baseResult = await base();
|
||||
if (baseResult?.ring && !baseResult.synthesized) return baseResult;
|
||||
return answer ? finish(answer, ctx.scope) : baseResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PARIS_PLACE = {
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
label: 'Paris, France',
|
||||
name: 'Paris',
|
||||
types: ['locality', 'political'],
|
||||
};
|
||||
const square = (lat, lon, d) => [
|
||||
[lon - d, lat - d],
|
||||
[lon + d, lat - d],
|
||||
[lon + d, lat + d],
|
||||
[lon - d, lat + d],
|
||||
[lon - d, lat - d],
|
||||
];
|
||||
|
||||
test('a courtyard outline with no filled candidate anchor remains a point', async () => {
|
||||
const lat = 0.0005;
|
||||
const lon = 0.0005;
|
||||
const ring = square(lat, lon, 0.0005);
|
||||
const hole = square(lat, lon, 0.0002);
|
||||
const polygons = [[ring, hole]];
|
||||
const { resolve } = harness(null, {
|
||||
outlineRungs: rungsAnswering({
|
||||
ring,
|
||||
polygons,
|
||||
kind: 'building',
|
||||
heightM: 20,
|
||||
}),
|
||||
});
|
||||
const options = { latitude: lat, longitude: lon, entityKind: 'building' };
|
||||
const pending = await resolve(
|
||||
'this building',
|
||||
viewerAt(lat, lon, 1000),
|
||||
options,
|
||||
);
|
||||
const outline = await pending.resolveOutline();
|
||||
assert.equal(
|
||||
outline,
|
||||
null,
|
||||
'neither the mean nor the courtyard point is filled',
|
||||
);
|
||||
const inline = await resolve('this building', viewerAt(lat, lon, 1000), {
|
||||
...options,
|
||||
deferFootprint: false,
|
||||
});
|
||||
assert.equal(inline.ring, null);
|
||||
assert.deepEqual(
|
||||
[inline.lat, inline.lon],
|
||||
[lat, lon],
|
||||
'keep the honest point',
|
||||
);
|
||||
for (const resolved of [outline, inline]) {
|
||||
if (resolved?.polygons)
|
||||
assert.ok(polygonsContain(resolved.polygons, resolved.lat, resolved.lon));
|
||||
}
|
||||
});
|
||||
|
||||
test('a bent street keeps its outline when the anchor sits beside the centreline', async () => {
|
||||
// 500 m east then 500 m north: the ring mean lands off the 22 m ribbon.
|
||||
const lat0 = 30.27;
|
||||
const lon0 = -97.74;
|
||||
const dLon = 500 / (111320 * Math.cos((lat0 * Math.PI) / 180));
|
||||
const dLat = 500 / 111320;
|
||||
const line = [
|
||||
[lon0, lat0],
|
||||
[lon0 + dLon / 2, lat0],
|
||||
[lon0 + dLon, lat0],
|
||||
[lon0 + dLon, lat0 + dLat / 2],
|
||||
[lon0 + dLon, lat0 + dLat],
|
||||
];
|
||||
const footprint = streetFootprint({ lines: [line] });
|
||||
// A geocoded point 15 m north of the first leg, outside the ribbon.
|
||||
const lat = lat0 + 15 / 111320;
|
||||
const lon = lon0 + dLon / 2;
|
||||
assert.equal(polygonsContain(footprint.polygons, lat, lon), false);
|
||||
const { resolve } = harness(
|
||||
{ lat, lng: lon, name: 'Bent Street', label: 'Bent St', types: ['route'] },
|
||||
{ outlineRungs: rungsAnswering(footprint) },
|
||||
);
|
||||
const pending = await resolve('Bent Street', viewerAt(lat, lon, 3000), {
|
||||
entityKind: 'street',
|
||||
});
|
||||
const outline = await pending.resolveOutline();
|
||||
assert.equal(outline?.ring, footprint.ring, 'the street is still drawn');
|
||||
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
|
||||
assert.ok(Math.abs(outline.lat - lat0) < 1e-7, 'centred on the street');
|
||||
assert.ok(Math.abs(outline.lon - lon) < 1e-7);
|
||||
// A same-name street that never comes near the anchor is not that street.
|
||||
const far = lat0 + 300 / 111320;
|
||||
const away = harness(
|
||||
{
|
||||
lat: far,
|
||||
lng: lon,
|
||||
name: 'Bent Street',
|
||||
label: 'Bent St',
|
||||
types: ['route'],
|
||||
},
|
||||
{ outlineRungs: rungsAnswering(footprint) },
|
||||
);
|
||||
const distant = await away.resolve('Bent Street', viewerAt(far, lon, 3000), {
|
||||
entityKind: 'street',
|
||||
});
|
||||
assert.equal((await distant.resolveOutline())?.ring ?? null, null);
|
||||
});
|
||||
|
||||
test('local dateline outlines share a frame for area, centering and filled anchors', async () => {
|
||||
const ring = [
|
||||
[179.9995, -0.0005],
|
||||
[-179.9995, -0.0005],
|
||||
[-179.9995, 0.0005],
|
||||
[179.9995, 0.0005],
|
||||
[179.9995, -0.0005],
|
||||
];
|
||||
const hole = [
|
||||
[179.9998, -0.0003],
|
||||
[-179.9998, -0.0003],
|
||||
[-179.9998, 0.0003],
|
||||
[179.9998, 0.0003],
|
||||
[179.9998, -0.0003],
|
||||
];
|
||||
const polygons = [[ring, hole]];
|
||||
const { resolve } = harness(null, {
|
||||
outlineRungs: rungsAnswering({
|
||||
ring,
|
||||
polygons,
|
||||
kind: 'building',
|
||||
heightM: 20,
|
||||
}),
|
||||
});
|
||||
for (const deferFootprint of [true, false]) {
|
||||
for (const [lat, lon, filled] of [
|
||||
[0.0004, 179.9996, true],
|
||||
[0, 179.99999, false],
|
||||
[0.0004, 0, false],
|
||||
]) {
|
||||
const result = await resolve('this building', viewerAt(lat, lon, 1000), {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
entityKind: 'building',
|
||||
deferFootprint,
|
||||
});
|
||||
const outline = deferFootprint ? await result.resolveOutline() : result;
|
||||
if (!filled) {
|
||||
assert.equal(outline?.ring ?? null, null);
|
||||
continue;
|
||||
}
|
||||
assert.equal(outline.ring, ring);
|
||||
assert.equal(outline.polygons, polygons);
|
||||
assert.deepEqual(
|
||||
[outline.lat, outline.lon],
|
||||
[lat, lon],
|
||||
'keep the filled shell anchor',
|
||||
);
|
||||
}
|
||||
}
|
||||
const plain = harness(null, {
|
||||
outlineRungs: rungsAnswering({ ring, kind: 'building', heightM: 20 }),
|
||||
});
|
||||
const pending = await plain.resolve(
|
||||
'this building',
|
||||
viewerAt(0, 179.9999, 1000),
|
||||
{
|
||||
latitude: 0,
|
||||
longitude: 179.9999,
|
||||
entityKind: 'building',
|
||||
},
|
||||
);
|
||||
const centered = await pending.resolveOutline();
|
||||
assert.equal(centered.ring, ring);
|
||||
assert.ok(
|
||||
Math.abs(centered.lon) > 179.999 &&
|
||||
centered.lon >= -180 &&
|
||||
centered.lon < 180,
|
||||
);
|
||||
});
|
||||
|
||||
test('a coarse bundled admin-1 city waits for a finer outline', async () => {
|
||||
const overParis = viewerAt(48.8566, 2.3522, 40_000);
|
||||
// The outer-ring vertex mean is in the hole; the resolved anchor is in
|
||||
// the shell. Completion must not move that valid point into the hole.
|
||||
const ring = square(48.8566, 2.3822, 0.04);
|
||||
const hole = square(48.8566, 2.3822, 0.02);
|
||||
const polygons = [[ring, hole]];
|
||||
const rungs = rungsAnswering({ ring, polygons, kind: 'area', heightM: null });
|
||||
const fine = harness(PARIS_PLACE, { outlineRungs: rungs });
|
||||
const resolved = await fine.resolve('Paris', overParis);
|
||||
assert.equal(
|
||||
resolved.source,
|
||||
'geocode',
|
||||
'the 11-vertex admin-1 is not drawn at once',
|
||||
);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.deepEqual(outline.ring, ring, 'the finer outline wins');
|
||||
assert.deepEqual(outline.polygons, polygons);
|
||||
assert.equal(outline.lat, PARIS_PLACE.lat);
|
||||
assert.equal(outline.lon, PARIS_PLACE.lng);
|
||||
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
|
||||
assert.deepEqual(rungs.asked, [{ scope: 'city', target: 'Paris' }]);
|
||||
const inline = await fine.resolve('Paris', overParis, {
|
||||
deferFootprint: false,
|
||||
});
|
||||
assert.deepEqual(inline.polygons, polygons);
|
||||
assert.equal(inline.lat, outline.lat);
|
||||
assert.equal(inline.lon, outline.lon);
|
||||
|
||||
// Nothing finer (Nominatim off, busy or capped): the bundled shape, not a point.
|
||||
const coarse = harness(PARIS_PLACE, { outlineRungs: rungsAnswering(null) });
|
||||
const fallback = await (
|
||||
await coarse.resolve('Paris', overParis)
|
||||
).resolveOutline();
|
||||
assert.equal(fallback.adminArea, 'Paris');
|
||||
assert.ok(fallback.ring.length < 32);
|
||||
|
||||
// No geocoder answer at all: the bundled shape at once.
|
||||
const offline = harness(null, { outlineRungs: rungsAnswering(null) });
|
||||
const direct = await offline.resolve('Paris', overParis);
|
||||
assert.equal(direct.source, 'bundled');
|
||||
assert.equal(direct.label, 'Paris');
|
||||
|
||||
const overDallas = viewerAt(32.7767, -96.797, 20_000);
|
||||
assert.equal(
|
||||
await offline.resolve('Paris', overDallas),
|
||||
null,
|
||||
'a missing geocode cannot select the distant coarse homonym',
|
||||
);
|
||||
assert.equal(
|
||||
await coarse.resolve('Paris', overDallas),
|
||||
null,
|
||||
'a rejected foreign geocode cannot restore the same distant polygon',
|
||||
);
|
||||
const outsideParis = harness(
|
||||
{ ...PARIS_PLACE, lat: 48.9, lng: 2.6 },
|
||||
{ outlineRungs: rungsAnswering(null) },
|
||||
);
|
||||
const anchored = await outsideParis.resolve('Paris', overParis);
|
||||
assert.equal(anchored.source, 'geocode');
|
||||
assert.equal(
|
||||
await anchored.resolveOutline(),
|
||||
undefined,
|
||||
'a nearby anchor outside Paris preserves the transient base, not its coarse polygon',
|
||||
);
|
||||
});
|
||||
|
||||
test('deferred outlines preserve provider administrative identity metadata', async () => {
|
||||
const overParis = viewerAt(48.8566, 2.3522, 40_000);
|
||||
const ring = square(48.8566, 2.3522, 0.05);
|
||||
const { resolve } = harness(PARIS_PLACE, {
|
||||
outlineRungs: rungsAnswering({
|
||||
ring,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
adminArea: 'Île-de-France',
|
||||
adminLevel: 'admin1',
|
||||
}),
|
||||
});
|
||||
const outline = await (await resolve('Paris', overParis)).resolveOutline();
|
||||
assert.equal(outline.adminArea, 'Île-de-France');
|
||||
assert.equal(outline.adminLevel, 'admin1');
|
||||
});
|
||||
|
||||
test('an explicitly distant area keeps its deferred outline', async () => {
|
||||
const kathmandu = {
|
||||
lat: 27.7172,
|
||||
lng: 85.324,
|
||||
label: 'Kathmandu, Nepal',
|
||||
name: 'Kathmandu',
|
||||
types: ['locality', 'political'],
|
||||
};
|
||||
const ring = square(27.7172, 85.324, 0.05);
|
||||
const { resolve } = harness(kathmandu, {
|
||||
outlineRungs: rungsAnswering({ ring, kind: 'area', heightM: null }),
|
||||
});
|
||||
const resolved = await resolve('Kathmandu', SAN_FRANCISCO, {
|
||||
allowDistant: true,
|
||||
});
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
assert.deepEqual(
|
||||
(await resolved.resolveOutline()).ring,
|
||||
ring,
|
||||
'the deferred centroid gate honors the same explicit distance policy as the anchor',
|
||||
);
|
||||
});
|
||||
|
||||
test('detailed admin-1 units and admin asks keep the bundled shape', async () => {
|
||||
const { resolve } = harness(null, { outlineRungs: rungsAnswering(null) });
|
||||
for (const [name, lat, lon] of [
|
||||
['Berlin', 52.52, 13.4],
|
||||
['Bavaria', 48.9, 11.4],
|
||||
['Texas', 31, -99],
|
||||
]) {
|
||||
const resolved = await resolve(name, viewerAt(lat, lon, 400_000));
|
||||
assert.equal(resolved?.source, 'bundled', name);
|
||||
}
|
||||
// Countries are never coarse cities, however few vertices.
|
||||
const singapore = await resolve('Singapore', viewerAt(1.35, 103.82, 60_000));
|
||||
assert.equal(singapore?.source, 'bundled');
|
||||
// An explicit admin ask takes the admin unit as it is.
|
||||
const dc = await resolve('Paris', viewerAt(48.8566, 2.3522, 40_000), {
|
||||
entityKind: 'state',
|
||||
});
|
||||
assert.equal(dc?.source, 'bundled');
|
||||
});
|
||||
|
||||
test('a campus ask reaches the campus, not the Census place of its town', async () => {
|
||||
const stanfordTown = {
|
||||
lat: 37.4241,
|
||||
lng: -122.1661,
|
||||
label: 'Stanford, CA, USA',
|
||||
name: 'Stanford',
|
||||
types: ['locality', 'political'],
|
||||
};
|
||||
const campus = square(37.4275, -122.1697, 0.01);
|
||||
for (const ask of ['Stanford University', 'Stanford campus']) {
|
||||
const rungs = rungsAnswering({ ring: campus, kind: 'area', heightM: null });
|
||||
const { resolve } = harness(stanfordTown, { outlineRungs: rungs });
|
||||
const resolved = await resolve(ask, viewerAt(37.43, -122.17, 8000));
|
||||
assert.notEqual(resolved?.source, 'bundled', ask);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.deepEqual(outline.ring, campus, ask);
|
||||
assert.equal(
|
||||
outline.outlineCredit,
|
||||
null,
|
||||
'an injected outline is not inferred OSM',
|
||||
);
|
||||
assert.equal(
|
||||
rungs.asked[0].scope,
|
||||
'compound',
|
||||
`${ask}: past the Census rung`,
|
||||
);
|
||||
}
|
||||
// "Stanford" said to be a compound is the campus too.
|
||||
const kindRungs = rungsAnswering({
|
||||
ring: campus,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
});
|
||||
const byKind = harness(stanfordTown, { outlineRungs: kindRungs });
|
||||
const compound = await (
|
||||
await byKind.resolve('Stanford', viewerAt(37.43, -122.17, 8000), {
|
||||
entityKind: 'compound',
|
||||
})
|
||||
).resolveOutline();
|
||||
assert.deepEqual(compound.ring, campus);
|
||||
assert.equal(kindRungs.asked[0].scope, 'compound');
|
||||
const labelRungs = rungsAnswering({
|
||||
ring: campus,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
});
|
||||
const byLabel = harness(stanfordTown, { outlineRungs: labelRungs });
|
||||
const labelled = await byLabel.resolve(
|
||||
'Stanford',
|
||||
viewerAt(37.43, -122.17, 8000),
|
||||
{ labelHint: 'Stanford campus' },
|
||||
);
|
||||
assert.notEqual(labelled.source, 'bundled');
|
||||
assert.deepEqual((await labelled.resolveOutline()).ring, campus);
|
||||
assert.equal(labelRungs.asked[0].scope, 'compound');
|
||||
// "Stanford" alone is the town (the Stanford CDP), and a town named after a
|
||||
// college stays a town.
|
||||
const { resolve } = harness(stanfordTown, {
|
||||
outlineRungs: rungsAnswering(null),
|
||||
});
|
||||
const town = await (
|
||||
await resolve('Stanford', viewerAt(39, -100, 5_000_000))
|
||||
).resolveOutline();
|
||||
assert.equal(town.adminArea, 'Stanford');
|
||||
const station = harness(
|
||||
{
|
||||
lat: 30.628,
|
||||
lng: -96.3344,
|
||||
label: 'College Station, TX, USA',
|
||||
name: 'College Station',
|
||||
types: ['locality', 'political'],
|
||||
},
|
||||
{ outlineRungs: rungsAnswering(null) },
|
||||
);
|
||||
const collegeStation = await (
|
||||
await station.resolve('College Station', viewerAt(39, -100, 5_000_000))
|
||||
).resolveOutline();
|
||||
assert.equal(collegeStation.adminArea, 'College Station');
|
||||
});
|
||||
|
||||
test('a grounds ask never takes the state the geocoder typed', async () => {
|
||||
const texas = {
|
||||
lat: 31.0,
|
||||
lng: -99.0,
|
||||
label: 'Texas, USA',
|
||||
name: 'Texas',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
};
|
||||
for (const [ask, extra] of [
|
||||
['the Texas Capitol grounds', {}],
|
||||
['Texas Capitol', { entityKind: 'compound' }],
|
||||
// The grounds wording is only in the label.
|
||||
['Texas Capitol', { labelHint: 'Capitol grounds' }],
|
||||
]) {
|
||||
const rungs = rungsAnswering(null);
|
||||
const { resolve } = harness(texas, { outlineRungs: rungs });
|
||||
const resolved = await resolve(ask, viewerAt(31, -99, 3_000_000), extra);
|
||||
assert.notEqual(resolved?.source, 'bundled', ask);
|
||||
const outline = await resolved.resolveOutline();
|
||||
assert.notEqual(outline?.adminArea, 'Texas', ask);
|
||||
assert.equal(rungs.asked[0].scope, 'compound', ask);
|
||||
}
|
||||
});
|
||||
|
||||
test('configured OSM geometry retains its origin through inline, deferred and cached base results', async () => {
|
||||
const ring = square(31, -99, 0.001);
|
||||
let calls = 0;
|
||||
const resolver = createAnnotationResolver({
|
||||
boundarySource: {
|
||||
query: async () => {
|
||||
calls++;
|
||||
return [
|
||||
{
|
||||
type: 'way',
|
||||
id: 1,
|
||||
tags: { name: 'Library', building: 'yes' },
|
||||
geometry: ring.map(([lon, lat]) => ({ lon, lat })),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
outlineRungs: { resolve: (_ctx, { base }) => base() },
|
||||
});
|
||||
for (const deferFootprint of [false, true]) {
|
||||
const result = await resolver.resolveAnnotationTarget({
|
||||
viewer: viewerAt(31, -99, 3000),
|
||||
latitude: 31,
|
||||
longitude: -99,
|
||||
target: 'Library',
|
||||
entityKind: 'building',
|
||||
footprint: true,
|
||||
deferFootprint,
|
||||
});
|
||||
const outline = deferFootprint ? await result.resolveOutline() : result;
|
||||
assert.equal(outline.footprintKind, 'building');
|
||||
assert.equal(outline.outlineCredit, 'osm');
|
||||
assert.deepEqual(outline.ring, ring);
|
||||
}
|
||||
assert.equal(calls, 1, 'cached geometry retains its original provider');
|
||||
});
|
||||
@@ -44,10 +44,115 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
|
||||
for (const name of ['Gulf of Mexico', 'the Alps']) {
|
||||
const region = await resolveRegionRingForQuery(name);
|
||||
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
|
||||
assert.equal(region.source, 'natural-earth');
|
||||
assert.equal(region.error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('region ring: duplicate natural names need a containing geocoded anchor', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
});
|
||||
const miss = geocoderFor(null);
|
||||
assert.equal(
|
||||
await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(miss.calls, ['Cordillera Oriental']);
|
||||
|
||||
const inside = geocoderFor({
|
||||
lat: 5,
|
||||
lng: -73,
|
||||
label: 'Cordillera Oriental',
|
||||
name: 'Cordillera Oriental',
|
||||
types: ['natural_feature'],
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Cordillera Oriental',
|
||||
undefined,
|
||||
inside,
|
||||
);
|
||||
assert.equal(region?.source, 'natural-earth');
|
||||
assert.equal(region.name, 'Cordillera Oriental');
|
||||
assert.ok(region.ring.length >= 8);
|
||||
assert.deepEqual(inside.calls, ['Cordillera Oriental']);
|
||||
});
|
||||
|
||||
test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
});
|
||||
for (const [name, lat, lng] of [
|
||||
['Cordillera Oriental', 30.27, -97.74],
|
||||
['Canadian Shield', 45.6336, -74.86493],
|
||||
]) {
|
||||
const geocoder = geocoderFor({
|
||||
lat,
|
||||
lng,
|
||||
label: name,
|
||||
name,
|
||||
types: ['natural_feature'],
|
||||
});
|
||||
assert.equal(
|
||||
await resolveRegionRingForQuery(name, undefined, geocoder),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(geocoder.calls, [name]);
|
||||
}
|
||||
});
|
||||
|
||||
test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => {
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: async (point) => {
|
||||
lookups.push(point);
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
});
|
||||
const geo = geocoderFor({
|
||||
lat: 5,
|
||||
lng: -73,
|
||||
label: 'Cordillera Oriental',
|
||||
name: 'Cordillera Oriental',
|
||||
types: ['locality'],
|
||||
});
|
||||
const resolved = await resolveRegionRingForQuery(
|
||||
'Cordillera Oriental',
|
||||
undefined,
|
||||
geo,
|
||||
);
|
||||
assert.equal(
|
||||
resolved,
|
||||
null,
|
||||
'an unavailable city boundary is not a natural-region outline',
|
||||
);
|
||||
assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]);
|
||||
assert.deepEqual(geo.calls, ['Cordillera Oriental']);
|
||||
});
|
||||
|
||||
test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = resolveRegionRingForQuery('the Alps', controller.signal);
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
|
||||
const later = new AbortController();
|
||||
await assert.rejects(
|
||||
resolveRegionRingForQuery('Cordillera Oriental', later.signal, {
|
||||
async geocode() {
|
||||
later.abort();
|
||||
return { place: null };
|
||||
},
|
||||
}),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
||||
let adminSignal;
|
||||
let release;
|
||||
@@ -119,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn
|
||||
]) {
|
||||
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
|
||||
assert.equal(region?.name, name, query);
|
||||
assert.equal(region.source, 'bundled-admin');
|
||||
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
|
||||
}
|
||||
assert.deepEqual(geocoder.calls, [], 'no geocoding');
|
||||
|
||||
+465
-42
@@ -11,6 +11,7 @@ import {
|
||||
approximateAreaM2,
|
||||
approximateDistanceM,
|
||||
pointInPolygon,
|
||||
nearestPointOnLines,
|
||||
} from '../sources/featureGeometry.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
@@ -19,17 +20,83 @@ import {
|
||||
lookupNaturalRegionOutline,
|
||||
findNaturalRegion,
|
||||
} from '../data/naturalEarthRegions.js';
|
||||
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
|
||||
import {
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
polygonsContain as polygonsContainPoint,
|
||||
} from '../data/adminBoundaries.js';
|
||||
import {
|
||||
findNeighborhoodArea,
|
||||
findNeighborhoodAreaAt,
|
||||
findPlaceArea,
|
||||
findPlaceAreaAt,
|
||||
NEIGHBORHOOD_NEAR_KM,
|
||||
normalizePlaceName,
|
||||
PLACE_NEAR_KM,
|
||||
} from '../data/placeBoundaries.js';
|
||||
import {
|
||||
registerDynamicCredit,
|
||||
NATURAL_EARTH_CREDIT,
|
||||
US_CENSUS_CREDIT,
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
WOF_CREDIT,
|
||||
} from '../data/dataCredits.js';
|
||||
import { createOutlineRungs } from './outlineRungs.js';
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
|
||||
/** Prepare only local dateline outlines for planar admission and centering. */
|
||||
function localOutlineFrame(fp) {
|
||||
const original = { ring: fp?.ring, polygons: fp?.polygons, reference: null };
|
||||
if (!Array.isArray(fp?.ring) || fp.ring.length < 3) return original;
|
||||
let west = Infinity;
|
||||
let east = -Infinity;
|
||||
for (const [lon] of fp.ring) {
|
||||
west = Math.min(west, lon);
|
||||
east = Math.max(east, lon);
|
||||
}
|
||||
if (east - west <= 180) return original;
|
||||
const reference = fp.ring[0][0];
|
||||
west = Infinity;
|
||||
east = -Infinity;
|
||||
const shift = (ring) =>
|
||||
ring.map(([lon, lat]) => {
|
||||
const x = lon - 360 * Math.round((lon - reference) / 360);
|
||||
west = Math.min(west, x);
|
||||
east = Math.max(east, x);
|
||||
return [x, lat];
|
||||
});
|
||||
const ring = shift(fp.ring);
|
||||
const polygons = Array.isArray(fp.polygons)
|
||||
? fp.polygons.map((part) => part.map(shift))
|
||||
: fp.polygons;
|
||||
// Shapes still spanning half the globe retain their existing behavior. Source coordinates
|
||||
// are never rewritten, including holes and unrelated multipart geometry.
|
||||
if (!Number.isFinite(east - west) || east - west >= 180) return original;
|
||||
return { ring, polygons, reference };
|
||||
}
|
||||
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** How far a street may run from its anchor when the ribbon holds neither. */
|
||||
const STREET_SNAP_MAX_M = 100;
|
||||
|
||||
/**
|
||||
* Names that end like a landmark rather than a district: "Central Park",
|
||||
* "Lake Merritt"-style suffixes are checked on the last word only, so "Park
|
||||
* Slope" and "Notting Hill" stay districts.
|
||||
*/
|
||||
const LANDMARK_NAME =
|
||||
/\b(?:park|gardens?|square|plaza|lake|river|bay|beach|pier|harbou?r|airport|station|stadium|arena|cemetery|zoo|university|college|campus|palace|castle|cathedral|church|temple|mosque|bridge|market|mall|cent(?:er|re)|tower|museum|reservoir|forest|woods?|heath|common|green)$/i;
|
||||
|
||||
/** The name part of an ask, before any comma qualifier. */
|
||||
function stripQualifiers(query) {
|
||||
return String(query || '')
|
||||
.split(',')[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Entity facts that rule out a state/county reading of the ask. */
|
||||
const NON_ADMIN_ENTITY_KINDS = new Set([
|
||||
@@ -47,8 +114,12 @@ export function createAnnotationResolver({
|
||||
boundarySource,
|
||||
signal: lifetime,
|
||||
}),
|
||||
// Overpass-free rungs (OpenFreeMap tiles, guarded Nominatim) around the ladder.
|
||||
outlineRungs = createOutlineRungs(),
|
||||
} = {}) {
|
||||
requireFeatureSource(featureSource);
|
||||
const featureOutlineCredit =
|
||||
featureSource.outlineCredit === 'osm' ? 'osm' : null;
|
||||
lifetime?.throwIfAborted();
|
||||
|
||||
/**
|
||||
@@ -103,18 +174,6 @@ export function createAnnotationResolver({
|
||||
cacheWrite(cache, key, null);
|
||||
}
|
||||
|
||||
/** Wire an external AbortSignal to a local controller; returns a detach fn. */
|
||||
function linkAbort(controller, externalSignal) {
|
||||
if (!externalSignal) return () => {};
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
return () => {};
|
||||
}
|
||||
const onAbort = () => controller.abort();
|
||||
externalSignal.addEventListener('abort', onAbort, { once: true });
|
||||
return () => externalSignal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single annotation target to a normalized world anchor.
|
||||
*
|
||||
@@ -176,6 +235,21 @@ export function createAnnotationResolver({
|
||||
// locality suffixes; the types classify the entity (point-like vs area-like).
|
||||
let placesPrimary = null;
|
||||
let placeTypes = [];
|
||||
// A coarse bundled admin-1 shape that carries a city's name (Natural
|
||||
// Earth's 11-vertex "Paris"): kept as the fallback while the finer rungs
|
||||
// (Census, Nominatim) try first.
|
||||
let coarseCityShape = null;
|
||||
let bundledCenter = null;
|
||||
const coarseCityHolds = (point) =>
|
||||
coarseCityShape &&
|
||||
point &&
|
||||
polygonsContainPoint(coarseCityShape.polygons, point.lat, point.lon);
|
||||
const compoundAsk =
|
||||
entityKind === 'compound' ||
|
||||
isCampusAsk(target) ||
|
||||
isGroundsLikeQuery(target) ||
|
||||
isGroundsLikeQuery(labelHint);
|
||||
const askNamesAdmin = Boolean(adminScopeFromAsk(target, entityKind));
|
||||
// Instrumentation (logged once per target at the end): which sources were tried and what they returned.
|
||||
const trace = {
|
||||
query: String(target || '').trim(),
|
||||
@@ -193,15 +267,32 @@ export function createAnnotationResolver({
|
||||
footprint &&
|
||||
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
|
||||
trace.query &&
|
||||
!compoundAsk &&
|
||||
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
|
||||
) {
|
||||
const center =
|
||||
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
|
||||
bundledCenter = center;
|
||||
const admin = await findAdminArea(trace.query, { near: center }).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
|
||||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||||
coarseCityShape = admin;
|
||||
else if (admin)
|
||||
return bundledAdminTarget(viewer, admin, trace.query, signal);
|
||||
// Bundled US places and neighborhoods named at or near the view. A
|
||||
// campus, grounds or monument ask ("Stanford" as a compound, "the
|
||||
// Capitol grounds") wants that feature, not a district of its name,
|
||||
// and "the area around X" is drawn by the ladder below.
|
||||
if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) {
|
||||
const area = await bundledAreaNear(trace.query, center, {
|
||||
entityKind,
|
||||
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (area) return bundledAdminTarget(viewer, area, trace.query, signal);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
@@ -329,7 +420,10 @@ export function createAnnotationResolver({
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon))
|
||||
return coarseCityHolds(bundledCenter)
|
||||
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
|
||||
: null;
|
||||
|
||||
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
|
||||
// several Texas Capitol monuments landed blocks-to-miles apart, looking "all over the map"). When
|
||||
@@ -394,7 +488,9 @@ export function createAnnotationResolver({
|
||||
`osmSnap=${trace.osmSnap} → FINAL source=rejected (proximity gate, ${drift.driftKm.toFixed(0)}km > ${drift.limitKm.toFixed(0)}km)`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return coarseCityHolds(bundledCenter)
|
||||
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +510,18 @@ export function createAnnotationResolver({
|
||||
// an admin boundary for a city/state, the enclosing compound for a mall/campus, a single
|
||||
// building for a premise. The voice model's entityKind (an entity FACT) refines an
|
||||
// unresolved 'auto' scope only — real geocode types always win.
|
||||
const baseScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
|
||||
// A campus or grounds ask ("Stanford University", "Stanford campus",
|
||||
// "the Capitol grounds", in the target or its label) or a compound by
|
||||
// entity fact wants that compound even when the geocoder types the town
|
||||
// or the state: never a Census place, neighborhood or admin boundary.
|
||||
const typedScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
|
||||
const baseScope =
|
||||
compoundAsk &&
|
||||
['country', 'state', 'county', 'city', 'neighborhood'].includes(
|
||||
typedScope,
|
||||
)
|
||||
? 'compound'
|
||||
: typedScope;
|
||||
// Point-like targets (monuments/statues/memorials/…) resolve POINT-FIRST: only an
|
||||
// (almost) exactly-named, monument-scale polygon may replace the point; a nearby polygon
|
||||
// sharing locality words must not (docs/field-test-rootcause-2026-06-30.md §1).
|
||||
@@ -443,7 +550,8 @@ export function createAnnotationResolver({
|
||||
* The inline path awaits it right here; progressive callers (deferFootprint) invoke it
|
||||
* AFTER the anchor mark is drawn and upgrade the mark in place.
|
||||
*/
|
||||
const resolveOutline = async () => {
|
||||
const resolveBaseOutline = async () => {
|
||||
signal?.throwIfAborted();
|
||||
let scope = baseScope;
|
||||
const isAdmin =
|
||||
scope === 'country' ||
|
||||
@@ -469,8 +577,10 @@ export function createAnnotationResolver({
|
||||
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (ne) {
|
||||
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
|
||||
traceBundledOutline(ne.name, 'natural-earth');
|
||||
const neCentroid = ringCentroid(ne.ring);
|
||||
return {
|
||||
ring: ne.ring,
|
||||
@@ -478,6 +588,7 @@ export function createAnnotationResolver({
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
naturalRegion: ne.name,
|
||||
outlineSource: 'natural-earth',
|
||||
lat: neCentroid?.lat ?? lat,
|
||||
lon: neCentroid?.lon ?? lon,
|
||||
height: sampleGroundHeight(
|
||||
@@ -526,7 +637,20 @@ export function createAnnotationResolver({
|
||||
lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return bundledAdminOutline(viewer, admin);
|
||||
signal?.throwIfAborted();
|
||||
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
|
||||
coarseCityShape ||= admin;
|
||||
else if (admin) return bundledAdminOutline(viewer, admin, signal);
|
||||
}
|
||||
// A geocoded US city, town or CDP: the Census place that holds it.
|
||||
if (scope === 'city') {
|
||||
const place = await findPlaceAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (place) return bundledAdminOutline(viewer, place, signal);
|
||||
}
|
||||
// Pure admin: only an admin boundary is correct — never fall back to a
|
||||
// building/landuse (a city is never a single building).
|
||||
@@ -537,7 +661,21 @@ export function createAnnotationResolver({
|
||||
// resolve here instantly to a REAL boundary, sidestepping the slow/flaky live-Overpass
|
||||
// path that times out and falls back to points (see docs/field-test-2-analysis.md).
|
||||
const ext = await lookupNeighborhoodRing(lat, lon, matchName);
|
||||
if (ext) fp = { ring: ext.ring, kind: 'area', heightM: null };
|
||||
signal?.throwIfAborted();
|
||||
if (ext) {
|
||||
traceBundledOutline(ext.name || matchName, 'datasf');
|
||||
fp = { ring: ext.ring, kind: 'area', heightM: null };
|
||||
}
|
||||
// THEN a bundled Who's On First neighborhood carrying the name at the point.
|
||||
if (!fp) {
|
||||
const wof = await findNeighborhoodAreaAt(
|
||||
[target, matchName],
|
||||
lat,
|
||||
lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (wof) return bundledAdminOutline(viewer, wof, signal);
|
||||
}
|
||||
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
|
||||
// returns a footprint, null (definitively no polygon), or undefined (transient
|
||||
// upstream failure). Synthesize a blob (the "Mission" problem, research §3b) ONLY
|
||||
@@ -663,6 +801,12 @@ export function createAnnotationResolver({
|
||||
}
|
||||
}
|
||||
}
|
||||
return finishOutline(fp, scope);
|
||||
};
|
||||
|
||||
/** Validate a footprint for its scope and re-centre it (resolveOutline's patch). */
|
||||
const finishOutline = (fp, scope) => {
|
||||
const frame = localOutlineFrame(fp);
|
||||
// Scope sanity: reject a real footprint whose area is wildly wrong for the asked
|
||||
// scope (a building/compound/neighborhood intent must never draw a state-sized
|
||||
// blob). Synthesized discs are deliberately sized and exempt.
|
||||
@@ -671,20 +815,50 @@ export function createAnnotationResolver({
|
||||
!fp.synthesized &&
|
||||
Array.isArray(fp.ring) &&
|
||||
fp.ring.length >= 3 &&
|
||||
exceedsScopeArea(fp, scope)
|
||||
exceedsScopeArea(frame, scope)
|
||||
) {
|
||||
fp = null;
|
||||
}
|
||||
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
|
||||
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
|
||||
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
|
||||
const centroid = ringCentroid(fp.ring);
|
||||
let centroid = ringCentroid(frame.ring);
|
||||
if (!centroid) return null;
|
||||
// An outer-ring mean can lie in a hole or outside a concave part. Keep
|
||||
// a filled original anchor or decline, without changing ordinary centering
|
||||
// or inventing a new interior point.
|
||||
if (
|
||||
Array.isArray(frame.polygons) &&
|
||||
!polygonsContainPoint(frame.polygons, centroid.lat, centroid.lon)
|
||||
) {
|
||||
const anchorLon =
|
||||
frame.reference === null
|
||||
? lon
|
||||
: lon - 360 * Math.round((lon - frame.reference) / 360);
|
||||
if (polygonsContainPoint(frame.polygons, lat, anchorLon)) {
|
||||
centroid = { lat, lon };
|
||||
} else if (Array.isArray(fp.lines)) {
|
||||
// A bent street's ribbon rarely holds its ring mean, and a geocoded
|
||||
// anchor can sit beside the centreline: centre on the street itself,
|
||||
// only when it runs beside the anchor.
|
||||
const onStreet = nearestPointOnLines(fp.lines, lat, lon);
|
||||
if (!onStreet || onStreet.distanceM > STREET_SNAP_MAX_M) return null;
|
||||
centroid = { lat: onStreet.lat, lon: onStreet.lon };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
frame.reference !== null &&
|
||||
(centroid.lon < -180 || centroid.lon >= 180)
|
||||
)
|
||||
centroid.lon = ((((centroid.lon + 180) % 360) + 360) % 360) - 180;
|
||||
// Same drift bound as the anchor gate: a polygon whose centroid would drag a good
|
||||
// anchor out of plausibility is the wrong feature — drop it, keep the point.
|
||||
if (
|
||||
fromGeocode &&
|
||||
!bypassNearViewGuards &&
|
||||
!allowDistant &&
|
||||
gateDrift(centroid.lat, centroid.lon)
|
||||
)
|
||||
return null;
|
||||
@@ -693,17 +867,59 @@ export function createAnnotationResolver({
|
||||
footprintKind: fp.kind,
|
||||
buildingHeight: fp.heightM,
|
||||
synthesized: Boolean(fp.synthesized),
|
||||
outlineCredit: fp.outlineCredit || null,
|
||||
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
|
||||
...(fp.adminArea ? { adminArea: fp.adminArea } : {}),
|
||||
...(fp.adminLevel ? { adminLevel: fp.adminLevel } : {}),
|
||||
lat: centroid.lat,
|
||||
lon: centroid.lon,
|
||||
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
|
||||
};
|
||||
};
|
||||
|
||||
// Streets and pointed-at buildings from tiles, then this ladder, then the
|
||||
// guarded Nominatim outline (see outlineRungs.js).
|
||||
const outlineContext = {
|
||||
scope: baseScope,
|
||||
target,
|
||||
matchName,
|
||||
lat,
|
||||
lon,
|
||||
fromName: fromGeocode || source === 'places' || source === 'osm-local',
|
||||
pointLike,
|
||||
groundsLike,
|
||||
around: intent === 'around_the_thing',
|
||||
view: viewportProximity(viewer),
|
||||
signal,
|
||||
credit: (kind) =>
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT,
|
||||
),
|
||||
};
|
||||
const resolveOutline = async () => {
|
||||
const result = await outlineRungs.resolve(outlineContext, {
|
||||
base: resolveBaseOutline,
|
||||
finish: finishOutline,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
// Nothing finer answered (no polygon, Nominatim off, busy or capped):
|
||||
// the coarse bundled shape beats a point.
|
||||
if (
|
||||
coarseCityHolds({ lat, lon }) &&
|
||||
!(result?.ring && !result.synthesized)
|
||||
)
|
||||
return bundledAdminOutline(viewer, coarseCityShape, signal);
|
||||
return result;
|
||||
};
|
||||
|
||||
let ring = null;
|
||||
let footprintKind = null; // 'building' | 'area'
|
||||
let buildingHeight = null; // meters, only for buildings
|
||||
let outlineUnavailable = false;
|
||||
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
|
||||
let polygons = null; // every part of a multi-part outline, when known
|
||||
let outlineCredit = null;
|
||||
if (footprint && !deferFootprint) {
|
||||
const fp = await resolveOutline();
|
||||
outlineUnavailable = isUnavailableCapability(fp);
|
||||
@@ -712,6 +928,8 @@ export function createAnnotationResolver({
|
||||
footprintKind = fp.footprintKind;
|
||||
buildingHeight = fp.buildingHeight;
|
||||
synthesized = fp.synthesized;
|
||||
polygons = fp.polygons || null;
|
||||
outlineCredit = fp.outlineCredit || null;
|
||||
// Re-center the anchor on the resolved footprint centroid.
|
||||
lat = fp.lat;
|
||||
lon = fp.lon;
|
||||
@@ -739,6 +957,8 @@ export function createAnnotationResolver({
|
||||
source,
|
||||
synthesized,
|
||||
outlineUnavailable,
|
||||
outlineCredit,
|
||||
...(polygons ? { polygons } : {}),
|
||||
viewport: placeViewport,
|
||||
...(footprint && deferFootprint ? { resolveOutline } : {}),
|
||||
};
|
||||
@@ -778,13 +998,110 @@ export function createAnnotationResolver({
|
||||
const GROUNDS_RADIUS_M = 300; // "X grounds/compound/campus" loose disc when OSM has no polygon
|
||||
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
|
||||
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
|
||||
const COARSE_CITY_MAX_KM2 = 5_000; // city-sized admin-1 units (see isCoarseCityShape)
|
||||
const COARSE_CITY_MAX_VERTICES = 32;
|
||||
|
||||
/** Credit the pack a bundled boundary came from. */
|
||||
function creditAdminSource(viewer, admin) {
|
||||
registerDynamicCredit(
|
||||
viewer,
|
||||
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
|
||||
);
|
||||
const credit = {
|
||||
'natural-earth': NATURAL_EARTH_CREDIT,
|
||||
'us-census': US_CENSUS_CREDIT,
|
||||
wof: WOF_CREDIT,
|
||||
}[admin.source];
|
||||
if (credit) registerDynamicCredit(viewer, credit);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundled US place or neighborhood an ask names at or near the view,
|
||||
* from Census places, the San Francisco DataSF pack (first in SF), then
|
||||
* Who's On First. How far "near" reaches follows the view (half its
|
||||
* radius); a state-qualified place ("Springfield, Illinois") needs no
|
||||
* view. A district ask takes a neighborhood first and a place only when
|
||||
* the view is inside it ("Richmond" over SF is not Richmond, CA). Without
|
||||
* an entity kind the candidate that holds the view wins, else the nearer.
|
||||
* A name that ends like a landmark ("Central Park", "Lake Merritt") is
|
||||
* left to the landmark ladder unless the ask is a district.
|
||||
*/
|
||||
async function bundledAreaNear(
|
||||
query,
|
||||
center,
|
||||
{ entityKind = null, viewRadiusKm = null } = {},
|
||||
) {
|
||||
const district = entityKind === 'district';
|
||||
if (!district && LANDMARK_NAME.test(stripQualifiers(query))) return null;
|
||||
const reach = Number.isFinite(viewRadiusKm) ? viewRadiusKm / 2 : null;
|
||||
const clamp = (km, lo, hi) =>
|
||||
km === null ? hi : Math.min(hi, Math.max(lo, km));
|
||||
const placeReach = district ? 0 : clamp(reach, 3, PLACE_NEAR_KM);
|
||||
const place = await findPlaceArea(query, {
|
||||
near: center,
|
||||
nearKm: placeReach,
|
||||
}).catch(() => null);
|
||||
const holds = (area) =>
|
||||
Boolean(center) &&
|
||||
polygonsContainPoint(area.polygons, center.lat, center.lon);
|
||||
// A district ask only takes a place the view is inside (or one a state
|
||||
// qualifier named outright).
|
||||
const placeOk =
|
||||
place && (!district || holds(place) || /,/.test(query)) ? place : null;
|
||||
if (!center) return placeOk;
|
||||
let hood = null;
|
||||
const sf = await lookupNeighborhoodRing(center.lat, center.lon, query, {
|
||||
exact: true,
|
||||
}).catch(() => null);
|
||||
if (sf) hood = sfNeighborhoodArea(sf);
|
||||
else
|
||||
hood = await findNeighborhoodArea(query, {
|
||||
near: center,
|
||||
nearKm: clamp(reach, 1, NEIGHBORHOOD_NEAR_KM),
|
||||
}).catch(() => null);
|
||||
// Outside a district ask, an alternate name unrelated to the name is too
|
||||
// weak: Se in Sao Paulo lists "Sao Paulo" among its names, and "outline
|
||||
// Sao Paulo" means the city ("Richmond" for Richmond District still counts).
|
||||
// Whole words: an alias hit on "York" does not keep "Yorkville".
|
||||
if (
|
||||
!district &&
|
||||
hood?.byAlias &&
|
||||
!` ${normalizePlaceName(hood.name)} `.includes(
|
||||
` ${normalizePlaceName(stripQualifiers(query))} `,
|
||||
)
|
||||
)
|
||||
hood = null;
|
||||
if (!hood || !placeOk) return district ? hood || placeOk : placeOk || hood;
|
||||
if (district) return hood;
|
||||
const hoodHolds = holds(hood);
|
||||
const placeHolds = holds(placeOk);
|
||||
if (hoodHolds !== placeHolds) return hoodHolds ? hood : placeOk;
|
||||
const distance = (area) =>
|
||||
approximateDistanceM(
|
||||
center.lat,
|
||||
center.lon,
|
||||
area.label.lat,
|
||||
area.label.lon,
|
||||
);
|
||||
return distance(hood) <= distance(placeOk) ? hood : placeOk;
|
||||
}
|
||||
|
||||
/** An SF DataSF ring in the bundled-area shape `bundledAdminTarget` takes. */
|
||||
function sfNeighborhoodArea({ ring, name }) {
|
||||
const lons = ring.map((p) => p[0]);
|
||||
const lats = ring.map((p) => p[1]);
|
||||
return {
|
||||
kind: 'neighborhood',
|
||||
name,
|
||||
region: null,
|
||||
source: 'datasf',
|
||||
polygons: [[ring]],
|
||||
ring,
|
||||
bbox: [
|
||||
Math.min(...lons),
|
||||
Math.min(...lats),
|
||||
Math.max(...lons),
|
||||
Math.max(...lats),
|
||||
],
|
||||
label: ringCentroid(ring),
|
||||
candidates: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -794,8 +1111,10 @@ export function createAnnotationResolver({
|
||||
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
|
||||
* centroid drift bound: the unit IS the asked scope.
|
||||
*/
|
||||
function bundledAdminOutline(viewer, admin) {
|
||||
function bundledAdminOutline(viewer, admin, signal) {
|
||||
signal?.throwIfAborted();
|
||||
creditAdminSource(viewer, admin);
|
||||
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
|
||||
const { lat, lon } = admin.label;
|
||||
return {
|
||||
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
|
||||
@@ -804,15 +1123,41 @@ export function createAnnotationResolver({
|
||||
buildingHeight: null,
|
||||
synthesized: false,
|
||||
adminArea: admin.name,
|
||||
outlineSource: admin.source,
|
||||
lat,
|
||||
lon,
|
||||
height: sampleGroundHeight(viewer, lon, lat),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a bundled unit is too coarse to stand for a city of its name: a
|
||||
* Natural Earth admin-1 at city size (up to 5,000 km²) drawn with fewer
|
||||
* than 32 vertices. Paris (11), Buenos Aires (11), Washington DC (9),
|
||||
* Vienna and Prague (23) and Seoul (28) are; Berlin (48), Hamburg (43),
|
||||
* Bremen (39) and every larger state are not. Countries never are.
|
||||
*/
|
||||
function isCoarseCityShape(admin) {
|
||||
if (admin?.source !== 'natural-earth' || admin.kind !== 'state')
|
||||
return false;
|
||||
if (!(admin.areaKm2 <= COARSE_CITY_MAX_KM2)) return false;
|
||||
const vertices = (admin.polygons || []).reduce(
|
||||
(n, poly) => n + poly.reduce((m, ring) => m + ring.length, 0),
|
||||
0,
|
||||
);
|
||||
return vertices < COARSE_CITY_MAX_VERTICES;
|
||||
}
|
||||
|
||||
/** One trace line per outline a bundled pack answered (same shape as outlineRungs). */
|
||||
function traceBundledOutline(name, source, parts = 1) {
|
||||
console.log(
|
||||
`[Outline] "${name}": bundled → ${source} (${parts || 1} part(s))`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A complete resolved target for a bundled administrative unit (no outline pending). */
|
||||
function bundledAdminTarget(viewer, admin, query) {
|
||||
const outline = bundledAdminOutline(viewer, admin);
|
||||
function bundledAdminTarget(viewer, admin, query, signal) {
|
||||
const outline = bundledAdminOutline(viewer, admin, signal);
|
||||
const [west, south, east, north] = admin.bbox;
|
||||
console.log(
|
||||
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
|
||||
@@ -829,6 +1174,7 @@ export function createAnnotationResolver({
|
||||
buildingHeight: null,
|
||||
label: admin.name,
|
||||
source: 'bundled',
|
||||
outlineSource: admin.source,
|
||||
synthesized: false,
|
||||
outlineUnavailable: false,
|
||||
viewport: {
|
||||
@@ -1239,7 +1585,12 @@ export function createAnnotationResolver({
|
||||
? 40
|
||||
: 120;
|
||||
ring = simplifyRing(ring, tolM);
|
||||
const fp = { ring, kind: 'area', heightM: null };
|
||||
const fp = {
|
||||
ring,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
};
|
||||
|
||||
// Scope sanity BEFORE caching: a city-suffixed neighborhood name ("Downtown San
|
||||
// Francisco") can match the CITY admin. Caching an oversized ring would poison the
|
||||
@@ -1323,7 +1674,14 @@ export function createAnnotationResolver({
|
||||
bestRing = closeRing(coords.map((p) => [p.lon, p.lat]));
|
||||
}
|
||||
}
|
||||
return bestRing ? { ring: bestRing, kind: 'area', heightM: null } : null;
|
||||
return bestRing
|
||||
? {
|
||||
ring: bestRing,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1366,7 +1724,12 @@ export function createAnnotationResolver({
|
||||
}
|
||||
}
|
||||
if (bestRing) {
|
||||
const fp = { ring: bestRing, kind: 'area', heightM: null };
|
||||
const fp = {
|
||||
ring: bestRing,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
};
|
||||
cacheWrite(footprintCache, cacheKey, fp);
|
||||
return fp;
|
||||
}
|
||||
@@ -1397,6 +1760,7 @@ export function createAnnotationResolver({
|
||||
ring: bufferCorridor(line, 11),
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
};
|
||||
cacheWrite(footprintCache, cacheKey, fp);
|
||||
return fp;
|
||||
@@ -1535,7 +1899,12 @@ export function createAnnotationResolver({
|
||||
areaM2 < bestArea * 0.999 ||
|
||||
(areaM2 <= bestArea * 1.05 && nameMatch && !bestNameMatch); // near-tie: name-match wins
|
||||
if (better) {
|
||||
best = { ring: ringLonLat, kind: 'area', heightM: null };
|
||||
best = {
|
||||
ring: ringLonLat,
|
||||
kind: 'area',
|
||||
heightM: null,
|
||||
outlineCredit: featureOutlineCredit,
|
||||
};
|
||||
bestArea = areaM2;
|
||||
bestNameMatch = nameMatch;
|
||||
}
|
||||
@@ -1561,6 +1930,19 @@ export function createAnnotationResolver({
|
||||
/** A target that reads like an enclosing GROUNDS / COMPOUND / CAMPUS (e.g. "Texas Capitol grounds")
|
||||
* rather than a single building — used to synthesize a loose-footprint disc when OSM has no real
|
||||
* polygon, instead of failing outright. */
|
||||
/**
|
||||
* A university, college or campus named as such: "Stanford University",
|
||||
* "Boston College", "University of Texas", "Stanford campus". Towns named
|
||||
* after one ("College Station", "University Park") are not.
|
||||
*/
|
||||
function isCampusAsk(query) {
|
||||
const text = stripQualifiers(String(query || '')).trim();
|
||||
return (
|
||||
/\b(?:university|college|campus)$/i.test(text) ||
|
||||
/^(?:the\s+)?(?:university|college)\s+of\b/i.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isGroundsLikeQuery(query) {
|
||||
return /\b(grounds|compound|campus|complex|quad|plaza)\b/i.test(
|
||||
String(query || ''),
|
||||
@@ -1784,6 +2166,7 @@ export function createAnnotationResolver({
|
||||
best = {
|
||||
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
|
||||
kind: isBuilding ? 'building' : 'area',
|
||||
outlineCredit: featureOutlineCredit,
|
||||
heightM: isBuilding
|
||||
? (element.heightM ??
|
||||
Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6)))
|
||||
@@ -2004,8 +2387,8 @@ export function createAnnotationResolver({
|
||||
/**
|
||||
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
|
||||
* a name-only entry point that reuses this module's boundary machinery
|
||||
* without the annotation pipeline. Natural Earth pack first (offline,
|
||||
* instant; largest-area match is correct for a global name-only ask), then
|
||||
* without the annotation pipeline. Unique Natural Earth names resolve first
|
||||
* (offline); duplicate names require a geocoded containment anchor, then
|
||||
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
|
||||
* Overpass, so repeat asks are instant). Returns null when the name doesn't
|
||||
* resolve to a region-like boundary — the analyst engine reports that
|
||||
@@ -2020,7 +2403,7 @@ export function createAnnotationResolver({
|
||||
* @param {AbortSignal} [signal]
|
||||
* @param {object} [placeSearch]
|
||||
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
|
||||
*/
|
||||
async function resolveRegionRingForQuery(
|
||||
name,
|
||||
@@ -2037,16 +2420,24 @@ export function createAnnotationResolver({
|
||||
const q = String(name || '').trim();
|
||||
if (!q) return null;
|
||||
const ne = await findNaturalRegion(q).catch(() => null);
|
||||
if (ne?.polygons?.length) {
|
||||
signal?.throwIfAborted();
|
||||
if (ne?.candidates === 1 && ne.polygons?.length) {
|
||||
// Largest ring carries the query scope; multi-ring regions (Andes) keep
|
||||
// their main cordillera — good enough for containment counting.
|
||||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||||
if (ring?.length >= 3) return { name: ne.name, ring };
|
||||
if (ring?.length >= 3)
|
||||
return { name: ne.name, ring, source: 'natural-earth' };
|
||||
}
|
||||
// Bundled states/provinces/counties by name (offline); the main part only,
|
||||
// like the Natural Earth rung above.
|
||||
const admin = await findAdminArea(q).catch(() => null);
|
||||
if (admin) return { name: admin.name, ring: [...admin.ring] };
|
||||
signal?.throwIfAborted();
|
||||
if (admin)
|
||||
return {
|
||||
name: admin.name,
|
||||
ring: [...admin.ring],
|
||||
source: 'bundled-admin',
|
||||
};
|
||||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||||
if (!Number.isFinite(budgetMs)) return lookup;
|
||||
let timer;
|
||||
@@ -2067,8 +2458,26 @@ export function createAnnotationResolver({
|
||||
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
|
||||
() => null,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!geo) return null;
|
||||
const scope = scopeFromTypes(geo.types);
|
||||
if (
|
||||
scope === 'auto' ||
|
||||
(scope === 'compound' && geo.types?.includes('natural_feature'))
|
||||
) {
|
||||
const natural = await lookupNaturalRegionOutline(
|
||||
q,
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (natural)
|
||||
return {
|
||||
name: natural.name,
|
||||
ring: natural.ring,
|
||||
source: 'natural-earth',
|
||||
};
|
||||
}
|
||||
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
|
||||
if (scope === 'state' || scope === 'county' || scope === 'country') {
|
||||
const admin = await findAdminAreaAt(
|
||||
@@ -2077,12 +2486,26 @@ export function createAnnotationResolver({
|
||||
geo.lon,
|
||||
scope,
|
||||
).catch(() => null);
|
||||
if (admin) return { name: q, ring: [...admin.ring] };
|
||||
signal?.throwIfAborted();
|
||||
if (admin)
|
||||
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
|
||||
}
|
||||
if (scope === 'city') {
|
||||
const place = await findPlaceAreaAt(
|
||||
[q, geo.primaryName],
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
).catch(() => null);
|
||||
signal?.throwIfAborted();
|
||||
if (place)
|
||||
return { name: q, ring: [...place.ring], source: 'bundled-admin' };
|
||||
}
|
||||
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
|
||||
() => null,
|
||||
);
|
||||
if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring };
|
||||
signal?.throwIfAborted();
|
||||
if (fp?.ring?.length >= 3)
|
||||
return { name: q, ring: fp.ring, source: 'remote-boundary' };
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,164 +1,177 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
|
||||
import ScreenAnnotations from '../ui/views/panels/ScreenAnnotations.vue';
|
||||
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor(owner) {
|
||||
this.owner = owner;
|
||||
this.names = new Set();
|
||||
}
|
||||
|
||||
reset(value) {
|
||||
this.names = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
for (const name of names) this.names.add(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
for (const name of names) this.names.delete(name);
|
||||
this.owner.attributes.set('class', Array.from(this.names).join(' '));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.names.has(name);
|
||||
}
|
||||
// happy-dom does not lay out SVG; every element measures as a 40 x 16 box.
|
||||
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
|
||||
if (type) type.prototype.getBBox = () => ({ x: 0, y: 0, width: 40, height: 16 });
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.style = {};
|
||||
this.classList = new FakeClassList(this);
|
||||
this.textContent = '';
|
||||
}
|
||||
const settle = async () => {
|
||||
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||
};
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'class') this.classList.reset(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.setAttribute('class', value);
|
||||
}
|
||||
|
||||
get className() {
|
||||
return this.getAttribute('class') || '';
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
this.setAttribute('id', value);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.getAttribute('id') || '';
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child);
|
||||
if (index >= 0) this.children.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.parentNode?.removeChild(this);
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const className = selector.startsWith('.') ? selector.slice(1) : null;
|
||||
const matches = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (className && child.classList.contains(className)) matches.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
|
||||
getBBox() {
|
||||
return { x: 0, y: 0, width: 40, height: 16 };
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
}
|
||||
}
|
||||
|
||||
function fakeDocument() {
|
||||
const head = new FakeElement('head');
|
||||
const body = new FakeElement('body');
|
||||
const roots = [head, body];
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createElementNS: (_namespace, tag) => new FakeElement(tag),
|
||||
getElementById(id) {
|
||||
let found = null;
|
||||
const visit = (node) => {
|
||||
if (node.getAttribute('id') === id) found = node;
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findAnnotationGroup(document) {
|
||||
const layer = document.body.children.find((child) => child.classList.contains('gev-screen-whiteboard'));
|
||||
const svg = layer.children.find((child) => child.classList.contains('gev-screen-whiteboard-svg'));
|
||||
return {
|
||||
svg,
|
||||
group: svg.children.find((child) => child.classList.contains('gev-anno')),
|
||||
};
|
||||
}
|
||||
|
||||
test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
/** Mount the board's view; animation frames run when `frames()` is called. */
|
||||
function board(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useScreenAnnotationsStore(pinia);
|
||||
const app = createApp(ScreenAnnotations).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
const queued = [];
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
globalThis.requestAnimationFrame = (callback) => queued.push(callback);
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
app.unmount();
|
||||
});
|
||||
const frames = () => {
|
||||
for (const callback of queued.splice(0)) callback();
|
||||
};
|
||||
const svg = () => document.querySelector('.gev-screen-whiteboard-svg');
|
||||
const groups = () => [...(svg()?.querySelectorAll('g.gev-anno') ?? [])];
|
||||
return { store, frames, svg, groups };
|
||||
}
|
||||
|
||||
function cameraOver(lon, lat) {
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
// Low enough that point marks keep their full size.
|
||||
return { positionWC, directionWC, positionCartographic: { height: 500 } };
|
||||
}
|
||||
|
||||
function sceneFor(camera, extra = {}) {
|
||||
return {
|
||||
camera,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** Project every point to the given window position for the test's length. */
|
||||
function projectTo(t, at) {
|
||||
const original = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = typeof at === 'function' ? at : () => at;
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = original;
|
||||
});
|
||||
}
|
||||
|
||||
test('a pin renders its reticle, leader and callout on the page board', async (t) => {
|
||||
const { store, frames, svg, groups } = board(t);
|
||||
projectTo(t, { x: 200, y: 300 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: 'pin',
|
||||
type: 'pin',
|
||||
color: 'amber',
|
||||
label: 'Capitol',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
});
|
||||
await settle();
|
||||
const layer = document.body.querySelector(':scope > .gev-screen-whiteboard');
|
||||
assert.ok(layer);
|
||||
assert.equal(svg().getAttribute('preserveAspectRatio'), 'none');
|
||||
assert.equal(svg().getAttribute('viewBox'), '0 0 800 600');
|
||||
assert.ok(svg().querySelector('defs > filter#gev-sketch > feTurbulence'));
|
||||
const [group] = groups();
|
||||
assert.equal(group.getAttribute('class'), 'gev-anno');
|
||||
assert.equal(group.getAttribute('opacity'), '1');
|
||||
assert.deepEqual(
|
||||
[...group.children].map((child) => [child.tagName.toLowerCase(), child.getAttribute('class')]),
|
||||
[
|
||||
['circle', 'gev-anno-ring gev-pulse'],
|
||||
['circle', 'gev-anno-ring'],
|
||||
['circle', 'gev-anno-dot'],
|
||||
['line', 'gev-anno-leader'],
|
||||
['g', 'gev-anno-callout'],
|
||||
],
|
||||
);
|
||||
const [outer, inner, dot, leader, callout] = group.children;
|
||||
assert.equal(outer.getAttribute('stroke'), '#ffb547');
|
||||
assert.equal(outer.getAttribute('cx'), '200.0');
|
||||
assert.equal(outer.getAttribute('r'), '34.0');
|
||||
assert.equal(inner.getAttribute('fill-opacity'), '0.12');
|
||||
assert.equal(inner.getAttribute('filter'), 'url(#gev-sketch)');
|
||||
assert.equal(dot.getAttribute('r'), '5.0');
|
||||
assert.equal(leader.getAttribute('x1'), '200.0');
|
||||
assert.equal(leader.getAttribute('x2'), '218.0');
|
||||
// The card is sized to its measured text, and the leader meets its bottom.
|
||||
const [card, accent, text] = callout.children;
|
||||
assert.equal(text.textContent, 'Capitol');
|
||||
assert.equal(text.getAttribute('x'), '9');
|
||||
assert.equal(card.getAttribute('width'), '58');
|
||||
assert.equal(card.getAttribute('height'), '26');
|
||||
assert.equal(accent.getAttribute('height'), '18');
|
||||
assert.equal(accent.getAttribute('fill'), '#ffb547');
|
||||
assert.equal(callout.getAttribute('transform'), 'translate(218.0, 266.0)');
|
||||
assert.equal(leader.getAttribute('y2'), '292.0');
|
||||
frames();
|
||||
await settle();
|
||||
assert.equal(group.getAttribute('class'), 'gev-anno gev-in');
|
||||
});
|
||||
|
||||
test('an arrow defines its arrowhead and drops the dash once drawn on', async (t) => {
|
||||
const { store, frames, svg, groups } = board(t);
|
||||
let calls = 0;
|
||||
projectTo(t, () => (calls++ % 2 ? { x: 400, y: 100 } : { x: 100, y: 100 }));
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: 'arrow',
|
||||
type: 'arrow',
|
||||
color: 'green',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
to: { lon: 0.01, lat: 0 },
|
||||
});
|
||||
await settle();
|
||||
const marker = svg().querySelector('defs > marker#gev-arrow-green');
|
||||
assert.equal(marker.getAttribute('orient'), 'auto-start-reverse');
|
||||
assert.equal(marker.querySelector('path').getAttribute('fill'), '#5dff9f');
|
||||
const path = groups()[0].querySelector('path');
|
||||
assert.equal(path.getAttribute('class'), 'gev-anno-arrow gev-draw');
|
||||
assert.equal(path.getAttribute('marker-end'), 'url(#gev-arrow-green)');
|
||||
assert.match(path.getAttribute('d'), /^M 100\.0 100\.0 Q /);
|
||||
frames();
|
||||
await settle();
|
||||
const ended = new Event('transitionend', { bubbles: true });
|
||||
ended.propertyName = 'stroke-dashoffset';
|
||||
path.dispatchEvent(ended);
|
||||
await settle();
|
||||
assert.equal(path.style.strokeDasharray, 'none');
|
||||
});
|
||||
|
||||
test('outline upgrade preserves the existing SVG group identity', async (t) => {
|
||||
const { store, svg, groups } = board(t);
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.ZERO,
|
||||
directionWC: Cesium.Cartesian3.ZERO,
|
||||
positionCartographic: { height: 1000 },
|
||||
};
|
||||
const sampledAnchors = [];
|
||||
const scene = {
|
||||
camera,
|
||||
const scene = sceneFor(camera, {
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
clampToHeightSupported: true,
|
||||
clampToHeight(world) {
|
||||
@@ -169,9 +182,12 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
]);
|
||||
return world;
|
||||
},
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
||||
});
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'anno-fb3',
|
||||
type: 'area',
|
||||
@@ -182,8 +198,11 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
ring: null,
|
||||
};
|
||||
renderer.add(anno);
|
||||
const before = findAnnotationGroup(globalThis.document);
|
||||
assert.ok(before.group.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
|
||||
await settle();
|
||||
const [before] = groups();
|
||||
assert.ok(before.querySelector('.gev-anno-ring'), 'pending area starts as a reticle');
|
||||
const callout = before.querySelector('.gev-anno-callout');
|
||||
const dot = before.querySelector('.gev-anno-dot');
|
||||
|
||||
const labelProxy = Object.assign(Object.create(anno), {
|
||||
type: 'label',
|
||||
@@ -191,47 +210,65 @@ test('outline upgrade preserves the existing SVG group identity', (t) => {
|
||||
anchor: { lon: -97.5, lat: 31.2, height: 0 },
|
||||
});
|
||||
renderer.update(labelProxy);
|
||||
const after = findAnnotationGroup(globalThis.document);
|
||||
await settle();
|
||||
const [after] = groups();
|
||||
|
||||
assert.equal(after.group, before.group, 'the rendered group is mutated, not replaced');
|
||||
assert.ok(after === before, 'the rendered group is mutated, not replaced');
|
||||
assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor');
|
||||
assert.equal(after.svg.children.filter((child) => child.classList.contains('gev-anno')).length, 1);
|
||||
assert.equal(after.group.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
|
||||
assert.ok(after.group.querySelector('.gev-anno-callout'), 'the original callout remains in the group');
|
||||
renderer.destroy();
|
||||
assert.equal(groups().length, 1);
|
||||
assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place');
|
||||
assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group');
|
||||
assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group');
|
||||
assert.ok(svg());
|
||||
});
|
||||
|
||||
test('annotation fade consumes the actual tracked host paint rectangle after layout', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, width: 800, height: 600 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
test('a callout whose text changes is sized to the new text', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 100, y: 100 });
|
||||
// Text measures 7px a character; everything else keeps the shared box.
|
||||
const original = globalThis.SVGGraphicsElement.prototype.getBBox;
|
||||
globalThis.SVGGraphicsElement.prototype.getBBox = function () {
|
||||
return this.tagName.toLowerCase() === 'text'
|
||||
? { x: 0, y: 0, width: this.textContent.length * 7, height: 16 }
|
||||
: original.call(this);
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.SVGGraphicsElement.prototype.getBBox = original;
|
||||
});
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'upgrade',
|
||||
type: 'label',
|
||||
color: 'primary',
|
||||
label: 'Texas',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
};
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
const card = () => groups()[0].querySelector('.gev-anno-card');
|
||||
assert.equal(card().getAttribute('width'), String(5 * 7 + 18));
|
||||
renderer.update({ ...anno, outlineUnavailable: true });
|
||||
await settle();
|
||||
const text = 'Texas · Detailed outline unavailable';
|
||||
assert.equal(groups()[0].querySelector('text').textContent, text);
|
||||
assert.equal(card().getAttribute('width'), String(text.length * 7 + 18));
|
||||
});
|
||||
|
||||
test('annotation fade consumes the actual tracked host paint rectangle after layout', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 0, y: 0 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const paintRectCalls = [];
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{
|
||||
store,
|
||||
activeTrackedReadoutId: () => 'installations:test',
|
||||
overlayPaintRect(sourceId, entryId) {
|
||||
paintRectCalls.push({ sourceId, entryId });
|
||||
@@ -239,6 +276,7 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
},
|
||||
},
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
renderer.add({
|
||||
id: 'anno-overlap',
|
||||
type: 'label',
|
||||
@@ -247,7 +285,8 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
});
|
||||
const { group } = findAnnotationGroup(globalThis.document);
|
||||
await settle();
|
||||
const [group] = groups();
|
||||
assert.deepEqual(paintRectCalls.at(-1), {
|
||||
sourceId: 'tracked',
|
||||
entryId: 'installations:test',
|
||||
@@ -256,44 +295,59 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
Number(group.getAttribute('opacity')) < 1,
|
||||
'final annotation bbox fades against the complete painted card rectangle',
|
||||
);
|
||||
});
|
||||
|
||||
test('a removed mark fades out, then leaves the board', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 100, y: 100 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'fading',
|
||||
type: 'label',
|
||||
color: 'primary',
|
||||
label: 'Gone soon',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0 },
|
||||
};
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
const [group] = groups();
|
||||
renderer.remove(anno);
|
||||
// A new mark for the same place gets its own group beside the fading one.
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
assert.equal(group.getAttribute('class'), 'gev-anno gev-out');
|
||||
assert.equal(groups().length, 2);
|
||||
assert.ok(groups()[1] !== group);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await settle();
|
||||
assert.deepEqual(groups().length, 1);
|
||||
assert.equal(group.isConnected, false);
|
||||
renderer.destroy();
|
||||
await settle();
|
||||
assert.equal(document.querySelector('.gev-screen-whiteboard'), null);
|
||||
});
|
||||
|
||||
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
|
||||
//
|
||||
// add() inserts the group and records it, then does more live-document work
|
||||
// (draw-on wiring, the first projection pass). A throw in that tail used to
|
||||
// leave an ORPHANED <g> plus a record nobody owned — the engine's rollback only
|
||||
// deletes its own map entry — so re-annotating the same place stacked a second
|
||||
// mark over the corpse.
|
||||
// add() records the mark, then does more work (presenting it, the first
|
||||
// projection pass). A throw in that tail used to leave an ORPHANED mark plus a
|
||||
// record nobody owned — the engine's rollback only deletes its own map entry —
|
||||
// so re-annotating the same place stacked a second mark over the corpse.
|
||||
|
||||
test('an add that throws after inserting its group unwinds instead of orphaning it', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.document = fakeDocument();
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(); return 1; };
|
||||
t.after(() => {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
const positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
const directionWC = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.negate(positionWC, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
test('an add that throws after inserting its group unwinds instead of orphaning it', async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera, { canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 } }), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
const camera = { positionWC, directionWC, positionCartographic: { height: 1000 } };
|
||||
const scene = {
|
||||
camera,
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
clampToHeightSupported: false,
|
||||
postRender: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
const renderer = createScreenAnnotationRenderer({ scene, camera, trackedEntity: null });
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = {
|
||||
id: 'anno-partial-screen',
|
||||
type: 'pin',
|
||||
@@ -303,26 +357,221 @@ test('an add that throws after inserting its group unwinds instead of orphaning
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
};
|
||||
|
||||
// The group is in the document and recorded by the time add() reaches its
|
||||
// first projection pass — so a throw there is real partial state, not a
|
||||
// clean bail-out.
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => { throw new Error('projection failed'); };
|
||||
// The mark is on the board by the time add() reaches its first projection
|
||||
// pass — so a throw there is real partial state, not a clean bail-out.
|
||||
projectTo(t, () => {
|
||||
throw new Error('projection failed');
|
||||
});
|
||||
assert.throws(() => renderer.add(anno), /projection failed/);
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 0, y: 0 });
|
||||
const { svg } = findAnnotationGroup(globalThis.document);
|
||||
assert.equal(
|
||||
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
||||
0,
|
||||
'the half-built group must not stay in the document',
|
||||
);
|
||||
await settle();
|
||||
assert.equal(groups().length, 0, 'the half-built group must not stay in the document');
|
||||
|
||||
// The id is free again: a retry draws exactly one mark, and remove() still
|
||||
// owns it (a leaked record would leave a second group behind).
|
||||
renderer.add(anno);
|
||||
assert.equal(
|
||||
svg.children.filter((child) => child.classList.contains('gev-anno')).length,
|
||||
1,
|
||||
'the retry must draw one mark, not stack over an orphan',
|
||||
await settle();
|
||||
assert.equal(groups().length, 1, 'the retry must draw one mark, not stack over an orphan');
|
||||
});
|
||||
|
||||
test("an earlier renderer leaves a later renderer's board alone", async (t) => {
|
||||
const { store, groups } = board(t);
|
||||
projectTo(t, { x: 100, y: 100 });
|
||||
const camera = cameraOver(0, 0);
|
||||
const viewer = { scene: sceneFor(camera), camera, trackedEntity: null };
|
||||
const options = { store, activeTrackedReadoutId: () => null };
|
||||
const earlier = createScreenAnnotationRenderer(viewer, options);
|
||||
const later = createScreenAnnotationRenderer(viewer, options);
|
||||
t.after(() => later.destroy());
|
||||
later.add({ id: 'kept', type: 'label', label: 'Kept', alpha: 1, anchor: { lon: 0, lat: 0 } });
|
||||
earlier.add({ id: 'ignored', type: 'label', label: 'Ignored', alpha: 1, anchor: { lon: 0, lat: 0 } });
|
||||
earlier.destroy();
|
||||
await settle();
|
||||
assert.equal(groups().length, 1);
|
||||
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
|
||||
});
|
||||
|
||||
test('a multipart area with holes draws every visible part and keeps holes open', async (t) => {
|
||||
const { store, svg } = board(t);
|
||||
// A fixed projection: 0.001 degrees spans 100 px.
|
||||
projectTo(t, (_scene, world) => {
|
||||
const c = Cesium.Cartographic.fromCartesian(world);
|
||||
return {
|
||||
x: 100 + Cesium.Math.toDegrees(c.longitude) * 1e5,
|
||||
y: 100 + Cesium.Math.toDegrees(c.latitude) * 1e5,
|
||||
};
|
||||
});
|
||||
const camera = cameraOver(0, 0);
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene: sceneFor(camera), camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
renderer.destroy();
|
||||
t.after(() => renderer.destroy());
|
||||
const outer = [[0, 0], [0.001, 0], [0.001, 0.001], [0, 0.001], [0, 0]];
|
||||
const hole = [[0.0002, 0.0002], [0.0004, 0.0002], [0.0004, 0.0004], [0.0002, 0.0004], [0.0002, 0.0002]];
|
||||
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
|
||||
const anno = {
|
||||
id: 'anno-multipart-screen',
|
||||
label: 'Multipart',
|
||||
type: 'area',
|
||||
color: 'primary',
|
||||
alpha: 1,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
ring: outer,
|
||||
polygons: [[outer, hole], [island]],
|
||||
synthesized: false,
|
||||
};
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
const area = () => svg().querySelector('.gev-anno-area');
|
||||
const subpaths = () => (area().getAttribute('d').match(/M /g) || []).length;
|
||||
assert.equal(area().tagName.toLowerCase(), 'path');
|
||||
assert.equal(area().getAttribute('fill-rule'), 'evenodd');
|
||||
assert.equal(subpaths(), 3, 'screen fill retains both parts and the hole');
|
||||
|
||||
// The first part is behind the globe: the label centres on the next part.
|
||||
anno.polygons = [[island.map(([lon, lat]) => [lon + 180, lat])], [outer, hole]];
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(subpaths(), 2, 'an island behind the globe cannot hide the visible part and its hole');
|
||||
assert.notEqual(area().parentNode.style.display, 'none');
|
||||
assert.doesNotMatch(
|
||||
area().parentNode.querySelector('.gev-anno-callout').getAttribute('transform'),
|
||||
/NaN/,
|
||||
);
|
||||
|
||||
anno.polygons = [[outer, hole]];
|
||||
projectTo(t, (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return { x: lon > 0.0009 ? 2401 : 0, y: 0 };
|
||||
});
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(subpaths(), 2, 'finite offscreen shell coordinates cannot hide a visible holed part');
|
||||
assert.notEqual(area().parentNode.style.display, 'none');
|
||||
|
||||
anno.polygons = [[outer, hole], [island]];
|
||||
projectTo(t, (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return Math.abs(lon - 0.0002) < 1e-7 ? undefined : { x: 0, y: 0 };
|
||||
});
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(subpaths(), 1, 'an unprojectable hole omits its entire part, not the independently visible island');
|
||||
assert.notEqual(area().parentNode.style.display, 'none');
|
||||
});
|
||||
|
||||
test('Preview avoids the map callout without moving its world anchor', async (t) => {
|
||||
const { store, svg, groups } = board(t);
|
||||
projectTo(t, (_scene, world) => {
|
||||
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
|
||||
return { x: 400, y: lon > 0.0005 ? 166 : 200 };
|
||||
});
|
||||
const camera = cameraOver(0, 0);
|
||||
const scene = sceneFor(camera, {
|
||||
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
|
||||
});
|
||||
const panel = document.body.appendChild(document.createElement('section'));
|
||||
panel.id = 'gev-voice-panel';
|
||||
panel.dataset.view = 'preview';
|
||||
panel.hidden = true;
|
||||
let panelRect = { left: 210, top: 120, width: 100, height: 120 };
|
||||
let panelVisible = true;
|
||||
panel.getBoundingClientRect = () => panelRect;
|
||||
panel.checkVisibility = () => panelVisible;
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{ scene, camera, trackedEntity: null },
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const anno = { id: 'preview-label', type: 'label', color: 'primary', label: 'Texas', alpha: 1, anchor: { lon: 0, lat: 0, height: 0 } };
|
||||
renderer.add(anno);
|
||||
await settle();
|
||||
// Non-unit, independent axis scales and a nonzero client offset: the
|
||||
// overlay uses preserveAspectRatio="none", not letterboxing.
|
||||
svg().getBoundingClientRect = () => ({ left: 10, top: 20, width: 640, height: 400 });
|
||||
renderer.sync();
|
||||
await settle();
|
||||
const [group] = groups();
|
||||
const callout = () => group.querySelector('.gev-anno-callout');
|
||||
const leader = group.querySelector('.gev-anno-leader');
|
||||
const dot = group.querySelector('.gev-anno-dot');
|
||||
const natural = callout().getAttribute('transform');
|
||||
const anchor = structuredClone(anno.anchor);
|
||||
panel.hidden = false;
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(callout().getAttribute('transform'), 'translate(418.0, 146.0)');
|
||||
assert.equal(leader.getAttribute('x2'), '418.0');
|
||||
assert.equal(leader.getAttribute('y2'), '172.0');
|
||||
assert.equal(leader.getAttribute('x1'), '400.0');
|
||||
assert.equal(leader.getAttribute('y1'), '200.0');
|
||||
assert.equal(dot.getAttribute('cx'), '400.0');
|
||||
assert.equal(dot.getAttribute('cy'), '200.0');
|
||||
assert.deepEqual(anno.anchor, anchor);
|
||||
assert.ok(groups()[0] === group, 'the group is kept, not replaced');
|
||||
panel.hidden = true;
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(callout().getAttribute('transform'), natural, 'closing Preview restores natural placement');
|
||||
for (const variant of ['settings', 'invisible', 'no-overlap', 'no-room', 'collision']) {
|
||||
panel.hidden = false;
|
||||
panel.dataset.view = variant === 'settings' ? 'settings' : 'preview';
|
||||
panelVisible = variant !== 'invisible';
|
||||
panelRect = variant === 'no-overlap'
|
||||
? { left: 510, top: 120, width: 100, height: 120 }
|
||||
: variant === 'no-room'
|
||||
? { left: 210, top: 25, width: 100, height: 180 }
|
||||
: { left: 210, top: 120, width: 100, height: 120 };
|
||||
if (variant === 'collision') renderer.add({ ...anno, id: 'above-slot', anchor: { lon: 0.001, lat: 0, height: 0 } });
|
||||
renderer.sync();
|
||||
await settle();
|
||||
assert.equal(callout().getAttribute('transform'), natural, variant);
|
||||
}
|
||||
});
|
||||
|
||||
test('a mark hidden from Draw is not drawn or placed, and comes back when shown', async (t) => {
|
||||
// happy-dom measures every SVG box the same, so callout spreading cannot
|
||||
// settle here; a hidden mark simply has to stay out of drawing and
|
||||
// placement, which is what keeps it from pushing a visible callout aside.
|
||||
const { store, svg } = board(t);
|
||||
projectTo(t, { x: 200, y: 300 });
|
||||
const camera = cameraOver(0, 0);
|
||||
let project = null;
|
||||
const renderer = createScreenAnnotationRenderer(
|
||||
{
|
||||
scene: sceneFor(camera, {
|
||||
postRender: {
|
||||
addEventListener: (listener) => (project = listener),
|
||||
removeEventListener() {},
|
||||
},
|
||||
}),
|
||||
camera,
|
||||
trackedEntity: null,
|
||||
},
|
||||
{ store, activeTrackedReadoutId: () => null },
|
||||
);
|
||||
t.after(() => renderer.destroy());
|
||||
const camp = {
|
||||
id: 'camp',
|
||||
type: 'pin',
|
||||
color: 'amber',
|
||||
label: 'Camp',
|
||||
alpha: 1,
|
||||
hidden: true,
|
||||
anchor: { lon: 0, lat: 0, height: 0 },
|
||||
};
|
||||
renderer.add(camp);
|
||||
project();
|
||||
await settle();
|
||||
const drawn = () =>
|
||||
[...svg().querySelectorAll('g.gev-anno')].some(
|
||||
(group) =>
|
||||
group.textContent.includes('Camp') && group.style.display !== 'none',
|
||||
);
|
||||
assert.equal(drawn(), false, 'the hidden mark is not drawn');
|
||||
camp.hidden = false;
|
||||
project();
|
||||
await settle();
|
||||
assert.equal(drawn(), true, 'shown again, it is drawn');
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { recordWorkSlice } from '../sources/phaseTiming.js';
|
||||
import { ringCentroid } from '../sources/featureGeometry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||
|
||||
@@ -51,6 +53,232 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
const dataSource = new Cesium.CustomDataSource('gev-annotations');
|
||||
viewer.dataSources.add(dataSource);
|
||||
const osmAnnotations = new Set();
|
||||
// Every mark with entities on the globe, so sync() can take a hidden one out.
|
||||
const drawn = new Set();
|
||||
const pendingGround = new Map();
|
||||
const groundQueue = [];
|
||||
let observingGround = false;
|
||||
let destroyed = false;
|
||||
|
||||
function sampleBuilding(anno, options) {
|
||||
pendingGround.get(anno)?.finish('cancelled');
|
||||
return new Promise((resolve, reject) => {
|
||||
const queuedAt = performance.now();
|
||||
// The 2 s budget starts when this task reaches the head of the queue, so
|
||||
// waiting behind other buildings on a slow device never spends it.
|
||||
let started = null;
|
||||
const workTrace = options.workTrace;
|
||||
if (workTrace) workTrace.samplerStart = queuedAt;
|
||||
let groundDetailSlots = 0;
|
||||
let groundDetailReady = true;
|
||||
if (workTrace) {
|
||||
try {
|
||||
workTrace.groundDetailDropped = 0;
|
||||
} catch {
|
||||
groundDetailReady = false;
|
||||
}
|
||||
}
|
||||
const task = {
|
||||
anno,
|
||||
points: groundProbePositions(anno.ring),
|
||||
samples: [],
|
||||
index: 0,
|
||||
};
|
||||
const timing = {
|
||||
workMs: 0,
|
||||
maxSliceMs: 0,
|
||||
lastSliceMs: 0,
|
||||
probes: 0,
|
||||
queuedMs: 0,
|
||||
deadlineMs: null,
|
||||
};
|
||||
task.current = () =>
|
||||
!destroyed &&
|
||||
!options.signal?.aborted &&
|
||||
pendingGround.get(anno) === task &&
|
||||
(options.isCurrent?.() ?? true);
|
||||
task.begin = () => {
|
||||
if (started !== null) return;
|
||||
started = performance.now();
|
||||
timing.queuedMs = started - queuedAt;
|
||||
timing.deadlineMs = started + 2000;
|
||||
if (workTrace) workTrace.samplerHeadAt = started;
|
||||
task.timer = setTimeout(() => task.finish('timeout'), 2000);
|
||||
// A task promoted outside a frame (its predecessor timed out or was
|
||||
// cancelled) still needs frames in request-render mode.
|
||||
try {
|
||||
viewer.scene.requestRender?.();
|
||||
} catch {
|
||||
/* the enqueue path reports render failures */
|
||||
}
|
||||
};
|
||||
task.finish = (status, error) => {
|
||||
if (pendingGround.get(anno) !== task) return;
|
||||
pendingGround.delete(anno);
|
||||
const position = groundQueue.indexOf(task);
|
||||
groundQueue.splice(position, 1);
|
||||
clearTimeout(task.timer);
|
||||
if (position === 0) groundQueue[0]?.begin();
|
||||
options.signal?.removeEventListener('abort', task.abort);
|
||||
if (!groundQueue.length && observingGround) {
|
||||
viewer.scene.postRender.removeEventListener(onGroundFrame);
|
||||
observingGround = false;
|
||||
}
|
||||
timing.elapsedMs = performance.now() - (started ?? queuedAt);
|
||||
if (workTrace) {
|
||||
workTrace.probes = timing.probes;
|
||||
workTrace.samplerStatus = status;
|
||||
}
|
||||
if (error) reject(error);
|
||||
else resolve({ status, timing });
|
||||
};
|
||||
task.abort = () => task.finish('cancelled');
|
||||
task.step = () => {
|
||||
const workStart = workTrace ? performance.now() : 0;
|
||||
// Nested call/commit wall-time only; the original parent remains the gate.
|
||||
const detail =
|
||||
workTrace && groundDetailSlots++ < 8
|
||||
? {
|
||||
index: task.index,
|
||||
status: groundDetailReady ? 'recorded' : 'missing',
|
||||
clampStart: null,
|
||||
clampEnd: null,
|
||||
clampResult: 'not-run',
|
||||
commitStart: null,
|
||||
commitEnd: null,
|
||||
commitStatus: 'not-run',
|
||||
}
|
||||
: undefined;
|
||||
const note = detail
|
||||
? (key) => {
|
||||
try {
|
||||
const at = performance.now();
|
||||
if (Number.isFinite(at)) detail[key] = at;
|
||||
else detail.status = 'missing';
|
||||
} catch {
|
||||
detail.status = 'missing';
|
||||
}
|
||||
}
|
||||
: null;
|
||||
if (workTrace && !detail) {
|
||||
try {
|
||||
workTrace.groundDetailDropped += 1;
|
||||
} catch {
|
||||
// Missing attribution must not change the sampler outcome.
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!task.current()) return task.finish('cancelled');
|
||||
if (performance.now() - started >= 2000)
|
||||
return task.finish('timeout');
|
||||
const sliceStart = performance.now();
|
||||
let error;
|
||||
try {
|
||||
if (
|
||||
viewer.scene.clampToHeightSupported &&
|
||||
typeof viewer.scene.clampToHeight === 'function'
|
||||
) {
|
||||
const [lon, lat] = task.points[task.index++];
|
||||
timing.probes += 1;
|
||||
try {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
let c;
|
||||
note?.('clampStart');
|
||||
if (detail) detail.clampResult = 'threw';
|
||||
try {
|
||||
c = viewer.scene.clampToHeight(position);
|
||||
if (detail) detail.clampResult = 'returned';
|
||||
} finally {
|
||||
note?.('clampEnd');
|
||||
}
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000)
|
||||
task.samples.push(h);
|
||||
}
|
||||
} catch {
|
||||
/* surface not ready: preserve the existing sample filter */
|
||||
}
|
||||
} else task.index = task.points.length;
|
||||
if (performance.now() - started >= 2000) {
|
||||
task.finish('timeout');
|
||||
return;
|
||||
}
|
||||
if (!task.current()) return task.finish('cancelled');
|
||||
if (task.index === task.points.length) {
|
||||
if (detail) detail.commitStatus = 'attempted';
|
||||
note?.('commitStart');
|
||||
task.samples.sort((a, b) => a - b);
|
||||
const ground = task.samples.length
|
||||
? task.samples[Math.floor(task.samples.length * 0.3)]
|
||||
: null;
|
||||
add(anno, {
|
||||
sampledGroundHeight: ground,
|
||||
isCurrent: task.current,
|
||||
});
|
||||
if (performance.now() >= timing.deadlineMs) {
|
||||
task.finish('timeout');
|
||||
remove(anno);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
} finally {
|
||||
timing.lastSliceMs = performance.now() - sliceStart;
|
||||
timing.workMs += timing.lastSliceMs;
|
||||
timing.maxSliceMs = Math.max(timing.maxSliceMs, timing.lastSliceMs);
|
||||
}
|
||||
if (error) {
|
||||
task.finish('failed', error);
|
||||
try {
|
||||
remove(anno);
|
||||
} catch {
|
||||
/* retain the original commit error */
|
||||
}
|
||||
} else if (task.index === task.points.length)
|
||||
task.finish('installed');
|
||||
} finally {
|
||||
// Commit includes sort/selection/add/deadline/finish or rollback,
|
||||
// including intervening bookkeeping; it is not exclusive entity cost.
|
||||
if (detail?.commitStatus === 'attempted') note('commitEnd');
|
||||
if (workTrace)
|
||||
recordWorkSlice(
|
||||
workTrace,
|
||||
'ground-step',
|
||||
workStart,
|
||||
undefined,
|
||||
detail,
|
||||
);
|
||||
}
|
||||
};
|
||||
pendingGround.set(anno, task);
|
||||
groundQueue.push(task);
|
||||
if (groundQueue[0] === task) task.begin();
|
||||
options.signal?.addEventListener('abort', task.abort, { once: true });
|
||||
if (!task.current()) return task.finish('cancelled');
|
||||
if (!observingGround) {
|
||||
viewer.scene.postRender.addEventListener(onGroundFrame);
|
||||
observingGround = true;
|
||||
}
|
||||
try {
|
||||
viewer.scene.requestRender?.();
|
||||
} catch (error) {
|
||||
task.finish('failed', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onGroundFrame() {
|
||||
// One building probe TOTAL, not one per mark. Other renderers have their own work.
|
||||
const task = groundQueue[0];
|
||||
try {
|
||||
task?.step();
|
||||
if (groundQueue.length) viewer.scene.requestRender?.();
|
||||
} catch (error) {
|
||||
for (const pending of [...groundQueue]) pending.finish('failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
|
||||
// material the route pipeline renders. The animated `time` uniform is read straight
|
||||
@@ -81,7 +309,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}, false);
|
||||
}
|
||||
|
||||
function add(anno) {
|
||||
function add(anno, options = {}) {
|
||||
const base = colorFor(anno);
|
||||
// Published to the mark BEFORE anything is added, and mutated in place as
|
||||
// each entity lands: a mid-add failure (bad geometry, lost context) must
|
||||
@@ -89,21 +317,42 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// path cannot reach them and they stay on the globe forever.
|
||||
const entities = [];
|
||||
anno._entities = entities;
|
||||
drawn.add(anno);
|
||||
|
||||
if (
|
||||
anno.ring?.length >= 3 &&
|
||||
anno.footprintKind === 'building' &&
|
||||
!Object.hasOwn(options, 'sampledGroundHeight')
|
||||
) {
|
||||
return sampleBuilding(anno, options);
|
||||
}
|
||||
|
||||
if (
|
||||
anno.ring &&
|
||||
anno.ring.length >= 3 &&
|
||||
anno.footprintKind === 'building'
|
||||
) {
|
||||
const addBuildingEntity = (definition) => {
|
||||
if (!(options.isCurrent?.() ?? true))
|
||||
throw new Error('Building install cancelled');
|
||||
const entity = dataSource.entities.add(definition);
|
||||
if (!(options.isCurrent?.() ?? true)) {
|
||||
dataSource.entities.remove(entity);
|
||||
throw new Error('Building install cancelled');
|
||||
}
|
||||
return entity;
|
||||
};
|
||||
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
|
||||
// volume that tints the real photogrammetry mesh (dome, walls, roof —
|
||||
// everything inside the column) amber and pulses. A flat footprint
|
||||
// extrusion is only 2.5D and misses domes; classification colors the
|
||||
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
|
||||
const buffered = bufferRing(anno.ring, 3);
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
||||
const parts =
|
||||
Array.isArray(anno.polygons) && anno.polygons.length
|
||||
? anno.polygons
|
||||
: [[anno.ring]];
|
||||
const groundH =
|
||||
sampleGroundOutside(viewer.scene, anno.ring) ??
|
||||
options.sampledGroundHeight ??
|
||||
(Number.isFinite(anno.anchor.height)
|
||||
? anno.anchor.height - (anno.buildingHeight || 25)
|
||||
: 0);
|
||||
@@ -112,55 +361,73 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
// empty air above the roof isn't tinted — this just guarantees we cover
|
||||
// under-tagged building heights.
|
||||
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
|
||||
// 1) Classification volume — tints the mesh amber, pulsing.
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(positions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
classificationType: CLASSIFY,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.45, pulse: true }),
|
||||
),
|
||||
},
|
||||
}),
|
||||
const fillMaterial = new Cesium.ColorMaterialProperty(
|
||||
liveColor(anno, base, { alpha: 0.45, pulse: true }),
|
||||
);
|
||||
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
||||
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
||||
// per (often 100+) ring vertex.
|
||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
decimateRing(buffered, 14).flat(),
|
||||
);
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 3) Crisp draped base outline so the footprint reads on the ground.
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
polyline: {
|
||||
positions,
|
||||
width: 3,
|
||||
material: new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.25,
|
||||
color: liveColor(anno, base, { alpha: 0.7 }),
|
||||
const lineMaterial = new Cesium.PolylineGlowMaterialProperty({
|
||||
glowPower: 0.25,
|
||||
color: liveColor(anno, base, { alpha: 0.7 }),
|
||||
});
|
||||
for (const [outer, ...holes] of parts) {
|
||||
const buffered = bufferRing(outer, 3);
|
||||
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
|
||||
// 1) Classification volume — tints the mesh amber, pulsing.
|
||||
entities.push(
|
||||
addBuildingEntity({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
positions,
|
||||
holes.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
|
||||
),
|
||||
),
|
||||
),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
classificationType: CLASSIFY,
|
||||
material: fillMaterial,
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
|
||||
// the footprint so the extruded outline draws ~14 clean verticals, not one
|
||||
// per (often 100+) ring vertex.
|
||||
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
|
||||
decimateRing(buffered, 14).flat(),
|
||||
);
|
||||
entities.push(
|
||||
addBuildingEntity({
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
|
||||
height: baseH,
|
||||
extrudedHeight: topH,
|
||||
perPositionHeight: false,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 3) Crisp draped base outline so the footprint reads on the ground.
|
||||
for (const ring of [outer, ...holes]) {
|
||||
entities.push(
|
||||
addBuildingEntity({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
closedRing(ring).flat(),
|
||||
),
|
||||
width: 3,
|
||||
material: lineMaterial,
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
},
|
||||
}),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (anno.ring && anno.ring.length >= 3) {
|
||||
// Larger area (district / compound / park / state) → flat fill draped on
|
||||
// the tiles + a glowing outline. Draping is right here: you can't extrude
|
||||
@@ -247,6 +514,7 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
width: 9,
|
||||
material: new FlowMaterialProperty(
|
||||
PALETTE[anno.color] || PALETTE.primary,
|
||||
() => anno.alpha ?? 1,
|
||||
),
|
||||
clampToGround: true,
|
||||
classificationType: CLASSIFY,
|
||||
@@ -300,6 +568,19 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}
|
||||
entities.push(pointMarker(anno, base));
|
||||
}
|
||||
if (
|
||||
entities.length &&
|
||||
anno.ring?.length >= 3 &&
|
||||
!anno.synthesized &&
|
||||
(anno.outlineCredit === 'tiles' || anno.outlineCredit === 'osm') &&
|
||||
(options.isCurrent?.() ?? true)
|
||||
) {
|
||||
// The actual world proxy owns both geometry and credit, including rollback.
|
||||
osmAnnotations.add(anno);
|
||||
showOsmCredit(viewer, anno, {
|
||||
openMapTiles: anno.outlineCredit === 'tiles',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Captions belong to the hybrid renderer's screen-space callouts.
|
||||
@@ -318,8 +599,10 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
}
|
||||
|
||||
function remove(anno) {
|
||||
pendingGround.get(anno)?.finish('cancelled');
|
||||
hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.delete(anno);
|
||||
drawn.delete(anno);
|
||||
if (!anno?._entities) return;
|
||||
for (const entity of anno._entities) {
|
||||
try {
|
||||
@@ -331,13 +614,25 @@ export function createWorldAnnotationRenderer(viewer) {
|
||||
anno._entities = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CallbackProperty drives per-frame alpha and pulse. A hidden mark is taken
|
||||
* out whole, so no material that keeps its own colour (the route's flow),
|
||||
* ground classification or pick can still show it.
|
||||
*/
|
||||
function sync() {
|
||||
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
|
||||
for (const anno of drawn) {
|
||||
const show = !anno.hidden;
|
||||
for (const entity of anno._entities || [])
|
||||
if (entity.show !== show) entity.show = show;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
for (const task of [...pendingGround.values()]) task.finish('cancelled');
|
||||
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
|
||||
osmAnnotations.clear();
|
||||
drawn.clear();
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
@@ -407,8 +702,11 @@ export function ensureFlowFabricRegistered() {
|
||||
* returned a standalone Material Cesium never rendered, or treated `result` as a
|
||||
* Material — both left the real uniforms untouched, so nothing animated.)
|
||||
*/
|
||||
export function FlowMaterialProperty(colorCss) {
|
||||
export function FlowMaterialProperty(colorCss, alphaOf = () => 1) {
|
||||
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
|
||||
// The mark's live alpha (fade in and out, hidden), applied every frame.
|
||||
this._alphaOf = alphaOf;
|
||||
this._live = this._color.clone();
|
||||
this._definitionChanged = new Cesium.Event();
|
||||
}
|
||||
Object.defineProperties(FlowMaterialProperty.prototype, {
|
||||
@@ -431,7 +729,8 @@ FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
|
||||
// `time` is read straight from the wall clock so the dashes flow every rendered
|
||||
// frame (the scene renders continuously; no requestRenderMode here).
|
||||
if (!Cesium.defined(result)) result = {};
|
||||
result.color = this._color;
|
||||
this._live.alpha = this._color.alpha * this._alphaOf();
|
||||
result.color = this._live;
|
||||
result.time = performance.now() / 1000; // the per-frame animated value
|
||||
result.repeat = 64.0;
|
||||
result.duty = 0.46;
|
||||
@@ -458,25 +757,11 @@ function decimateRing(ring, n) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the ground height (m) just OUTSIDE a building footprint. Sampling at
|
||||
* the centroid would clamp onto the roof, so we probe several points beyond the
|
||||
* footprint radius and take a low percentile (≈ ground). Returns null if the
|
||||
* tiles under those points aren't loaded yet.
|
||||
* The original eight outside-footprint probes. Keep their coordinates and order
|
||||
* while scheduling the expensive scene picks across rendered frames.
|
||||
*/
|
||||
function sampleGroundOutside(scene, ring) {
|
||||
if (
|
||||
!scene?.clampToHeightSupported ||
|
||||
typeof scene.clampToHeight !== 'function'
|
||||
)
|
||||
return null;
|
||||
let clon = 0;
|
||||
let clat = 0;
|
||||
for (const [lon, lat] of ring) {
|
||||
clon += lon;
|
||||
clat += lat;
|
||||
}
|
||||
clon /= ring.length;
|
||||
clat /= ring.length;
|
||||
function groundProbePositions(ring) {
|
||||
const { lon: clon, lat: clat } = ringCentroid(ring);
|
||||
const latS = 111320;
|
||||
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
|
||||
let maxR = 0;
|
||||
@@ -494,23 +779,11 @@ function sampleGroundOutside(scene, ring) {
|
||||
[0.7, -0.7],
|
||||
[-0.7, 0.7],
|
||||
];
|
||||
const samples = [];
|
||||
for (const [dx, dy] of dirs) {
|
||||
return dirs.map(([dx, dy]) => {
|
||||
const lon = clon + (dx * out) / lonS;
|
||||
const lat = clat + (dy * out) / latS;
|
||||
try {
|
||||
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
|
||||
if (c) {
|
||||
const h = Cesium.Cartographic.fromCartesian(c).height;
|
||||
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
|
||||
}
|
||||
} catch {
|
||||
/* tile not ready */
|
||||
}
|
||||
}
|
||||
if (!samples.length) return null;
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length * 0.3)];
|
||||
return [lon, lat];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// A mark hidden from Draw's list leaves the globe whole: every entity it put
|
||||
// there is taken out on sync and comes back when shown, and the line's
|
||||
// flowing stroke, which keeps its own colour, follows the mark's alpha.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import {
|
||||
FlowMaterialProperty,
|
||||
createWorldAnnotationRenderer,
|
||||
} from './worldAnnotationRenderer.js';
|
||||
|
||||
const noop = () => {};
|
||||
function viewer(t) {
|
||||
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
|
||||
const original = globalThis[name];
|
||||
globalThis[name] = class {};
|
||||
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
|
||||
}
|
||||
const before = globalThis.document;
|
||||
globalThis.document = { addEventListener: noop, removeEventListener: noop };
|
||||
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
|
||||
return {
|
||||
creditDisplay: { addStaticCredit: noop, removeStaticCredit: noop },
|
||||
entities: new C.EntityCollection(),
|
||||
dataSources: new C.DataSourceCollection(),
|
||||
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
|
||||
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
|
||||
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
|
||||
};
|
||||
}
|
||||
|
||||
const marks = () => [
|
||||
{ id: 'route', type: 'route', color: 'amber', alpha: 1, anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] },
|
||||
{ id: 'area', type: 'area', color: 'green', alpha: 1, anchor: { lat: 0.005, lon: 0.005 }, ring: [[0, 0], [0.01, 0], [0.01, 0.01], [0, 0]] },
|
||||
{ id: 'pin', type: 'pin', color: 'primary', alpha: 1, anchor: { lat: 0.02, lon: 0.02 } },
|
||||
];
|
||||
|
||||
test('a hidden mark has every entity taken off on sync, and back when shown', (t) => {
|
||||
const renderer = createWorldAnnotationRenderer(viewer(t));
|
||||
const [route, area, pin] = marks();
|
||||
for (const anno of [route, area, pin]) renderer.add(anno);
|
||||
const shown = (anno) => anno._entities.map((entity) => entity.show);
|
||||
for (const anno of [route, area, pin]) assert.ok(anno._entities.length > 0, anno.id);
|
||||
|
||||
route.hidden = true;
|
||||
area.hidden = true;
|
||||
renderer.sync();
|
||||
assert.ok(shown(route).every((show) => show === false), 'the line is off');
|
||||
assert.ok(shown(area).every((show) => show === false), 'the area is off');
|
||||
assert.ok(shown(pin).every((show) => show === true), 'a visible mark stays');
|
||||
|
||||
route.hidden = false;
|
||||
renderer.sync();
|
||||
assert.ok(shown(route).every((show) => show === true), 'the line is back');
|
||||
renderer.remove(area);
|
||||
renderer.sync();
|
||||
assert.equal(area._entities, null, 'a removed mark is no longer synced');
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
test('the line\'s flowing stroke follows its mark\'s alpha', () => {
|
||||
let alpha = 1;
|
||||
const material = new FlowMaterialProperty('#ffb547', () => alpha);
|
||||
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.95) < 1e-9);
|
||||
alpha = 0;
|
||||
assert.equal(material.getValue(null, {}).color.alpha, 0, 'hidden: nothing painted');
|
||||
alpha = 0.5;
|
||||
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.475) < 1e-9, 'fading');
|
||||
assert.equal(new FlowMaterialProperty('#fff').getValue(null, {}).color.alpha, 0.95, 'no alpha source: opaque as before');
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mountApplicationViews } from '../ui/views/index.js';
|
||||
const START_ORDER = ['scene', 'controls', 'data', 'tools'];
|
||||
// Controls cancel restoration while the data manager and viewer still exist.
|
||||
const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
|
||||
@@ -6,7 +7,9 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
|
||||
* Own application startup and teardown without importing an application entry.
|
||||
* Constructors receive earlier components, an AbortSignal and defer(cleanup).
|
||||
* Register cleanup immediately after acquiring each resource, before any await.
|
||||
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors
|
||||
* The page's views render first; `omitViews` names the ones this application
|
||||
* leaves out (see mountApplicationViews).
|
||||
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function,omitViews?:string[]}} constructors
|
||||
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
|
||||
*/
|
||||
export function createApplication({
|
||||
@@ -14,7 +17,10 @@ export function createApplication({
|
||||
createControls,
|
||||
createData,
|
||||
createTools,
|
||||
omitViews = [],
|
||||
}) {
|
||||
// A host's page needs its views before any phase reads the markup.
|
||||
mountApplicationViews(globalThis.document, { omit: omitViews });
|
||||
const factories = {
|
||||
scene: createScene,
|
||||
controls: createControls,
|
||||
|
||||
@@ -31,6 +31,8 @@ test('importing and constructing the package is inactive without browser globals
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
// Views are single-file components; Node needs the loader hosts use.
|
||||
'--import=gods-eye-view/build/register-vue',
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`
|
||||
@@ -219,6 +221,17 @@ test('the separate viewer export imports without constructing a browser viewer',
|
||||
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
||||
});
|
||||
|
||||
test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => {
|
||||
const { applicationViewerOptions } = await import('./viewer.js');
|
||||
const { StarrySky } = await import('../sky/starrySky.js');
|
||||
const creditContainer = {};
|
||||
const options = applicationViewerOptions(creditContainer);
|
||||
// CesiumWidget skips scene.sun and scene.moon when skyBox is false.
|
||||
assert.ok(options.skyBox instanceof StarrySky);
|
||||
assert.equal(options.creditContainer, creditContainer);
|
||||
options.skyBox.destroy();
|
||||
});
|
||||
|
||||
function pinchFixture({ zoomEventTypes } = {}) {
|
||||
const handlers = new Set();
|
||||
const relayed = [];
|
||||
|
||||
@@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains
|
||||
assert.equal(dataManager.layers.size, 0);
|
||||
});
|
||||
|
||||
test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
hidden: true,
|
||||
visibilityState: 'hidden',
|
||||
getElementById: () => null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
t.after(() => {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
if (previous === undefined) delete globalThis.document;
|
||||
else globalThis.document = previous;
|
||||
});
|
||||
const releases = [];
|
||||
const { dataManager } = createApplicationData({
|
||||
scene: { viewer: {} },
|
||||
controls: { styleManager: { attachDataManager() {} } },
|
||||
catalog: createLayerCatalog([], []),
|
||||
defer: (release) => releases.push(release),
|
||||
});
|
||||
const visibility = dataManager._visibilityTarget;
|
||||
assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel');
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
assert.equal(visibility.hidden, false);
|
||||
assert.equal(visibility.visibilityState, 'visible');
|
||||
for (const release of releases.reverse()) await release();
|
||||
});
|
||||
|
||||
test('controls bind catalog instances rather than similarly named defaults', () => {
|
||||
const ids = [
|
||||
'traffic',
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createApplicationTraffic } from './layers/traffic.js';
|
||||
import { createApplicationBikeshare } from './layers/bikeshare.js';
|
||||
import { createApplicationDirections } from './layers/directions.js';
|
||||
import { createApplicationRecentImagery } from './layers/recentImagery.js';
|
||||
import { createApplicationOsmPlaces } from './layers/osmPlaces.js';
|
||||
import { createApplicationTransit } from './layers/transit.js';
|
||||
import { createApplicationInstallations } from './layers/militaryInstallations.js';
|
||||
import { createApplicationSatellites } from './layers/satellites.js';
|
||||
@@ -27,7 +28,10 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||
import { createApplicationAirQuality } from './layers/airQuality.js';
|
||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
@@ -40,6 +44,8 @@ import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
|
||||
*/
|
||||
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
|
||||
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
|
||||
// Voice search results belong to this session, like a receiver to this machine.
|
||||
Object.freeze({ id: 'osm-places', disposition: 'local-only' }),
|
||||
]);
|
||||
|
||||
/** Serialization metadata for every layer the application catalog constructs. */
|
||||
@@ -125,6 +131,11 @@ export function createApplicationCatalog({
|
||||
}),
|
||||
createApplicationGnssIntegrity({
|
||||
source: sources['gnss-integrity'],
|
||||
windowMs: sources['gnss-integrity'].windowMs,
|
||||
}),
|
||||
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
||||
createApplicationDroneAirspace({
|
||||
source: sources['drone-airspace'],
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
createApplicationStreetLevel({
|
||||
@@ -141,6 +152,7 @@ export function createApplicationCatalog({
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections({ source: sources.directions }),
|
||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||
createApplicationOsmPlaces(),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
@@ -166,6 +178,7 @@ export function createApplicationCatalog({
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
createApplicationFirms({
|
||||
|
||||
@@ -40,8 +40,13 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 32);
|
||||
assert.equal(first.layers.length, 37);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'osm-places'),
|
||||
{ id: 'osm-places', disposition: 'local-only' },
|
||||
'voice search results never enter share links or stored state',
|
||||
);
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
{ id: 'local-adsb', disposition: 'local-only' },
|
||||
@@ -61,6 +66,8 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
);
|
||||
assert.ok(first.get('fire-perimeters'));
|
||||
assert.ok(first.get('gnss-integrity'));
|
||||
assert.ok(first.get('s2-ghostmaps'));
|
||||
assert.ok(first.get('drone-airspace'));
|
||||
assert.ok(first.get('transit'));
|
||||
const order = first.layers.map(({ id }) => id);
|
||||
assert.deepEqual(
|
||||
@@ -154,7 +161,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 32);
|
||||
assert.equal(catalog.layers.length, 37);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
@@ -192,3 +199,42 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
/Mapillary source/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a GNSS integrity source can set the layer evidence window', (t) => {
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
const gnssLayer = (descriptor) => {
|
||||
const sources = fixtureSources([], []);
|
||||
sources['gnss-integrity'] = {
|
||||
getSnapshot: async () => ({}),
|
||||
...descriptor,
|
||||
};
|
||||
return createApplicationCatalog({
|
||||
sources,
|
||||
signal: lifetime.signal,
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
}).get('gnss-integrity');
|
||||
};
|
||||
const shows = (layer, minutes) => {
|
||||
assert.equal(layer.provenance.window.minutes, minutes);
|
||||
const { info, legend } = layer.getRowControls();
|
||||
assert.match(info, new RegExp(`visited views over ${minutes} min`));
|
||||
assert.match(legend[0].blurb, new RegExp(`over ${minutes} minutes`));
|
||||
};
|
||||
shows(gnssLayer({ windowMs: 65 * 60_000 }), 65);
|
||||
shows(gnssLayer({}), 30);
|
||||
for (const windowMs of [
|
||||
undefined,
|
||||
null,
|
||||
NaN,
|
||||
Infinity,
|
||||
0,
|
||||
-60_000,
|
||||
'3600000',
|
||||
])
|
||||
assert.equal(
|
||||
gnssLayer({ windowMs }).provenance.window.minutes,
|
||||
30,
|
||||
`${String(windowMs)} falls back to the default`,
|
||||
);
|
||||
});
|
||||
Vendored
+18
-1
@@ -1,10 +1,16 @@
|
||||
import { catalogControlServices } from './catalog.js';
|
||||
import { StyleManager } from '../ui/composition.js';
|
||||
import { flyToAustin } from '../camera.js';
|
||||
import { installZoomOutGuard } from './zoomOutGuard.js';
|
||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the existing controls and camera presentation. */
|
||||
/**
|
||||
* Construct the existing controls and camera presentation. `initialShareCode`
|
||||
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
|
||||
* whose address does not carry one. `localePicker: false` removes the language
|
||||
* picker, for a host that does not initialize Core's locale.
|
||||
*/
|
||||
export function createApplicationControls({
|
||||
scene: { viewer, mapStackController, operations },
|
||||
loaderStatus,
|
||||
@@ -13,6 +19,8 @@ export function createApplicationControls({
|
||||
catalog,
|
||||
placeSearch,
|
||||
defer,
|
||||
initialShareCode = null,
|
||||
localePicker = true,
|
||||
}) {
|
||||
// Initialize the style manager (post-processing, HUD, locations, share links)
|
||||
const styleManager = new Controls(viewer, {
|
||||
@@ -27,10 +35,19 @@ export function createApplicationControls({
|
||||
requestServices: operations.requests,
|
||||
mapStackController,
|
||||
placeSearch,
|
||||
// A share code to open at start-up instead of the page's address.
|
||||
initialShareCode,
|
||||
localePicker,
|
||||
});
|
||||
defer(() => styleManager.orbitController.stop());
|
||||
defer(() => styleManager.hud.destroy());
|
||||
defer(() => styleManager.dispose());
|
||||
defer(
|
||||
installZoomOutGuard(viewer, {
|
||||
subscribeCameraHandoff: (listener) =>
|
||||
styleManager.subscribeCameraHandoff?.(listener),
|
||||
}),
|
||||
);
|
||||
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
||||
// clouds use a separate, capped low-resolution GPU pass that never attaches
|
||||
// Cesium fog or post-process stages and is fully stopped in map mode.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import '../testSupport/stubMgrs.mjs';
|
||||
import { installVueDom } from '../testSupport/vueDom.mjs';
|
||||
import { MAXIMUM_ZOOM_DISTANCE_M } from './zoomOutGuard.js';
|
||||
|
||||
const { createApplicationControls } = await import('./controls.js');
|
||||
|
||||
let uninstallDom;
|
||||
before(() => {
|
||||
uninstallDom = installVueDom();
|
||||
});
|
||||
after(() => uninstallDom());
|
||||
|
||||
test('application controls install the zoom-out guard and remove it on teardown', () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const controller = { maximumZoomDistance: Infinity };
|
||||
const scene = {
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
screenSpaceCameraController: controller,
|
||||
preRender: new Cesium.Event(),
|
||||
postUpdate: new Cesium.Event(),
|
||||
};
|
||||
const camera = {
|
||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 1_000_000),
|
||||
positionCartographic: { height: 1_000_000 },
|
||||
};
|
||||
const viewer = { scene, camera, canvas };
|
||||
const handoffs = new Set();
|
||||
class Controls {
|
||||
hasShareState = true;
|
||||
orbitController = { stop() {} };
|
||||
hud = { destroy() {} };
|
||||
dispose() {}
|
||||
subscribeCameraHandoff(listener) {
|
||||
handoffs.add(listener);
|
||||
return () => handoffs.delete(listener);
|
||||
}
|
||||
}
|
||||
const cleanups = [];
|
||||
createApplicationControls({
|
||||
scene: {
|
||||
viewer,
|
||||
mapStackController: {},
|
||||
operations: {
|
||||
surface: { controlServices: {} },
|
||||
searchAndFlyTo() {},
|
||||
requests: { regional: {}, weather: {} },
|
||||
},
|
||||
},
|
||||
loaderStatus: {},
|
||||
Controls,
|
||||
catalog: { get: (id) => ({ id }) },
|
||||
defer: (cleanup) => cleanups.push(cleanup),
|
||||
});
|
||||
|
||||
assert.equal(controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
assert.equal(scene.preRender.numberOfListeners, 1);
|
||||
assert.equal(scene.postUpdate.numberOfListeners, 1);
|
||||
assert.equal(handoffs.size, 1);
|
||||
|
||||
for (const cleanup of cleanups.reverse()) cleanup();
|
||||
assert.equal(controller.maximumZoomDistance, Infinity);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(scene.postUpdate.numberOfListeners, 0);
|
||||
assert.equal(handoffs.size, 0);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { LayerPresentation } from './layerPresentation.js';
|
||||
import { createCyberSonarScene } from '../cyberSonarScene.js';
|
||||
import { panelAwareVisibility } from './embed.js';
|
||||
/** Register the application layer catalog before allowing state restoration. */
|
||||
export function createApplicationData({
|
||||
scene: { viewer, mapStackController },
|
||||
@@ -13,6 +14,9 @@ export function createApplicationData({
|
||||
// Initialize data layer manager
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
// A panel's host may report it hidden while it is on screen; its layers
|
||||
// keep polling (see keepPanelRendering in embed.js).
|
||||
visibilityTarget: panelAwareVisibility(document),
|
||||
getSourceAvailability: catalog?.getSourceAvailability,
|
||||
});
|
||||
defer(async () => {
|
||||
|
||||
+25
-3
@@ -15,9 +15,9 @@ import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
|
||||
export const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
export const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
|
||||
const EMBED_READY_MESSAGE = 'gev:ready';
|
||||
|
||||
const FOLLOW_LAYERS = {
|
||||
aircraft: 'flights',
|
||||
@@ -45,6 +45,28 @@ export function isEmbeddedInline() {
|
||||
return globalThis.GEV_EMBED_INLINE === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
|
||||
* may report it hidden while it is on screen (see keepPanelRendering), so an
|
||||
* inline panel always reads as visible.
|
||||
*/
|
||||
export function panelAwareVisibility(doc = globalThis.document) {
|
||||
const hidden = () =>
|
||||
(doc.hidden === true || doc.visibilityState === 'hidden') &&
|
||||
!isEmbeddedInline();
|
||||
return {
|
||||
get hidden() {
|
||||
return hidden();
|
||||
},
|
||||
get visibilityState() {
|
||||
return hidden() ? 'hidden' : 'visible';
|
||||
},
|
||||
addEventListener: (type, listener) => doc.addEventListener(type, listener),
|
||||
removeEventListener: (type, listener) =>
|
||||
doc.removeEventListener(type, listener),
|
||||
};
|
||||
}
|
||||
|
||||
// A panel keeps drawing at about this rate when its host stops animation
|
||||
// frames; see keepPanelRendering.
|
||||
const PANEL_FRAME_MS = 33;
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
installViews,
|
||||
isEmbedded,
|
||||
keepPanelRendering,
|
||||
panelAwareVisibility,
|
||||
} from './embed.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { createView } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
@@ -144,6 +146,12 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
id: 3,
|
||||
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
|
||||
});
|
||||
// A view kept from before Snow was retired still applies, on Normal.
|
||||
send(parent, {
|
||||
type: 'gev:view',
|
||||
id: 4,
|
||||
view: { camera: { lat: 1, lon: 2 }, style: 'snow' },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(posted[1], {
|
||||
type: 'gev:view-applied',
|
||||
@@ -159,6 +167,13 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
name === 'set_layer_visibility' && args.layerId === 'flights',
|
||||
),
|
||||
);
|
||||
assert.equal(posted[3].id, 4);
|
||||
assert.equal(posted[3].ok, true);
|
||||
assert.ok(
|
||||
calls.some(
|
||||
([name, args]) => name === 'set_visual_style' && args.style === 'normal',
|
||||
),
|
||||
);
|
||||
assert.equal(posted.filter((message) => message.id === 1).length, 0);
|
||||
// Ready names no data and goes to any parent; answers go only to the
|
||||
// origin that asked.
|
||||
@@ -166,6 +181,7 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
'*',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
]);
|
||||
remove();
|
||||
});
|
||||
@@ -211,6 +227,60 @@ test('an app loaded inline into a panel page talks through its own window', asyn
|
||||
}
|
||||
});
|
||||
|
||||
test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: true,
|
||||
visibilityState: 'hidden',
|
||||
addEventListener: (_type, fn) => listeners.add(fn),
|
||||
removeEventListener: (_type, fn) => listeners.delete(fn),
|
||||
};
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 3; i++) await Promise.resolve();
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
let updates = 0;
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ visibilityTarget: panelAwareVisibility(doc) },
|
||||
);
|
||||
manager.register({
|
||||
id: 'flights',
|
||||
name: 'flights',
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval: 1000,
|
||||
async init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
async update() {
|
||||
updates++;
|
||||
await Promise.resolve();
|
||||
},
|
||||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||||
});
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
await manager.setEnabled('flights', true);
|
||||
await settle();
|
||||
const enabled = updates;
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
assert.equal(updates, enabled + 2, 'the panel keeps polling');
|
||||
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses');
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
await manager.destroyAll();
|
||||
}
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
test('annotations in a link are drawn after it restores, embedded or not', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const an = encodeURIComponent(
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as focus from '../../data/focusDeemphasis.js';
|
||||
import * as worldFocus from '../../worldFocus.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as readouts from '../../hudReadouts.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
export function createApplicationVessels({ source, options = {} }) {
|
||||
@@ -26,6 +27,7 @@ export function createApplicationVessels({ source, options = {} }) {
|
||||
focus,
|
||||
worldFocus,
|
||||
render,
|
||||
readouts,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire FAA special-use airspace into the application catalog. */
|
||||
export function createApplicationDroneAirspace(options) {
|
||||
return createDroneAirspaceLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
|
||||
export function createApplicationGhostMaps(options) {
|
||||
return createGhostMapsLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createOsmPlacesLayer } from '../../layers/osmPlaces/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
|
||||
/** Wire OSM Places labels to the application overlay host. */
|
||||
export function createApplicationOsmPlaces() {
|
||||
return createOsmPlacesLayer({ overlayHost });
|
||||
}
|
||||
@@ -32,21 +32,11 @@ function defaultProviders(sources) {
|
||||
* including when the list is empty.
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
surface,
|
||||
sources = {},
|
||||
providers = defaultProviders(sources),
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
services: {
|
||||
sprites,
|
||||
picking,
|
||||
input,
|
||||
scenePick,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
terrain: surface?.terrain ?? null,
|
||||
},
|
||||
services: { sprites, picking, input, scenePick, render },
|
||||
});
|
||||
}
|
||||
@@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
|
||||
test('every built-in provider is exported, so hosts can choose their own list', async () => {
|
||||
const google = await import('gods-eye-view/layers/street-level/google');
|
||||
const kartaview = await import('gods-eye-view/layers/street-level/kartaview');
|
||||
const panoramax = await import('gods-eye-view/layers/street-level/panoramax');
|
||||
const layer = createApplicationStreetLevel({
|
||||
providers: [
|
||||
google.createGoogleProvider({ getApiKey: () => null }),
|
||||
kartaview.createKartaViewProvider(),
|
||||
panoramax.createPanoramaxProvider(),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']);
|
||||
});
|
||||
+2
-1
@@ -14,7 +14,7 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the application globe using the caller's local configuration. */
|
||||
export async function createApplicationScene({
|
||||
@@ -60,6 +60,7 @@ export async function createApplicationScene({
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || googleTokens || cesiumToken
|
||||
? t('Loading Google 3D Tiles...')
|
||||
|
||||
@@ -48,12 +48,14 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
||||
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
|
||||
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
/** An omitted provider is unavailable, never an authoritative empty dataset. */
|
||||
export class SourceUnavailableError extends Error {
|
||||
class SourceUnavailableError extends Error {
|
||||
constructor(name) {
|
||||
super(`Data source not configured: ${name}`);
|
||||
this.name = 'SourceUnavailableError';
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 32);
|
||||
assert.equal(actual.layers.length, 37);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
@@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej
|
||||
);
|
||||
});
|
||||
|
||||
test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => {
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches++;
|
||||
throw new Error('Unexpected standalone acquisition');
|
||||
});
|
||||
const missing = catalog({}, t);
|
||||
assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: missing.getSourceAvailability },
|
||||
);
|
||||
manager.register(missing.get('s2-ghostmaps'));
|
||||
assert.equal(await manager.setEnabled('s2-ghostmaps', true), false);
|
||||
assert.equal(manager.layers.get('s2-ghostmaps').initialized, false);
|
||||
assert.equal(
|
||||
createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'],
|
||||
null,
|
||||
);
|
||||
|
||||
let reads = 0;
|
||||
let subscriptions = 0;
|
||||
let unsubscribed = 0;
|
||||
const source = {
|
||||
getCatalog: async () => {
|
||||
reads++;
|
||||
return { commit: 'a'.repeat(40), products: [] };
|
||||
},
|
||||
getFile: async () => {
|
||||
throw new Error('Empty catalog must not read files');
|
||||
},
|
||||
subscribe: () => {
|
||||
subscriptions++;
|
||||
return () => {
|
||||
unsubscribed++;
|
||||
};
|
||||
},
|
||||
};
|
||||
const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source });
|
||||
assert.equal(supplied['s2-ghostmaps'], source);
|
||||
const configured = catalog(supplied, t);
|
||||
assert.equal(
|
||||
configured.getSourceAvailability('s2-ghostmaps').available,
|
||||
true,
|
||||
);
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
globalThis.document = { addEventListener() {}, removeEventListener() {} };
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'document', previous);
|
||||
else delete globalThis.document;
|
||||
});
|
||||
const layer = configured.get('s2-ghostmaps');
|
||||
t.after(() => layer.destroy());
|
||||
layer.init({ scene: { requestRender() {} } });
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.disable();
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(subscriptions, 1);
|
||||
assert.equal(unsubscribed, 1);
|
||||
assert.equal(layer.getStats().commit, 'a'.repeat(40));
|
||||
assert.equal(fetches, 0);
|
||||
});
|
||||
|
||||
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
const manager = new LayerLifecycle(
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Compatibility entry; source contracts are owned by sources/.
|
||||
export { createSourceSlot } from '../sources/sourceSlot.js';
|
||||
@@ -1,24 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSourceSlot } from './sourceSlot.js';
|
||||
|
||||
test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => {
|
||||
let finish;
|
||||
const slot = createSourceSlot({ read: async () => 'default' }, ['read']);
|
||||
const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const pending = slot.source.read();
|
||||
const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } });
|
||||
old();
|
||||
finish('stale');
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.equal(await slot.source.read(), 'replacement');
|
||||
assert.equal(slot.source.attribution.name, 'Replacement');
|
||||
stop();
|
||||
assert.throws(() => slot.source.read(), /not configured/);
|
||||
});
|
||||
|
||||
test('an invalid replacement leaves the working source intact', async () => {
|
||||
const slot = createSourceSlot({ read: async () => 42 }, ['read']);
|
||||
assert.throws(() => slot.configure({}), TypeError);
|
||||
assert.equal(await slot.source.read(), 42);
|
||||
});
|
||||
+32
-4
@@ -2,11 +2,14 @@ import { SceneDirector } from '../scenes/director.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
import { initGeoFileImport } from '../ui/geoFileImport.js';
|
||||
import { useDisplayStore } from '../ui/stores/display.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||
import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
getRenderGovernorDiagnostics,
|
||||
@@ -52,12 +55,26 @@ export function createApplicationTools({
|
||||
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
|
||||
annotations.destroy();
|
||||
});
|
||||
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||
const displayStore = useDisplayStore(applicationPinia());
|
||||
// The Display panel's controls answer through the store; returns the
|
||||
// disconnect.
|
||||
const connectDisplay = (actions) => {
|
||||
const token = displayStore.connect(actions);
|
||||
return () => displayStore.detach(token);
|
||||
};
|
||||
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||
// while a session is open, so its teardown belongs to the application
|
||||
// lifetime rather than to whoever last pressed the button.
|
||||
const drawTool = initDrawTool({ viewer, annotations });
|
||||
// lifetime rather than to whoever last pressed the button. A row's fly-to
|
||||
// goes through the shell's navigation policy, as Import's does.
|
||||
const drawTool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation: styleManager,
|
||||
publish: (view) => displayStore.publishDraw(view),
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => drawTool?.destroy());
|
||||
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
// own data source. They live for the page session, so the application
|
||||
// lifetime removes them and the drop listeners on the viewer container.
|
||||
const geoFileImport = initGeoFileImport({
|
||||
@@ -65,6 +82,8 @@ export function createApplicationTools({
|
||||
navigation: styleManager,
|
||||
ground: operations.surface?.groundFloor,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
publish: (view) => displayStore.publishGeoImport(view),
|
||||
connect: connectDisplay,
|
||||
});
|
||||
defer(() => geoFileImport?.destroy());
|
||||
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
|
||||
@@ -110,6 +129,15 @@ export function createApplicationTools({
|
||||
// nothing animates per frame. Installed AFTER every module above has had
|
||||
// its chance to register pre-install holds. (perf wave 2)
|
||||
installRenderGovernor(viewer);
|
||||
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
|
||||
// alive in reused per-pass arrays; trim them after each disable.
|
||||
defer(
|
||||
installLayerReleaseTrim({
|
||||
scene: viewer.scene,
|
||||
dataManager,
|
||||
requestRender: governorRequestRender,
|
||||
}),
|
||||
);
|
||||
|
||||
// Install the explicit scope mask used by the DISPLAY controls.
|
||||
installScopeMask(viewer);
|
||||
|
||||
+23
-6
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
@@ -102,11 +103,9 @@ export function installTrackpadPinchZoom(
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
if (!container || !creditContainer)
|
||||
throw new TypeError('Viewer and credit containers are required');
|
||||
const viewer = new Cesium.Viewer(container, {
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
return {
|
||||
timeline: false,
|
||||
animation: false,
|
||||
baseLayerPicker: false,
|
||||
@@ -119,12 +118,30 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
selectionIndicator: false,
|
||||
infoBox: false,
|
||||
baseLayer: false,
|
||||
// Replaces the blocky Tycho-2 default. An option, not `skyBox: false`,
|
||||
// which would also stop CesiumWidget from creating the Sun and Moon.
|
||||
skyBox: new StarrySky(),
|
||||
creditContainer,
|
||||
msaaSamples: 4,
|
||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
export function createApplicationViewer({ container, creditContainer }) {
|
||||
if (!container || !creditContainer)
|
||||
throw new TypeError('Viewer and credit containers are required');
|
||||
const viewer = new Cesium.Viewer(
|
||||
container,
|
||||
applicationViewerOptions(creditContainer),
|
||||
);
|
||||
try {
|
||||
viewer.targetFrameRate = 60;
|
||||
// A phone pinch spans fewer screen pixels than desktop zoom gestures.
|
||||
// Keep Cesium's distance-aware zoom and collision limits, with less travel
|
||||
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
|
||||
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
|
||||
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
|
||||
// Before any tile builds a draw command: Cesium's per-vertex model
|
||||
// atmosphere fails to LINK on Apple's Metal backend and kills the
|
||||
// render loop. See app/atmosphereCompat.js.
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
|
||||
/** Whole-Earth input limit; explicit camera destinations can go farther. */
|
||||
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
|
||||
/** Below this, oblique browsing is left alone. */
|
||||
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
|
||||
/** Look this far below the Earth's limb, so the globe stays on screen. */
|
||||
export const LIMB_MARGIN_RAD = Cesium.Math.toRadians(8);
|
||||
const PITCH_EASE = 0.35;
|
||||
const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1);
|
||||
const HEIGHT_EPSILON_M = 0.0001;
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const INPUT_BINDINGS = Object.values(Cesium.CameraEventType).flatMap((type) =>
|
||||
[undefined, ...Object.values(Cesium.KeyboardEventModifier)].map(
|
||||
(modifier) => ({
|
||||
type,
|
||||
modifier,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The shallowest pitch (radians, negative is down) that still keeps the Earth
|
||||
* framed from height `h`, or null where tilted views are left alone.
|
||||
* The limb sits acos(R / (R + h)) below the local horizontal.
|
||||
*/
|
||||
export function maxPitchForHeight(h) {
|
||||
if (!(h >= PITCH_CLAMP_MIN_HEIGHT_M)) return null;
|
||||
const limb = Math.acos(EARTH_RADIUS_M / (EARTH_RADIUS_M + h));
|
||||
return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO);
|
||||
}
|
||||
|
||||
function matchesZoomBinding(bindings, type, modifier) {
|
||||
if (Array.isArray(bindings))
|
||||
return bindings.some((binding) =>
|
||||
matchesZoomBinding(binding, type, modifier),
|
||||
);
|
||||
return typeof bindings === 'number'
|
||||
? bindings === type && modifier === undefined
|
||||
: bindings?.eventType === type && bindings?.modifier === modifier;
|
||||
}
|
||||
|
||||
function readInput(aggregator, bindings) {
|
||||
let outward = false;
|
||||
let cancel = false;
|
||||
for (const { type, modifier } of INPUT_BINDINGS) {
|
||||
if (!aggregator.isMoving(type, modifier)) continue;
|
||||
const movement = aggregator.getMovement(type, modifier);
|
||||
const pinch = type === Cesium.CameraEventType.PINCH;
|
||||
const distance = pinch ? movement.distance : movement;
|
||||
const delta = distance.endPosition.y - distance.startPosition.y;
|
||||
// Cesium tilts by the fingers' midpoint while zooming by their spacing,
|
||||
// both in the same quarter-pixel units. Fingers rarely keep a perfectly
|
||||
// even spacing, so the larger of the two movements decides the gesture.
|
||||
const tilt = pinch
|
||||
? movement.angleAndHeight.endPosition.y -
|
||||
movement.angleAndHeight.startPosition.y
|
||||
: 0;
|
||||
if (
|
||||
matchesZoomBinding(bindings, type, modifier) &&
|
||||
Math.abs(delta) > Math.abs(tilt)
|
||||
) {
|
||||
outward ||= delta < 0;
|
||||
cancel ||= delta > 0;
|
||||
} else {
|
||||
// Includes modifier-specific tilts and parallel two-finger movement.
|
||||
cancel = true;
|
||||
}
|
||||
}
|
||||
aggregator.reset();
|
||||
return { outward, cancel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep outward user zooms bounded and framed, without taking camera ownership
|
||||
* from tilts, tracked entities or flights. Returns an idempotent disposer.
|
||||
*/
|
||||
export function installZoomOutGuard(
|
||||
viewer,
|
||||
{
|
||||
requestRender = governorRequestRender,
|
||||
subscribeCameraHandoff,
|
||||
createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas),
|
||||
} = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const controller = scene?.screenSpaceCameraController;
|
||||
const camera = viewer?.camera;
|
||||
const canvas = viewer?.canvas;
|
||||
if (
|
||||
!controller ||
|
||||
!camera ||
|
||||
!canvas ||
|
||||
!scene.preRender ||
|
||||
!scene.postUpdate
|
||||
)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
const aggregator = createAggregator(canvas);
|
||||
const originalMaximumZoomDistance = controller.maximumZoomDistance;
|
||||
const ownsCeiling = originalMaximumZoomDistance > MAXIMUM_ZOOM_DISTANCE_M;
|
||||
let maximumZoomDistance = Math.min(
|
||||
originalMaximumZoomDistance,
|
||||
MAXIMUM_ZOOM_DISTANCE_M,
|
||||
);
|
||||
controller.maximumZoomDistance = maximumZoomDistance;
|
||||
// Cesium clamps any zoom that starts above the limit to the limit, in either
|
||||
// direction. A camera placed higher by a flight or share link keeps its own
|
||||
// height as the limit, so it zooms in gradually and can't climb further.
|
||||
const followCeiling = () => {
|
||||
if (!ownsCeiling || controller.maximumZoomDistance !== maximumZoomDistance)
|
||||
return;
|
||||
// While tracking, Cesium measures zoom from the target, not the Earth, so
|
||||
// the camera's altitude would lift the ceiling with every outward step.
|
||||
const untracked =
|
||||
scene.mode === Cesium.SceneMode.SCENE3D &&
|
||||
Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
|
||||
const height = untracked ? camera.positionCartographic?.height : undefined;
|
||||
maximumZoomDistance =
|
||||
height > MAXIMUM_ZOOM_DISTANCE_M ? height : MAXIMUM_ZOOM_DISTANCE_M;
|
||||
controller.maximumZoomDistance = maximumZoomDistance;
|
||||
};
|
||||
followCeiling();
|
||||
|
||||
let lastHeight = camera.positionCartographic?.height;
|
||||
let lastPosition = Cesium.Cartesian3.clone(camera.position);
|
||||
let lastHeading = camera.heading;
|
||||
let lastPitch = camera.pitch;
|
||||
let lastRoll = camera.roll;
|
||||
let zoomingOut = false;
|
||||
let settling = false;
|
||||
const cancel = () => {
|
||||
zoomingOut = false;
|
||||
settling = false;
|
||||
};
|
||||
const guard = () => {
|
||||
const input = readInput(aggregator, controller.zoomEventTypes);
|
||||
const height = camera.positionCartographic?.height;
|
||||
const climbing = height > lastHeight + HEIGHT_EPSILON_M;
|
||||
const descending = height < lastHeight - HEIGHT_EPSILON_M;
|
||||
try {
|
||||
if (
|
||||
scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
controller.enableInputs === false ||
|
||||
controller.enableZoom === false ||
|
||||
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY) ||
|
||||
// Cesium has no public running-flight flag.
|
||||
camera._currentFlight ||
|
||||
input.cancel ||
|
||||
(descending && !settling)
|
||||
) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (input.outward) {
|
||||
zoomingOut = true;
|
||||
// A genuine outward gesture can be blocked by the controller's ceiling.
|
||||
settling = true;
|
||||
}
|
||||
if (climbing) {
|
||||
if (!zoomingOut) return;
|
||||
settling = true;
|
||||
} else {
|
||||
zoomingOut = false;
|
||||
// Outward inertia can lower altitude when Cesium clamps slant range at
|
||||
// the ceiling. Finish that correction; fresh inward input cancels above.
|
||||
// A stationary correction owns only the pose it last produced.
|
||||
if (
|
||||
!input.outward &&
|
||||
((!descending &&
|
||||
!Cesium.Cartesian3.equals(camera.position, lastPosition)) ||
|
||||
Math.abs(Cesium.Math.negativePiToPi(camera.heading - lastHeading)) >
|
||||
1e-10 ||
|
||||
Math.abs(camera.pitch - lastPitch) > 1e-10 ||
|
||||
Math.abs(Cesium.Math.negativePiToPi(camera.roll - lastRoll)) >
|
||||
1e-10)
|
||||
)
|
||||
settling = false;
|
||||
if (!settling) return;
|
||||
}
|
||||
const maxPitch = maxPitchForHeight(height);
|
||||
if (maxPitch === null || !(camera.pitch > maxPitch)) {
|
||||
settling = false;
|
||||
} else {
|
||||
const error = maxPitch - camera.pitch;
|
||||
const easedPitch =
|
||||
Math.abs(error) <= PITCH_SNAP_RAD
|
||||
? maxPitch
|
||||
: camera.pitch + error * PITCH_EASE;
|
||||
// A large wheel step must not leave the view above the limb while easing.
|
||||
const limbPitch = -Math.acos(
|
||||
EARTH_RADIUS_M / (EARTH_RADIUS_M + height),
|
||||
);
|
||||
const pitch = Math.min(easedPitch, limbPitch);
|
||||
camera.setView({
|
||||
orientation: { heading: camera.heading, pitch, roll: camera.roll },
|
||||
});
|
||||
settling = pitch > maxPitch;
|
||||
}
|
||||
// Observe the end of inertia too; no arbitrary timeout cuts off a long zoom.
|
||||
if (zoomingOut || settling) requestRender('zoom-out-guard');
|
||||
} finally {
|
||||
lastHeight = camera.positionCartographic?.height;
|
||||
lastPosition = Cesium.Cartesian3.clone(camera.position, lastPosition);
|
||||
lastHeading = camera.heading;
|
||||
lastPitch = camera.pitch;
|
||||
lastRoll = camera.roll;
|
||||
}
|
||||
};
|
||||
const removePreRender = scene.preRender.addEventListener(guard);
|
||||
// Fires every frame, after Cesium has applied this frame's input, while
|
||||
// preRender fires only on frames that draw. Input that didn't move the camera
|
||||
// (such as wheel at the ceiling) would otherwise wait for an unrelated frame.
|
||||
const removePostUpdate = scene.postUpdate.addEventListener(() => {
|
||||
followCeiling();
|
||||
if (
|
||||
INPUT_BINDINGS.some(({ type, modifier }) =>
|
||||
aggregator.isMoving(type, modifier),
|
||||
)
|
||||
)
|
||||
requestRender('zoom-out-guard');
|
||||
});
|
||||
const removeCameraHandoff = subscribeCameraHandoff?.(() => {
|
||||
cancel();
|
||||
// Discard input queued before an explicit navigation action took ownership.
|
||||
aggregator.reset();
|
||||
});
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
removeCameraHandoff?.();
|
||||
removePreRender?.();
|
||||
removePostUpdate?.();
|
||||
aggregator.destroy();
|
||||
if (controller.maximumZoomDistance === maximumZoomDistance)
|
||||
controller.maximumZoomDistance = originalMaximumZoomDistance;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
installZoomOutGuard,
|
||||
maxPitchForHeight,
|
||||
MAXIMUM_ZOOM_DISTANCE_M,
|
||||
} from './zoomOutGuard.js';
|
||||
|
||||
const deg = Cesium.Math.toDegrees;
|
||||
const { WHEEL, PINCH, RIGHT_DRAG, LEFT_DRAG, MIDDLE_DRAG } =
|
||||
Cesium.CameraEventType;
|
||||
const { CTRL, SHIFT, ALT } = Cesium.KeyboardEventModifier;
|
||||
|
||||
test('maxPitchForHeight leaves low-altitude tilt alone', () => {
|
||||
assert.equal(maxPitchForHeight(0), null);
|
||||
assert.equal(maxPitchForHeight(299_999), null);
|
||||
assert.equal(maxPitchForHeight(NaN), null);
|
||||
assert.equal(maxPitchForHeight(undefined), null);
|
||||
});
|
||||
|
||||
test('maxPitchForHeight looks 8 degrees below the limb', () => {
|
||||
assert.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01);
|
||||
assert.equal(deg(maxPitchForHeight(1e12)), -90);
|
||||
});
|
||||
|
||||
test('maxPitchForHeight steepens monotonically with height', () => {
|
||||
let previous = 0;
|
||||
for (let h = 300_000; h <= 100_000_000; h *= 1.5) {
|
||||
const pitch = maxPitchForHeight(h);
|
||||
assert.ok(pitch <= previous, `pitch at ${h} m`);
|
||||
previous = pitch;
|
||||
}
|
||||
});
|
||||
|
||||
function guardFixture({ maximumZoomDistance = Infinity } = {}) {
|
||||
const preRender = new Set();
|
||||
const postUpdate = new Set();
|
||||
const handoffs = new Set();
|
||||
const container = new EventTarget();
|
||||
const canvas = new EventTarget();
|
||||
const movements = new Map();
|
||||
const key = (type, modifier) => `${type}:${modifier}`;
|
||||
const aggregator = {
|
||||
resets: 0,
|
||||
destroys: 0,
|
||||
isMoving: (type, modifier) => movements.has(key(type, modifier)),
|
||||
getMovement: (type, modifier) => movements.get(key(type, modifier)),
|
||||
reset() {
|
||||
movements.clear();
|
||||
this.resets++;
|
||||
},
|
||||
destroy() {
|
||||
this.destroys++;
|
||||
movements.clear();
|
||||
},
|
||||
};
|
||||
const camera = {
|
||||
height: 170_000,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
heading: 0.4,
|
||||
pitch: Cesium.Math.toRadians(-25),
|
||||
roll: 0,
|
||||
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
views: [],
|
||||
get positionCartographic() {
|
||||
return {
|
||||
height: this.height,
|
||||
longitude: this.longitude,
|
||||
latitude: this.latitude,
|
||||
};
|
||||
},
|
||||
get position() {
|
||||
return this.positionWC;
|
||||
},
|
||||
get positionWC() {
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
this.longitude,
|
||||
this.latitude,
|
||||
this.height,
|
||||
);
|
||||
},
|
||||
setView({ orientation }) {
|
||||
this.views.push(orientation);
|
||||
this.heading = orientation.heading;
|
||||
this.pitch = orientation.pitch;
|
||||
this.roll = orientation.roll;
|
||||
},
|
||||
};
|
||||
const controller = {
|
||||
maximumZoomDistance,
|
||||
enableInputs: true,
|
||||
enableZoom: true,
|
||||
zoomEventTypes: [
|
||||
RIGHT_DRAG,
|
||||
WHEEL,
|
||||
PINCH,
|
||||
{ eventType: WHEEL, modifier: CTRL },
|
||||
],
|
||||
};
|
||||
const scene = {
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
screenSpaceCameraController: controller,
|
||||
preRender: {
|
||||
addEventListener(handler) {
|
||||
preRender.add(handler);
|
||||
return () => preRender.delete(handler);
|
||||
},
|
||||
},
|
||||
postUpdate: {
|
||||
addEventListener(handler) {
|
||||
postUpdate.add(handler);
|
||||
return () => postUpdate.delete(handler);
|
||||
},
|
||||
},
|
||||
};
|
||||
const renders = [];
|
||||
const dispose = installZoomOutGuard(
|
||||
{ camera, container, canvas, scene },
|
||||
{
|
||||
subscribeCameraHandoff(listener) {
|
||||
handoffs.add(listener);
|
||||
return () => handoffs.delete(listener);
|
||||
},
|
||||
createAggregator(element) {
|
||||
assert.equal(element, canvas);
|
||||
return aggregator;
|
||||
},
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
},
|
||||
);
|
||||
// A frame Cesium skips drawing in request-render mode: no preRender.
|
||||
const idleFrame = () => {
|
||||
for (const handler of postUpdate) handler();
|
||||
};
|
||||
const frame = (height = camera.height) => {
|
||||
camera.height = height;
|
||||
idleFrame();
|
||||
for (const handler of preRender) handler();
|
||||
};
|
||||
const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => {
|
||||
const distance = {
|
||||
startPosition: new Cesium.Cartesian2(0, 0),
|
||||
endPosition: new Cesium.Cartesian2(0, deltaY),
|
||||
};
|
||||
movements.set(
|
||||
key(type, modifier),
|
||||
type === PINCH
|
||||
? {
|
||||
distance,
|
||||
angleAndHeight: {
|
||||
startPosition: new Cesium.Cartesian2(0, 0),
|
||||
endPosition: new Cesium.Cartesian2(0, tiltDeltaY),
|
||||
},
|
||||
}
|
||||
: distance,
|
||||
);
|
||||
// Also exercise the former raw DOM path when running against the PR head.
|
||||
const event = new Event(
|
||||
type === WHEEL ? 'wheel' : type === PINCH ? 'touchmove' : 'pointermove',
|
||||
);
|
||||
Object.assign(event, {
|
||||
buttons: type === RIGHT_DRAG ? 2 : type === MIDDLE_DRAG ? 4 : 1,
|
||||
ctrlKey: modifier === CTRL,
|
||||
shiftKey: modifier === SHIFT,
|
||||
altKey: modifier === ALT,
|
||||
touches: type === PINCH ? [1, 2] : [],
|
||||
});
|
||||
container.dispatchEvent(event);
|
||||
};
|
||||
return {
|
||||
camera,
|
||||
controller,
|
||||
scene,
|
||||
aggregator,
|
||||
preRender,
|
||||
postUpdate,
|
||||
handoffs,
|
||||
handoff: () => {
|
||||
for (const listener of handoffs) listener();
|
||||
},
|
||||
renders,
|
||||
dispose,
|
||||
frame,
|
||||
idleFrame,
|
||||
movement,
|
||||
};
|
||||
}
|
||||
|
||||
test('a tilted wheel zoom-out pitches down and preserves heading and roll', () => {
|
||||
const f = guardFixture();
|
||||
f.frame();
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_000_000);
|
||||
const target = maxPitchForHeight(1_000_000);
|
||||
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25));
|
||||
assert.ok(f.camera.pitch > target, 'eases rather than snapping');
|
||||
assert.equal(f.camera.heading, 0.4);
|
||||
assert.equal(f.camera.roll, 0);
|
||||
for (let i = 1; i <= 40; i++) f.frame(1_000_000 + i);
|
||||
assert.ok(
|
||||
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-3,
|
||||
);
|
||||
assert.ok(f.renders.length > 0);
|
||||
});
|
||||
|
||||
test('a large outward step immediately keeps the Earth limb in view', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -100);
|
||||
f.frame(20_000_000);
|
||||
const limbPitch = -Math.acos(6_371_000 / (6_371_000 + 20_000_000));
|
||||
assert.ok(f.camera.pitch <= limbPitch);
|
||||
assert.ok(f.camera.pitch > maxPitchForHeight(20_000_000));
|
||||
});
|
||||
|
||||
test('pinch, right-drag and configured Ctrl+wheel can zoom out', () => {
|
||||
for (const [type, modifier] of [[PINCH], [RIGHT_DRAG], [WHEEL, CTRL]]) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(type, -10, { modifier });
|
||||
f.frame(700_000);
|
||||
assert.equal(f.camera.views.length, 1, `${type}:${modifier}`);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('Ctrl+right-drag tilt is left at the pose chosen by Cesium', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(RIGHT_DRAG, 3, { modifier: CTRL });
|
||||
// Real Cesium's orbit tilt raises altitude with no zoom movement.
|
||||
f.camera.pitch = Cesium.Math.toRadians(-26.07722459498313);
|
||||
f.frame(529_392.3759876974);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
assert.equal(deg(f.camera.pitch), -26.07722459498313);
|
||||
});
|
||||
|
||||
test('parallel two-finger tilt is not mistaken for pinch zoom', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(PINCH, 0, { tiltDeltaY: 3 });
|
||||
f.frame(600_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
test('two-finger tilt with finger-spacing drift is left to Cesium', () => {
|
||||
for (const [spacing, tilt] of [
|
||||
[-0.01, 3],
|
||||
[-2, 3],
|
||||
[-3, -3],
|
||||
]) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(PINCH, spacing, { tiltDeltaY: tilt });
|
||||
// Real Cesium's two-finger tilt climbs and picks its own pitch.
|
||||
f.camera.pitch = Cesium.Math.toRadians(-26.07746);
|
||||
f.frame(529_406);
|
||||
assert.equal(f.camera.views.length, 0, `${spacing}, ${tilt}`);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pinch that closes the fingers more than it moves them still zooms out', () => {
|
||||
// One finger still, the other sliding towards it: the midpoint moves half as far.
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(PINCH, -10, { tiltDeltaY: -5 });
|
||||
f.frame(700_000);
|
||||
assert.equal(f.camera.views.length, 1);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('unconfigured wheel modifiers and one-finger drag do not enable the guard', () => {
|
||||
for (const [type, modifier] of [[WHEEL, SHIFT], [WHEEL, ALT], [LEFT_DRAG]]) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(type, -10, { modifier });
|
||||
f.frame(600_000);
|
||||
assert.equal(f.camera.views.length, 0, `${type}:${modifier}`);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('current controller zoom bindings are honored', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.controller.zoomEventTypes = { eventType: MIDDLE_DRAG, modifier: ALT };
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(600_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
f.movement(MIDDLE_DRAG, -10, { modifier: ALT });
|
||||
f.frame(700_000);
|
||||
assert.equal(f.camera.views.length, 1);
|
||||
});
|
||||
|
||||
test('programmatic height changes alone never activate the guard', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(170_000);
|
||||
f.frame(2_000_000);
|
||||
f.frame(3_000_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
test('inward zoom does not activate the guard even when altitude rises', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, 10);
|
||||
f.frame(600_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
test('outward input with falling height does not alter pitch', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(2_000_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_500_000);
|
||||
f.frame(1_500_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
test('pitch finishes settling when the zoom stops changing height', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(2_000_000);
|
||||
const firstPitch = f.camera.pitch;
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.ok(f.camera.pitch < firstPitch);
|
||||
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
|
||||
const views = f.camera.views.length;
|
||||
f.frame();
|
||||
assert.equal(
|
||||
f.camera.views.length,
|
||||
views,
|
||||
'settled pose does not keep rendering',
|
||||
);
|
||||
});
|
||||
|
||||
test('completed settling does not authorize a later programmatic climb', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(700_000);
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
const views = f.camera.views.length;
|
||||
f.frame(3_000_000);
|
||||
assert.equal(f.camera.views.length, views);
|
||||
});
|
||||
|
||||
test('a fresh inward zoom or unrelated gesture cancels pending correction', () => {
|
||||
for (const [type, delta, options] of [
|
||||
[WHEEL, 10, {}],
|
||||
[RIGHT_DRAG, 3, { modifier: CTRL }],
|
||||
[LEFT_DRAG, 3, {}],
|
||||
[MIDDLE_DRAG, 3, {}],
|
||||
[PINCH, 0, { tiltDeltaY: 3 }],
|
||||
]) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(700_000);
|
||||
const views = f.camera.views.length;
|
||||
f.movement(type, delta, options);
|
||||
f.frame(800_000);
|
||||
f.frame(900_000);
|
||||
assert.equal(f.camera.views.length, views, `${type}:${options.modifier}`);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('an external pose change at fixed height cancels pending correction', () => {
|
||||
for (const field of ['heading', 'pitch', 'roll', 'longitude', 'latitude']) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(700_000);
|
||||
const views = f.camera.views.length;
|
||||
f.camera[field] += 0.1;
|
||||
f.frame();
|
||||
f.frame(900_000);
|
||||
assert.equal(f.camera.views.length, views, field);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled controls, flights, tracked transforms and non-3D scenes cancel intent', () => {
|
||||
const cases = [
|
||||
(f) => {
|
||||
f.controller.enableInputs = false;
|
||||
return () => {
|
||||
f.controller.enableInputs = true;
|
||||
};
|
||||
},
|
||||
(f) => {
|
||||
f.controller.enableZoom = false;
|
||||
return () => {
|
||||
f.controller.enableZoom = true;
|
||||
};
|
||||
},
|
||||
(f) => {
|
||||
f.camera._currentFlight = {};
|
||||
return () => {
|
||||
delete f.camera._currentFlight;
|
||||
};
|
||||
},
|
||||
(f) => {
|
||||
f.camera.transform = Cesium.Matrix4.fromTranslation(
|
||||
new Cesium.Cartesian3(1, 2, 3),
|
||||
);
|
||||
return () => {
|
||||
f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
|
||||
};
|
||||
},
|
||||
(f) => {
|
||||
f.scene.mode = Cesium.SceneMode.SCENE2D;
|
||||
return () => {
|
||||
f.scene.mode = Cesium.SceneMode.SCENE3D;
|
||||
};
|
||||
},
|
||||
];
|
||||
for (const block of cases) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(700_000);
|
||||
const views = f.camera.views.length;
|
||||
const restore = block(f);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(800_000);
|
||||
restore();
|
||||
f.frame(900_000);
|
||||
assert.equal(f.camera.views.length, views);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_000_000);
|
||||
assert.equal(f.camera.views.length, views + 1);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('installer sets the ceiling and disposer releases aggregator and frame listener', () => {
|
||||
const f = guardFixture();
|
||||
assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
assert.equal(f.preRender.size, 1);
|
||||
assert.equal(f.postUpdate.size, 1);
|
||||
f.frame();
|
||||
assert.ok(f.aggregator.resets > 0);
|
||||
f.dispose();
|
||||
f.dispose();
|
||||
assert.equal(f.controller.maximumZoomDistance, Infinity);
|
||||
assert.equal(f.aggregator.destroys, 1);
|
||||
assert.equal(f.preRender.size, 0);
|
||||
assert.equal(f.postUpdate.size, 0);
|
||||
assert.equal(f.handoffs.size, 0);
|
||||
});
|
||||
|
||||
test('installer preserves a stricter host zoom ceiling', () => {
|
||||
const f = guardFixture({ maximumZoomDistance: 123 });
|
||||
assert.equal(f.controller.maximumZoomDistance, 123);
|
||||
f.dispose();
|
||||
assert.equal(f.controller.maximumZoomDistance, 123);
|
||||
});
|
||||
|
||||
test('disposer keeps a ceiling someone else changed later', () => {
|
||||
const f = guardFixture();
|
||||
f.controller.maximumZoomDistance = 5;
|
||||
f.dispose();
|
||||
assert.equal(f.controller.maximumZoomDistance, 5);
|
||||
});
|
||||
|
||||
test('installer rejects an incomplete viewer', () => {
|
||||
assert.throws(() => installZoomOutGuard({}), TypeError);
|
||||
});
|
||||
|
||||
test('outward input at the ceiling reframes a deliberately tilted camera', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(MAXIMUM_ZOOM_DISTANCE_M);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame();
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.ok(
|
||||
Math.abs(f.camera.pitch - maxPitchForHeight(MAXIMUM_ZOOM_DISTANCE_M)) <
|
||||
1e-10,
|
||||
);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('input that leaves the camera still requests a frame to read it', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(MAXIMUM_ZOOM_DISTANCE_M);
|
||||
f.idleFrame();
|
||||
assert.deepEqual(f.renders, [], 'an idle scene stays idle');
|
||||
// At the ceiling Cesium doesn't move the camera, so nothing would draw.
|
||||
f.movement(WHEEL, -10);
|
||||
f.idleFrame();
|
||||
assert.deepEqual(f.renders, ['zoom-out-guard']);
|
||||
f.frame();
|
||||
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25), 'read that frame');
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('equivalent heading and roll wraps do not cancel stationary settling', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(2_000_000);
|
||||
f.camera.heading += Cesium.Math.TWO_PI;
|
||||
f.camera.roll += Cesium.Math.TWO_PI;
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('a real Cesium camera settles across the north heading wrap at Istanbul', () => {
|
||||
const scene = {
|
||||
canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 },
|
||||
drawingBufferWidth: 1200,
|
||||
drawingBufferHeight: 800,
|
||||
mapProjection: new Cesium.GeographicProjection(),
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
preRender: new Cesium.Event(),
|
||||
postUpdate: new Cesium.Event(),
|
||||
screenSpaceCameraController: {
|
||||
maximumZoomDistance: Infinity,
|
||||
enableInputs: true,
|
||||
zoomEventTypes: [WHEEL],
|
||||
},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
const setHeight = (height) =>
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, height),
|
||||
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-25), roll: 0 },
|
||||
});
|
||||
setHeight(1_000_000);
|
||||
let outward = false;
|
||||
const dispose = installZoomOutGuard(
|
||||
{ scene, camera, canvas: scene.canvas },
|
||||
{
|
||||
createAggregator: () => ({
|
||||
isMoving: (type, modifier) =>
|
||||
outward && type === WHEEL && modifier === undefined,
|
||||
getMovement: () => ({
|
||||
startPosition: { y: 0 },
|
||||
endPosition: { y: -10 },
|
||||
}),
|
||||
reset: () => {
|
||||
outward = false;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
requestRender() {},
|
||||
},
|
||||
);
|
||||
try {
|
||||
setHeight(MAXIMUM_ZOOM_DISTANCE_M);
|
||||
outward = true;
|
||||
for (let i = 0; i < 40; i++) scene.preRender.raiseEvent(scene);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
camera.pitch - maxPitchForHeight(camera.positionCartographic.height),
|
||||
) < 1e-8,
|
||||
);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('an outward inertia ceiling adjustment can descend while pitch settles', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(39_000_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(39_999_999);
|
||||
const before = f.camera.pitch;
|
||||
// Cesium limits slant range on an inertial pick, slightly below the altitude cap.
|
||||
f.frame(39_997_258);
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.ok(f.camera.pitch < before);
|
||||
assert.ok(
|
||||
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-10,
|
||||
);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('a descending programmatic tilt cancels pending correction', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(39_000_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(39_999_999);
|
||||
const count = f.camera.views.length;
|
||||
f.camera.pitch += 0.1;
|
||||
f.frame(39_997_258);
|
||||
for (let i = 0; i < 40; i++) f.frame();
|
||||
assert.equal(f.camera.views.length, count);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
test('explicit navigation cancels outward inertia before a climbing UI tilt', () => {
|
||||
const f = guardFixture();
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_000_000);
|
||||
f.movement(WHEEL, -10);
|
||||
f.handoff();
|
||||
const writes = f.camera.views.length;
|
||||
f.camera.pitch = Cesium.Math.toRadians(-25);
|
||||
f.frame(1_100_000);
|
||||
f.frame(1_200_000);
|
||||
assert.equal(f.camera.views.length, writes);
|
||||
assert.equal(deg(f.camera.pitch), -25);
|
||||
// A subsequent user zoom starts a new correction normally.
|
||||
f.movement(WHEEL, -10);
|
||||
f.frame(1_300_000);
|
||||
assert.ok(f.camera.views.length > writes);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
// A real Cesium camera and controller, fed by DOM wheel events on the canvas.
|
||||
function realControllerScene(startHeight) {
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
style: {},
|
||||
// Keep Cesium's input handler off the missing `document`.
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
}),
|
||||
});
|
||||
const scene = {
|
||||
canvas,
|
||||
drawingBufferWidth: 1200,
|
||||
drawingBufferHeight: 800,
|
||||
mapProjection: new Cesium.GeographicProjection(),
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
verticalExaggeration: 1,
|
||||
verticalExaggerationRelativeHeight: 0,
|
||||
preRender: new Cesium.Event(),
|
||||
postUpdate: new Cesium.Event(),
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
const controller = new Cesium.ScreenSpaceCameraController(scene);
|
||||
controller.inertiaZoom = 0;
|
||||
scene.screenSpaceCameraController = controller;
|
||||
const setHeight = (h) =>
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, h),
|
||||
orientation: { heading: 0, pitch: -Cesium.Math.PI_OVER_TWO, roll: 0 },
|
||||
});
|
||||
setHeight(startHeight);
|
||||
const dispose = installZoomOutGuard(
|
||||
{ scene, camera, canvas },
|
||||
{ requestRender() {} },
|
||||
);
|
||||
// Scene.render order: the controller applies input, then postUpdate, then
|
||||
// preRender on frames that draw.
|
||||
const frame = () => {
|
||||
controller.update();
|
||||
scene.postUpdate.raiseEvent(scene);
|
||||
scene.preRender.raiseEvent(scene);
|
||||
};
|
||||
const wheel = (deltaY) =>
|
||||
canvas.dispatchEvent(
|
||||
Object.assign(new Event('wheel'), {
|
||||
deltaY,
|
||||
deltaMode: 0,
|
||||
clientX: 600,
|
||||
clientY: 400,
|
||||
}),
|
||||
);
|
||||
const height = () => camera.positionCartographic.height;
|
||||
return {
|
||||
scene,
|
||||
camera,
|
||||
controller,
|
||||
setHeight,
|
||||
frame,
|
||||
wheel,
|
||||
height,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
test('a camera placed above the ceiling cannot zoom out and zooms in without a jump', () => {
|
||||
const s = realControllerScene(60_000_000);
|
||||
try {
|
||||
s.frame();
|
||||
s.wheel(1);
|
||||
s.frame();
|
||||
assert.ok(Math.abs(s.height() - 60_000_000) < 1, `${s.height()} m`);
|
||||
s.wheel(-1);
|
||||
s.frame();
|
||||
const firstStep = s.height();
|
||||
assert.ok(firstStep < 60_000_000, 'zooms in');
|
||||
assert.ok(firstStep > 50_000_000, `${firstStep} m is gradual`);
|
||||
// The camera can no longer climb back above where it has descended to.
|
||||
s.wheel(1);
|
||||
s.frame();
|
||||
assert.ok(Math.abs(s.height() - firstStep) < 1, `${s.height()} m`);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
s.wheel(-100);
|
||||
s.frame();
|
||||
}
|
||||
assert.ok(s.height() < MAXIMUM_ZOOM_DISTANCE_M);
|
||||
assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
for (let i = 0; i < 80; i++) {
|
||||
s.wheel(100);
|
||||
s.frame();
|
||||
}
|
||||
assert.ok(Math.abs(s.height() - MAXIMUM_ZOOM_DISTANCE_M) < 1_000);
|
||||
} finally {
|
||||
s.dispose();
|
||||
}
|
||||
assert.equal(s.controller.maximumZoomDistance, Infinity);
|
||||
});
|
||||
|
||||
test('a share-link view restored above the ceiling is honoured by later frames', () => {
|
||||
const s = realControllerScene(1_000_000);
|
||||
try {
|
||||
s.frame();
|
||||
s.setHeight(97_679_259);
|
||||
s.frame();
|
||||
s.wheel(100);
|
||||
s.frame();
|
||||
assert.ok(Math.abs(s.height() - 97_679_259) < 1, `${s.height()} m`);
|
||||
} finally {
|
||||
s.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('a tracked camera cannot climb past the ceiling from its target', () => {
|
||||
const s = realControllerScene(1_000_000);
|
||||
try {
|
||||
s.frame();
|
||||
// In look-at mode Cesium measures zoom from the target, not the Earth.
|
||||
s.camera.lookAt(
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
|
||||
new Cesium.HeadingPitchRange(0, -0.5, 5_000_000),
|
||||
);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
s.wheel(100);
|
||||
s.frame();
|
||||
}
|
||||
const range = Cesium.Cartesian3.magnitude(s.camera.position);
|
||||
assert.ok(range <= MAXIMUM_ZOOM_DISTANCE_M + 1_000, `${range} m`);
|
||||
assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
} finally {
|
||||
s.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @module awarenessEcho
|
||||
* @description The Global Context sonar echo field. The awareness layer
|
||||
* publishes it while its range pulses run; the detection overlay reads it to
|
||||
* pulse the brackets of contacts a pulse front has just crossed. It holds no
|
||||
* awareness policy of its own: the publisher passes radius, pulse timing and
|
||||
* echo styling in, so the overlay never imports awareness internals.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* @typedef {object} AwarenessEchoField
|
||||
* @property {Cesium.Cartesian3} center Subject's ground point. The publisher
|
||||
* may move it in place; it is read on every query.
|
||||
* @property {number} radiusM Range the pulses grow to.
|
||||
* @property {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
|
||||
* @property {{fadeMs: number, layerIds: string[]}} echo
|
||||
* @property {(nowMs: number) => number} [level] How far the pulses are
|
||||
* switched on, 0 to 1; echoes scale with it, so they rest with the pulses.
|
||||
*/
|
||||
|
||||
/** @type {AwarenessEchoField|null} */
|
||||
let field = null;
|
||||
let layerIds = new Set();
|
||||
const groundScratch = new Cesium.Cartesian3();
|
||||
|
||||
/**
|
||||
* Start (or replace) the echo field.
|
||||
* @param {AwarenessEchoField} next
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setAwarenessEchoField(next) {
|
||||
field = next;
|
||||
layerIds = new Set(next.echo.layerIds);
|
||||
}
|
||||
|
||||
/** Stop all echoes. Safe when none were set. */
|
||||
export function clearAwarenessEchoField() {
|
||||
field = null;
|
||||
layerIds = new Set();
|
||||
}
|
||||
|
||||
/** @returns {AwarenessEchoField|null} The field the overlay styles from. */
|
||||
export function getAwarenessEchoField() {
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any echo can light at `nowMs`: a field is set and its pulses are
|
||||
* running. The overlay checks this once per frame, so resting pulses cost no
|
||||
* per-contact work.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function awarenessEchoActive(nowMs) {
|
||||
return Boolean(field) && (field.level?.(nowMs) ?? 1) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How lit a contact's echo is at `nowMs`: 1 the moment a pulse front reaches
|
||||
* its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The
|
||||
* strongest pulse wins. A front that has wrapped back to the centre still
|
||||
* counts for the contact it last crossed, so rim contacts fade out instead of
|
||||
* cutting off. Nothing nearer than the birth ring `startFraction` out echoes,
|
||||
* and within the pulse's `fadeInFraction` the echo is scaled down with it.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {number} distanceM Contact's distance from the subject.
|
||||
* @param {number} radiusM Range the pulses grow to.
|
||||
* @param {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
|
||||
* @param {{fadeMs: number}} echo
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) {
|
||||
// Pulses are born `startFraction` out, so nothing nearer (the subject
|
||||
// itself, its wingmen) is ever crossed; without this it lit on every birth.
|
||||
if (
|
||||
!Number.isFinite(distanceM) ||
|
||||
distanceM < radiusM * pulse.startFraction ||
|
||||
distanceM > radiusM
|
||||
)
|
||||
return 0;
|
||||
// The phase at which a pulse front sits at this distance.
|
||||
const touchPhase =
|
||||
(distanceM / radiusM - pulse.startFraction) / (1 - pulse.startFraction);
|
||||
let strength = 0;
|
||||
for (let index = 0; index < pulse.count; index++) {
|
||||
const cycle = nowMs / pulse.periodMs + index / pulse.count;
|
||||
let since = cycle - Math.floor(cycle) - touchPhase;
|
||||
// Rounding at the exact crossing must not read as a whole period ago.
|
||||
if (since < -1e-9) since += 1;
|
||||
else if (since < 0) since = 0;
|
||||
const elapsedMs = since * pulse.periodMs;
|
||||
if (elapsedMs < echo.fadeMs)
|
||||
strength = Math.max(strength, (1 - elapsedMs / echo.fadeMs) ** 2);
|
||||
}
|
||||
// A front still fading in over `fadeInFraction` of its travel echoes no
|
||||
// brighter than it shows.
|
||||
const fadeIn = pulse.fadeInFraction ?? 0;
|
||||
return fadeIn > 0 ? strength * Math.min(1, touchPhase / fadeIn) : strength;
|
||||
}
|
||||
|
||||
/**
|
||||
* The echo for one contact of `layerId` at world `position`, or 0 when no
|
||||
* field is set or the layer does not echo. Distance is measured between
|
||||
* ground points, so an aircraft's altitude does not shift its echo.
|
||||
* @param {string} layerId Owning layer of the contact.
|
||||
* @param {Cesium.Cartesian3} position Contact's world position.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessEchoAt(layerId, position, nowMs) {
|
||||
if (!field || !position || !layerIds.has(layerId)) return 0;
|
||||
const level = field.level?.(nowMs) ?? 1;
|
||||
if (!(level > 0)) return 0;
|
||||
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
|
||||
position,
|
||||
groundScratch,
|
||||
);
|
||||
if (!ground) return 0;
|
||||
return (
|
||||
level *
|
||||
awarenessEchoStrength(
|
||||
nowMs,
|
||||
Cesium.Cartesian3.distance(field.center, ground),
|
||||
field.radiusM,
|
||||
field.pulse,
|
||||
field.echo,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The echo bracket the detection overlay draws around one contact, or null
|
||||
* when it has no echo. `band` is the alpha band, 1 to `steps`, the echo's
|
||||
* strength times the contact bracket's own `bracketAlpha` rounded to the
|
||||
* nearest band, so an echo is never drawn brighter than its bracket; one
|
||||
* that rounds to nothing is not drawn. `spreadPx` is how far the echo sits
|
||||
* outside the contact's bracket, 0 as the front arrives, growing to the
|
||||
* field's `spreadPx` as it fades.
|
||||
* @param {string} layerId Owning layer of the contact.
|
||||
* @param {Cesium.Cartesian3} position Contact's world position.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {number} bracketAlpha The contact bracket's drawn alpha, 0 to 1.
|
||||
* @param {number} steps Alpha bands the overlay batches by.
|
||||
* @returns {{band: number, spreadPx: number}|null}
|
||||
*/
|
||||
export function awarenessEchoBracket(
|
||||
layerId,
|
||||
position,
|
||||
nowMs,
|
||||
bracketAlpha,
|
||||
steps,
|
||||
) {
|
||||
const echo = awarenessEchoAt(layerId, position, nowMs);
|
||||
if (!(echo > 0.01)) return null;
|
||||
const band = Math.min(steps, Math.round(echo * bracketAlpha * steps));
|
||||
if (!(band >= 1)) return null;
|
||||
return { band, spreadPx: field.echo.spreadPx * (1 - echo) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The echo's stroke colour: the field's own colour on the default detection
|
||||
* style, and the style's line colour under the others (NVG, FLIR, CRT), so
|
||||
* an echo never stands out of a monochrome palette.
|
||||
* @param {string} styleLine The active detection style's line colour.
|
||||
* @param {boolean} defaultStyle Whether the default detection style is active.
|
||||
* @returns {string|null} Null when no field is set.
|
||||
*/
|
||||
export function awarenessEchoColor(styleLine, defaultStyle) {
|
||||
if (!field) return null;
|
||||
return defaultStyle ? field.echo.color : styleLine;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// src/awarenessEcho.test.mjs — the Global Context echo field: when a sonar
|
||||
// pulse front crosses a flight or vessel, its bracket echo lights and fades.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
awarenessEchoActive,
|
||||
awarenessEchoAt,
|
||||
awarenessEchoStrength,
|
||||
clearAwarenessEchoField,
|
||||
getAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from './awarenessEcho.js';
|
||||
|
||||
const R = 250_000;
|
||||
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
|
||||
const echo = { fadeMs: 1400, layerIds: ['flights', 'ais-live-vessels'] };
|
||||
const strength = (nowMs, distanceM) =>
|
||||
awarenessEchoStrength(nowMs, distanceM, R, pulse, echo);
|
||||
/** The moment pulse 0's front sits at `distanceM`, in a later cycle. */
|
||||
const arrival = (distanceM) =>
|
||||
((distanceM / R - pulse.startFraction) / (1 - pulse.startFraction)) *
|
||||
pulse.periodMs +
|
||||
pulse.periodMs * 4;
|
||||
|
||||
test('an echo lights as a pulse front reaches the contact, then fades', () => {
|
||||
const d = 100_000;
|
||||
const t0 = arrival(d);
|
||||
assert.ok(Math.abs(strength(t0, d) - 1) < 1e-6);
|
||||
assert.ok(Math.abs(strength(t0 + echo.fadeMs / 2, d) - 0.25) < 1e-6);
|
||||
assert.equal(strength(t0 + echo.fadeMs + 1, d), 0);
|
||||
assert.equal(strength(t0 - 50, d), 0, 'dark just before the front');
|
||||
// Every pulse lights it once a period: `count` separate flashes in any
|
||||
// one-period window that starts after a flash has faded.
|
||||
let flashes = 0;
|
||||
let wasLit = false;
|
||||
const from = t0 + echo.fadeMs + 1;
|
||||
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
|
||||
const lit = strength(from + ms, d) > 0.5;
|
||||
if (lit && !wasLit) flashes++;
|
||||
wasLit = lit;
|
||||
}
|
||||
assert.equal(flashes, pulse.count);
|
||||
});
|
||||
|
||||
test('rim contacts fade out after the front wraps; outside the range never', () => {
|
||||
const rim = R * 0.999;
|
||||
assert.ok(strength(arrival(rim) + echo.fadeMs / 2, rim) > 0.2);
|
||||
// Inside the ring pulses are born on, nothing is ever crossed: the
|
||||
// subject and anything within 5 km never echo, at any moment.
|
||||
const inner = R * pulse.startFraction;
|
||||
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
|
||||
assert.equal(strength(ms, 0), 0, 'the subject itself');
|
||||
assert.equal(strength(ms, inner - 1), 0);
|
||||
}
|
||||
assert.ok(strength(pulse.periodMs * 5, inner) > 0.99, 'the birth ring');
|
||||
for (let ms = 0; ms < pulse.periodMs; ms += 50)
|
||||
assert.equal(strength(ms, R + 1), 0);
|
||||
assert.equal(strength(0, Number.NaN), 0);
|
||||
assert.equal(strength(0, -1), 0);
|
||||
});
|
||||
|
||||
test('awarenessEchoAt reads the published field, by layer, at ground level', (t) => {
|
||||
t.after(clearAwarenessEchoField);
|
||||
const contact = Cesium.Cartesian3.fromDegrees(3.5, 27, 0);
|
||||
assert.equal(awarenessEchoAt('flights', contact, 0), 0, 'no field, no echo');
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
setAwarenessEchoField({ center, radiusM: R, pulse, echo });
|
||||
assert.ok(getAwarenessEchoField());
|
||||
const d = Cesium.Cartesian3.distance(center, contact);
|
||||
const t0 = arrival(d);
|
||||
assert.ok(awarenessEchoAt('flights', contact, t0) > 0.99);
|
||||
assert.ok(awarenessEchoAt('ais-live-vessels', contact, t0) > 0.99);
|
||||
// Layers outside the echo list never light.
|
||||
assert.equal(awarenessEchoAt('cctv', contact, t0), 0);
|
||||
assert.equal(awarenessEchoAt('military-installations', contact, t0), 0);
|
||||
// An aircraft 11 km up echoes with the ground point under it.
|
||||
const aloft = Cesium.Cartesian3.fromDegrees(3.5, 27, 11_000);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
awarenessEchoAt('flights', aloft, t0 + 300) -
|
||||
awarenessEchoAt('flights', contact, t0 + 300),
|
||||
) < 0.01,
|
||||
);
|
||||
// The field's centre is read in place, so a moved subject moves the echo:
|
||||
// about 50 km farther, the contact lights at its new distance's moment.
|
||||
Cesium.Cartesian3.clone(Cesium.Cartesian3.fromDegrees(3.5, 26.1, 0), center);
|
||||
const moved = Cesium.Cartesian3.distance(center, contact);
|
||||
assert.ok(Math.abs(moved - d) > 1000);
|
||||
assert.ok(awarenessEchoAt('flights', contact, arrival(moved)) > 0.99);
|
||||
assert.equal(awarenessEchoAt('flights', null, t0), 0);
|
||||
clearAwarenessEchoField();
|
||||
assert.equal(getAwarenessEchoField(), null);
|
||||
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
|
||||
});
|
||||
|
||||
test('echoes scale with the field level and rest with the pulses', (t) => {
|
||||
t.after(clearAwarenessEchoField);
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
// ~100 km east of the subject, on the ground.
|
||||
const contact = Cesium.Cartesian3.fromDegrees(4.008, 27, 0);
|
||||
let level = 1;
|
||||
setAwarenessEchoField({
|
||||
center,
|
||||
radiusM: R,
|
||||
pulse,
|
||||
echo,
|
||||
level: () => level,
|
||||
});
|
||||
const d = Cesium.Cartesian3.distance(center, contact);
|
||||
const t0 = arrival(d);
|
||||
const full = awarenessEchoAt('flights', contact, t0);
|
||||
assert.ok(full > 0.99);
|
||||
level = 0.5;
|
||||
assert.ok(
|
||||
Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9,
|
||||
);
|
||||
assert.equal(awarenessEchoActive(t0), true);
|
||||
level = 0;
|
||||
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
|
||||
// At rest the overlay skips echoes for the whole frame.
|
||||
assert.equal(awarenessEchoActive(t0), false);
|
||||
clearAwarenessEchoField();
|
||||
assert.equal(awarenessEchoActive(t0), false);
|
||||
});
|
||||
|
||||
test('near the subject an echo is no brighter than the fading-in front', () => {
|
||||
const fading = { ...pulse, fadeInFraction: 0.08 };
|
||||
const at = (distanceM) =>
|
||||
awarenessEchoStrength(arrival(distanceM), distanceM, R, fading, echo);
|
||||
// Born at 5 km, the front reaches full strength 8% of the way out.
|
||||
const full = R * (fading.startFraction + 0.08 * (1 - fading.startFraction));
|
||||
assert.equal(at(R * fading.startFraction), 0);
|
||||
const halfway = (R * fading.startFraction + full) / 2;
|
||||
assert.ok(Math.abs(at(halfway) - 0.5) < 0.01, `${at(halfway)}`);
|
||||
assert.ok(at(full + 1) > 0.99);
|
||||
assert.ok(
|
||||
at(R * 0.999) > 0.99,
|
||||
'the rim, and so the ring flash, is unchanged',
|
||||
);
|
||||
});
|
||||
@@ -1,51 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Camera presets for notable locations.
|
||||
* Phase 1 default: fly to Austin, TX on load.
|
||||
*/
|
||||
export const CAMERA_PRESETS = {
|
||||
austin: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(0),
|
||||
pitch: Cesium.Math.toRadians(-35),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
sf: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(30),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
nyc: {
|
||||
destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(-20),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly the camera to a preset location with a smooth animation.
|
||||
*/
|
||||
export function flyToPreset(viewer, presetName, duration = 3.0) {
|
||||
const preset = CAMERA_PRESETS[presetName];
|
||||
if (!preset) return;
|
||||
|
||||
viewer.camera.flyTo({
|
||||
destination: preset.destination,
|
||||
orientation: preset.orientation,
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera to Austin on load with a cinematic fly-in.
|
||||
* @returns {Function} Cancels the pending or active startup flight.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
/** Minimum eye height above the rendered surface for a usable view. */
|
||||
export const MIN_EYE_CLEARANCE_M = 120;
|
||||
/** Retries while tiles stream in; sampling fails until the mesh exists. */
|
||||
export const GUARD_ATTEMPTS = 8;
|
||||
export const GUARD_INTERVAL_MS = 600;
|
||||
const GUARD_ATTEMPTS = 8;
|
||||
const GUARD_INTERVAL_MS = 600;
|
||||
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
||||
const GUARD_EPSILON_M = 2;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LayerBindings } from './ui/layerBindings.js';
|
||||
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { StyleManager } from './ui/applicationShell.js';
|
||||
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -224,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))",
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
@@ -262,7 +261,7 @@ test('deferred search releases only after its final authority check', () => {
|
||||
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
|
||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
|
||||
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/);
|
||||
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
|
||||
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
|
||||
});
|
||||
|
||||
@@ -354,7 +353,7 @@ test('teardown refuses deferred location work before geocoding begins', () => {
|
||||
'if (!query || this.destroyed) return;',
|
||||
'const authority = this.begin();',
|
||||
'if (authority === false)',
|
||||
'this.input.blur();',
|
||||
'this.input?.blur();',
|
||||
'this.onStart?.(authority);',
|
||||
'await this.search(query',
|
||||
], 'disposed search refusal');
|
||||
@@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => {
|
||||
});
|
||||
|
||||
test('vessel and fire layers announce valid clicks and never fly cameras', () => {
|
||||
for (const [label, source] of [['vessels', vessels], ['fires', firms]]) {
|
||||
for (const source of [vessels, firms]) {
|
||||
assert.match(source, /requestWorldFocus\(\{/);
|
||||
assert.doesNotMatch(source, /camera\.flyTo/);
|
||||
}
|
||||
|
||||
+306
-13
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
|
||||
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
|
||||
* canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool
|
||||
* canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool
|
||||
* starts (the shared UI navigation policy releases this owner before the new
|
||||
* destination mutates the camera). fly_route is a
|
||||
* continuous motion in the same slot: a cinematic dolly along an existing
|
||||
@@ -13,14 +13,24 @@
|
||||
* Orbit circles the current screen-center ground target holding range and
|
||||
* pitch while advancing heading (per-frame camera.lookAt; the lookAt
|
||||
* transform is cleared on every stop so manual control returns cleanly).
|
||||
* Globe rotation turns the world-frame camera about its screen-up axis through
|
||||
* Earth's center, preserving range and framing. Left/right name the visible
|
||||
* globe motion, opposite to the camera's travel around Earth.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
} from './celestialRing.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from './renderGovernor.js';
|
||||
|
||||
let _isCockpitActive = null;
|
||||
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
|
||||
const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 };
|
||||
const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 };
|
||||
@@ -952,6 +962,7 @@ let _viewer = null;
|
||||
let _getTarget = null;
|
||||
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
|
||||
let _tickRemover = null;
|
||||
let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** Monotonic id for the motion currently running; 0 means "no motion". */
|
||||
let _motionSeq = 0;
|
||||
@@ -1162,7 +1173,11 @@ function onTick() {
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
} else if (m.kind === 'tilt' || m.kind === 'rotate') {
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
@@ -1172,7 +1187,38 @@ function onTick() {
|
||||
(scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.kind === 'tilt') {
|
||||
if (m.kind === 'rotate_globe') {
|
||||
// Tracking can acquire the camera without going through navigation.
|
||||
// Yield without resetting its pose/frame: the world-space arc proof
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
_viewer.trackedEntity ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
return;
|
||||
}
|
||||
// Cesium rotates both position and basis about the reference-frame
|
||||
// origin. The captured screen-up axis keeps left/right horizontal even
|
||||
// in a tilted or rolled view; a positive angle moves the globe right.
|
||||
const angle = m.direction === 'left' ? -stepRad : stepRad;
|
||||
if (globeRotationFitsKeyhole() !== true) {
|
||||
interruptCameraMotion('globe-framing-changed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!globeRotationHasClearance(cam.positionWC) ||
|
||||
Math.abs(
|
||||
Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius,
|
||||
) > 0.01
|
||||
) {
|
||||
interruptCameraMotion('globe-surface-limit');
|
||||
return;
|
||||
}
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
const next = cam.pitch + (up ? stepRad : -stepRad);
|
||||
@@ -1206,6 +1252,133 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
const { minimumRadius, maximumRadius } = ellipsoid;
|
||||
const radius = Cesium.Cartesian3.magnitude(position);
|
||||
return (
|
||||
Number.isFinite(minimumRadius) &&
|
||||
minimumRadius > 0 &&
|
||||
Number.isFinite(maximumRadius) &&
|
||||
maximumRadius >= minimumRadius &&
|
||||
Number.isFinite(radius) &&
|
||||
radius > maximumRadius * (1 + 1e-12)
|
||||
);
|
||||
}
|
||||
|
||||
/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */
|
||||
function globeRotationFitsKeyhole(camera = _viewer.camera) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii))
|
||||
return null;
|
||||
const canvas = _viewer.scene.canvas;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const frustum = camera.frustum;
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
!(frustum instanceof Cesium.PerspectiveFrustum) ||
|
||||
!Number.isFinite(frustum.near) ||
|
||||
frustum.near <= 0 ||
|
||||
!Number.isFinite(frustum.far) ||
|
||||
frustum.far <= frustum.near ||
|
||||
frustum.xOffset !== 0 ||
|
||||
frustum.yOffset !== 0
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const cam = camera;
|
||||
const distance = Cesium.Cartesian3.magnitude(cam.positionWC);
|
||||
const sphereRadius = ellipsoid.maximumRadius;
|
||||
if (!Number.isFinite(distance) || distance <= 0) return null;
|
||||
if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too.
|
||||
const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy);
|
||||
if (!radiusPx) return null;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
// The known nadir reset probe uses the same exact cone below; projecting
|
||||
// through SceneTransforms would read the live scene.camera instead.
|
||||
if (
|
||||
camera === _viewer.camera &&
|
||||
!projectEarthDiscToViewport(_viewer, width, height)
|
||||
) {
|
||||
// A near-surface horizon view cannot fit even when pointed at Earth;
|
||||
// an orbital camera looking away is unsupported, not a close-up offer.
|
||||
return radiusPx > Math.min(keyhole.radius, width / 2, height / 2)
|
||||
? false
|
||||
: null;
|
||||
}
|
||||
// The shared disc is an overlay approximation off-axis. Bound the sphere
|
||||
// against the existing keyhole cone. Canvas-edge clipping does not change
|
||||
// framing during rigid globe rotation and is not a clearance condition,
|
||||
// without changing the UI projection or defining another keyhole/mask.
|
||||
const toCenter = Cesium.Cartesian3.negate(
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC);
|
||||
const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC));
|
||||
const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC));
|
||||
const focalY = height / (2 * Math.tan(frustum.fovy / 2));
|
||||
const focalX =
|
||||
width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio);
|
||||
if (
|
||||
![depth, right, up, focalX, focalY].every(Number.isFinite) ||
|
||||
focalX <= 0 ||
|
||||
focalY <= 0
|
||||
)
|
||||
return null;
|
||||
const halfAngle = Math.asin(sphereRadius / distance);
|
||||
const centerAngle = Math.atan2(Math.hypot(right, up), depth);
|
||||
const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY));
|
||||
// No overlay visibility hysteresis: a fully contained smaller globe needs no reset.
|
||||
const framed = centerAngle + halfAngle <= keyholeAngle;
|
||||
if (!framed) return false;
|
||||
// Projection alone does not prove that a tiny globe is within depth planes.
|
||||
if (
|
||||
depth - sphereRadius < frustum.near ||
|
||||
depth + sphereRadius > frustum.far
|
||||
)
|
||||
return null;
|
||||
return true;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check the unchanged reset destination without moving the live camera. */
|
||||
function globeResetFitsKeyhole() {
|
||||
try {
|
||||
const camera = Cesium.Camera.clone(_viewer.camera);
|
||||
const { longitude, latitude } = _viewer.camera.positionCartographic;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
longitude,
|
||||
latitude,
|
||||
GLOBE_VIEW.heightM,
|
||||
),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||
roll: 0,
|
||||
},
|
||||
endTransform: Cesium.Matrix4.IDENTITY,
|
||||
});
|
||||
return globeRotationFitsKeyhole(camera) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Release only globe rotation when a new camera intent takes ownership. */
|
||||
export function interruptGlobeRotation(reason = 'camera-intent') {
|
||||
if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason);
|
||||
return { wasActive: false, reason, leveled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the module to the viewer once (idempotent). The view-target getter is
|
||||
* optional so a non-voice caller (the Directions FLY chip) can arm route
|
||||
@@ -1218,24 +1391,42 @@ function onTick() {
|
||||
* call brings none, so a callback from the old viewer can never answer for the
|
||||
* new one.
|
||||
*/
|
||||
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
|
||||
export function initCameraVerbs(
|
||||
viewer,
|
||||
getViewTargetCartesian = null,
|
||||
{ isCockpitActive } = {},
|
||||
) {
|
||||
const nextTarget =
|
||||
typeof getViewTargetCartesian === 'function'
|
||||
? getViewTargetCartesian
|
||||
: null;
|
||||
if (_viewer === viewer) {
|
||||
if (nextTarget) _getTarget = nextTarget;
|
||||
if (typeof isCockpitActive === 'function')
|
||||
_isCockpitActive = isCockpitActive;
|
||||
return;
|
||||
}
|
||||
// A globe motion belongs to the viewer whose world pose it captured.
|
||||
if (_active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('viewer-replaced');
|
||||
_getTarget = nextTarget;
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_viewer = viewer;
|
||||
if (_tickRemover) _tickRemover();
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||
// Yield on acquisition itself without changing legacy camera motions.
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
_inputRemovers = [];
|
||||
const canvas = viewer.scene.canvas;
|
||||
// ANY manual camera input reclaims control — the cancelFlight reflex.
|
||||
for (const evt of ['pointerdown', 'wheel']) {
|
||||
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
@@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
const mode = args.mode === 'continuous' ? 'continuous' : 'once';
|
||||
const mode =
|
||||
args.mode === 'continuous' ||
|
||||
(motion === 'rotate_globe' && args.mode === undefined)
|
||||
? 'continuous'
|
||||
: 'once';
|
||||
if (motion === 'stop') {
|
||||
const wasActiveBeforeHandoff = Boolean(_active);
|
||||
const stop = () => {
|
||||
@@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
|
||||
}
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) {
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`,
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
@@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
@@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
motion === 'orbit' &&
|
||||
_viewer?.trackedEntity &&
|
||||
typeof runNavigation !== 'function'
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
_viewer?.trackedEntity
|
||||
) {
|
||||
// The follow camera owns a tracked view; a lookAt orbit fights it frame
|
||||
// by frame and stop rips the camera out (field finding). Honest refusal
|
||||
@@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`,
|
||||
error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`,
|
||||
};
|
||||
}
|
||||
let globePose;
|
||||
if (motion === 'rotate_globe') {
|
||||
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'Globe rotation requires a 3D view.',
|
||||
};
|
||||
}
|
||||
const cam = _viewer.camera;
|
||||
globePose = {
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()),
|
||||
right: Cesium.Cartesian3.clone(cam.rightWC),
|
||||
};
|
||||
if (_isCockpitActive?.())
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'camera-owned',
|
||||
error: 'Exit Cockpit before rotating the globe.',
|
||||
};
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
if (!(
|
||||
Number.isFinite(ellipsoid.minimumRadius) &&
|
||||
ellipsoid.minimumRadius > 0 &&
|
||||
Number.isFinite(ellipsoid.maximumRadius) &&
|
||||
ellipsoid.maximumRadius >= ellipsoid.minimumRadius &&
|
||||
Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position))
|
||||
))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error:
|
||||
'Globe rotation requires a finite supported ellipsoid and a clear globe view.',
|
||||
};
|
||||
const fits = globeRotationFitsKeyhole();
|
||||
if (fits === null)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsupported-view',
|
||||
error: 'Globe rotation requires a supported perspective view.',
|
||||
};
|
||||
if (!fits) {
|
||||
const canReset = globeResetFitsKeyhole();
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable',
|
||||
error: canReset
|
||||
? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’'
|
||||
: 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.',
|
||||
};
|
||||
}
|
||||
if (!globeRotationHasClearance(globePose.position))
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
reason: 'unsafe-ellipsoid',
|
||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||
};
|
||||
}
|
||||
const start = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
const state = {
|
||||
@@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
const cam = _viewer.camera;
|
||||
cam.cancelFlight();
|
||||
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
// Handoff may level a route's bank. Keep the caller's exact world pose.
|
||||
// Copy the basis directly: setView's heading/pitch conversion is singular
|
||||
// at nadir and can change the captured orientation near the poles.
|
||||
Cesium.Cartesian3.clone(globePose.position, cam.position);
|
||||
Cesium.Cartesian3.clone(globePose.direction, cam.direction);
|
||||
Cesium.Cartesian3.clone(globePose.up, cam.up);
|
||||
Cesium.Cartesian3.clone(globePose.right, cam.right);
|
||||
state.rotationAxis = globePose.up;
|
||||
state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position);
|
||||
}
|
||||
if (motion === 'orbit') {
|
||||
if (_viewer.scene.tweens.length > 0) {
|
||||
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
|
||||
@@ -1458,8 +1746,13 @@ export function flyRoute(
|
||||
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
|
||||
? String(args.speed || 'normal').toLowerCase()
|
||||
: 'normal';
|
||||
// A route hidden from Draw's list is not on screen, so it is not flown.
|
||||
const routes = (annoList || []).filter(
|
||||
(a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2,
|
||||
(a) =>
|
||||
a.type === 'route' &&
|
||||
!a.hidden &&
|
||||
Array.isArray(a.path) &&
|
||||
a.path.length >= 2,
|
||||
);
|
||||
if (!routes.length) {
|
||||
return {
|
||||
|
||||
@@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({
|
||||
* @param {Object} [after={}] Ownership immediately after the await.
|
||||
* @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership.
|
||||
*/
|
||||
export function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
function mergeCctvEnableOwnership(before = {}, after = {}) {
|
||||
return {
|
||||
trackedEntity: before.trackedEntity || after.trackedEntity || null,
|
||||
cockpitActive: !!(before.cockpitActive || after.cockpitActive),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
runCctvLayerEnableFocus,
|
||||
runCctvLayerEnableTransition,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
CCTV_WORLD_CLICK_FOCUS_DURATION_SEC,
|
||||
CCTV_FOCUS_REQUEST_EVENT,
|
||||
|
||||
+141
-84
@@ -379,15 +379,23 @@ function drawMoonHaze(ctx, cx, cy, radius, angle) {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Keys each ring's overlay, so a new ring never reuses an old one's canvases.
|
||||
let rings = 0;
|
||||
|
||||
/**
|
||||
* Screen-space ring and celestial direction overlay for the full-globe view.
|
||||
* The overlay is presented to the celestial ring store, whose view renders it
|
||||
* inside the viewer's container and hands back the two canvases this paints.
|
||||
*/
|
||||
export class CelestialRing {
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @param {{enabled?:boolean,onAutoDisable?:Function}} [options]
|
||||
* @param {{enabled?:boolean,onAutoDisable?:Function,store:object}} options
|
||||
* `store` is the celestial ring store (src/ui/stores/celestialRing.js);
|
||||
* the keyhole helpers here stay free of Vue for the packages that use
|
||||
* them.
|
||||
*/
|
||||
constructor(viewer, { enabled = true, onAutoDisable = null } = {}) {
|
||||
constructor(viewer, { enabled = true, onAutoDisable = null, store }) {
|
||||
this.viewer = viewer;
|
||||
this.enabled = !!enabled;
|
||||
this.visible = false;
|
||||
@@ -406,6 +414,10 @@ export class CelestialRing {
|
||||
this._sunRenderKey = '';
|
||||
this._moonRenderKey = '';
|
||||
this._outlineRenderKey = '';
|
||||
this._sunCanvas = null;
|
||||
this._sunCtx = null;
|
||||
this._moonCanvas = null;
|
||||
this._moonCtx = null;
|
||||
|
||||
this._sunInertial = new Cesium.Cartesian3();
|
||||
this._moonInertial = new Cesium.Cartesian3();
|
||||
@@ -415,7 +427,7 @@ export class CelestialRing {
|
||||
this._screenCenter = new Cesium.Cartesian2();
|
||||
this._fixedMatrix = new Cesium.Matrix3();
|
||||
|
||||
this._buildDOM();
|
||||
this._initView(store);
|
||||
this._removePostRender = viewer.scene.postRender.addEventListener(() =>
|
||||
this._draw(),
|
||||
);
|
||||
@@ -436,48 +448,68 @@ export class CelestialRing {
|
||||
this.setEnabled(this.enabled);
|
||||
}
|
||||
|
||||
/** Construct cached effect layers and Material Symbols celestial markers. */
|
||||
_buildDOM() {
|
||||
this._root = document.createElement('div');
|
||||
this._root.id = 'celestial-ring-overlay';
|
||||
this._root.className = 'celestial-ring-overlay';
|
||||
this._root.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this._ringOutline = document.createElement('div');
|
||||
this._ringOutline.className = 'celestial-ring-outline';
|
||||
|
||||
this._sunCanvas = document.createElement('canvas');
|
||||
this._sunCanvas.className = 'celestial-ring-canvas celestial-sun-canvas';
|
||||
this._sunCtx = this._sunCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true,
|
||||
/** Present the overlay and take the canvases its view hands back. */
|
||||
_initView(store) {
|
||||
const owner = {};
|
||||
this._store = store;
|
||||
this._owner = owner;
|
||||
this._view = {
|
||||
id: ++rings,
|
||||
owner,
|
||||
container: this.viewer.container,
|
||||
enabled: this.enabled,
|
||||
visible: false,
|
||||
globeVisible: null,
|
||||
ephemerisUpdates: null,
|
||||
size: null,
|
||||
outline: null,
|
||||
sun: null,
|
||||
moon: null,
|
||||
sunMarker: null,
|
||||
moonMarker: null,
|
||||
};
|
||||
this._viewKey = '';
|
||||
store.publish(this._view);
|
||||
store.connect({
|
||||
attach: (from, kind, canvas) => {
|
||||
if (from === owner && this._shown()) this._attach(kind, canvas);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this._moonCanvas = document.createElement('canvas');
|
||||
this._moonCanvas.className = 'celestial-ring-canvas celestial-moon-canvas';
|
||||
this._moonCtx = this._moonCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true,
|
||||
});
|
||||
/** Whether the store still shows this ring's overlay. */
|
||||
_shown() {
|
||||
return this._store.ring?.owner === this._owner;
|
||||
}
|
||||
|
||||
this._sunMarker = document.createElement('span');
|
||||
this._sunMarker.className =
|
||||
'celestial-marker celestial-sun material-symbols-outlined';
|
||||
this._sunMarker.textContent = 'light_mode';
|
||||
|
||||
this._moonMarker = document.createElement('span');
|
||||
this._moonMarker.className =
|
||||
'celestial-marker celestial-moon material-symbols-outlined';
|
||||
this._moonMarker.textContent = 'dark_mode';
|
||||
|
||||
this._root.append(
|
||||
this._ringOutline,
|
||||
this._sunCanvas,
|
||||
this._moonCanvas,
|
||||
this._sunMarker,
|
||||
this._moonMarker,
|
||||
/** Publish a change to the overlay, skipping one that changes nothing. */
|
||||
_present(patch) {
|
||||
if (!this._shown()) return;
|
||||
const next = { ...this._view, ...patch };
|
||||
const key = JSON.stringify(next, (name, value) =>
|
||||
name === 'owner' || name === 'container' ? undefined : value,
|
||||
);
|
||||
this.viewer.container.appendChild(this._root);
|
||||
if (key === this._viewKey) return;
|
||||
this._view = next;
|
||||
this._viewKey = key;
|
||||
this._store.publish(next);
|
||||
}
|
||||
|
||||
/** Take (or, with null, drop) the canvas the view rendered for one layer. */
|
||||
_attach(kind, canvas) {
|
||||
const ctx =
|
||||
canvas?.getContext('2d', { alpha: true, desynchronized: true }) ?? null;
|
||||
// A new canvas starts blank, so the next frame repaints its layer.
|
||||
if (kind === 'sun') {
|
||||
this._sunCanvas = canvas;
|
||||
this._sunCtx = ctx;
|
||||
this._sunRenderKey = '';
|
||||
} else if (kind === 'moon') {
|
||||
this._moonCanvas = canvas;
|
||||
this._moonCtx = ctx;
|
||||
this._moonRenderKey = '';
|
||||
} else return;
|
||||
if (canvas && this.enabled) governorRequestRender('celestial-ring');
|
||||
}
|
||||
|
||||
/** Enable or disable the user preference for the effect. */
|
||||
@@ -491,12 +523,14 @@ export class CelestialRing {
|
||||
// all. Camera motion covers every later repaint; the 60 s ephemeris
|
||||
// timer requests its own. (perf wave 2 fix — owner playtest finding)
|
||||
if (this.enabled !== wasEnabled) governorRequestRender('celestial-ring');
|
||||
this._root.classList.toggle('disabled', !this.enabled);
|
||||
if (!this.enabled) {
|
||||
this.visible = false;
|
||||
this._root.classList.remove('visible');
|
||||
this._clear();
|
||||
}
|
||||
this._present({
|
||||
enabled: this.enabled,
|
||||
...(!this.enabled && { visible: false }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the current camera already frames the complete globe inside the keyhole. */
|
||||
@@ -577,7 +611,7 @@ export class CelestialRing {
|
||||
]) {
|
||||
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
this._ringOutline?.style.setProperty('display', 'none');
|
||||
this._present({ outline: { ...this._view.outline, hidden: true } });
|
||||
this._sunRenderKey = '';
|
||||
this._moonRenderKey = '';
|
||||
this._outlineRenderKey = '';
|
||||
@@ -607,10 +641,9 @@ export class CelestialRing {
|
||||
canvas.width = bw;
|
||||
canvas.height = bh;
|
||||
}
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
this._present({ size: { width, height } });
|
||||
this._renderScale = dpr;
|
||||
}
|
||||
|
||||
@@ -665,8 +698,8 @@ export class CelestialRing {
|
||||
);
|
||||
}
|
||||
|
||||
/** Position one icon-library marker along the keyhole circumference. */
|
||||
_positionMarker(marker, cx, cy, radius, angle, opacity) {
|
||||
/** Where one icon-library marker sits along the keyhole circumference. */
|
||||
_markerPosition(cx, cy, radius, angle, opacity) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const margin = 14;
|
||||
@@ -679,20 +712,26 @@ export class CelestialRing {
|
||||
? (cy - margin) / Math.abs(sin)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const markerRadius = Math.min(radius, maxRadiusX, maxRadiusY);
|
||||
marker.style.left = `${cx + cos * markerRadius}px`;
|
||||
marker.style.top = `${cy + sin * markerRadius}px`;
|
||||
marker.style.opacity = String(opacity);
|
||||
return {
|
||||
left: cx + cos * markerRadius,
|
||||
top: cy + sin * markerRadius,
|
||||
opacity: String(opacity),
|
||||
};
|
||||
}
|
||||
|
||||
/** Update the cached effect geometry only when camera distance or viewport changes. */
|
||||
_renderEffectLayers(cx, cy, radius, rayInnerRadius) {
|
||||
const outlineKey = `${cx}:${cy}:${radius}`;
|
||||
if (outlineKey !== this._outlineRenderKey) {
|
||||
this._ringOutline.style.display = '';
|
||||
this._ringOutline.style.left = `${cx - radius}px`;
|
||||
this._ringOutline.style.top = `${cy - radius}px`;
|
||||
this._ringOutline.style.width = `${radius * 2}px`;
|
||||
this._ringOutline.style.height = `${radius * 2}px`;
|
||||
this._present({
|
||||
outline: {
|
||||
hidden: false,
|
||||
left: cx - radius,
|
||||
top: cy - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
},
|
||||
});
|
||||
this._outlineRenderKey = outlineKey;
|
||||
}
|
||||
|
||||
@@ -724,7 +763,11 @@ export class CelestialRing {
|
||||
|
||||
/** Per-frame camera projection with GPU-composited cached effect layers. */
|
||||
_draw() {
|
||||
if (!this.enabled || !this._sunCtx || !this._moonCtx) return;
|
||||
// A ring the store no longer shows has handed its overlay to a later one.
|
||||
if (!this.enabled || !this._shown()) return;
|
||||
// Without its canvases (no view rendered them) the ring still tracks
|
||||
// whether the globe is framed, so it turns itself off as before.
|
||||
const painted = !!(this._sunCtx && this._moonCtx);
|
||||
const now = performance.now();
|
||||
if (now < this._nextDrawAt) return;
|
||||
this._nextDrawAt = now + 1000 / CELESTIAL_MAX_FRAME_RATE;
|
||||
@@ -732,7 +775,7 @@ export class CelestialRing {
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
if (!(width > 0 && height > 0)) return;
|
||||
this._resize(width, height);
|
||||
if (painted) this._resize(width, height);
|
||||
|
||||
const disc = this._projectedEarthDisc(width, height);
|
||||
const wasVisible = this.visible;
|
||||
@@ -740,9 +783,11 @@ export class CelestialRing {
|
||||
? isFullGlobeInsideKeyhole(disc, wasVisible)
|
||||
: false;
|
||||
this.visible = nextVisible;
|
||||
this._root.classList.toggle('visible', nextVisible);
|
||||
this._root.dataset.globeVisible = String(nextVisible);
|
||||
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
|
||||
this._present({
|
||||
visible: nextVisible,
|
||||
globeVisible: String(nextVisible),
|
||||
ephemerisUpdates: String(this._ephemerisUpdateCount),
|
||||
});
|
||||
if (!nextVisible || !disc) {
|
||||
this._clear();
|
||||
this._debug = { enabled: true, visible: false, disc };
|
||||
@@ -751,10 +796,14 @@ export class CelestialRing {
|
||||
}
|
||||
|
||||
if (!wasVisible) this._ephemerisDirty = true;
|
||||
if (!painted) {
|
||||
this._debug = { enabled: true, visible: true, disc };
|
||||
return;
|
||||
}
|
||||
|
||||
const time = Cesium.JulianDate.now();
|
||||
if (!this._updateEphemeris(time)) return;
|
||||
this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount);
|
||||
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
|
||||
const camera = this.viewer.camera;
|
||||
const sunProjection = celestialScreenAngle(
|
||||
Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC),
|
||||
@@ -779,10 +828,6 @@ export class CelestialRing {
|
||||
disc.earthRadius + Math.max(38, radius * 0.065),
|
||||
);
|
||||
this._renderEffectLayers(cx, cy, radius, rayInnerRadius);
|
||||
this._sunCanvas.style.transform = `rotate(${this._sunAngle}rad)`;
|
||||
this._sunCanvas.style.opacity = String(this._sunOpacity);
|
||||
this._moonCanvas.style.transform = `rotate(${this._moonAngle}rad)`;
|
||||
this._moonCanvas.style.opacity = String(this._moonOpacity);
|
||||
|
||||
// Markers sit inside the illuminated band rather than straddling its outer
|
||||
// stroke. Near conjunction (such as a new moon), keep the true bearings but
|
||||
@@ -791,22 +836,30 @@ export class CelestialRing {
|
||||
const markersCollide =
|
||||
circularAngleDistance(this._sunAngle, this._moonAngle) <
|
||||
MARKER_COLLISION_ANGLE;
|
||||
this._positionMarker(
|
||||
this._sunMarker,
|
||||
cx,
|
||||
cy,
|
||||
markerRadius,
|
||||
this._sunAngle,
|
||||
this._sunOpacity,
|
||||
);
|
||||
this._positionMarker(
|
||||
this._moonMarker,
|
||||
cx,
|
||||
cy,
|
||||
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
|
||||
this._moonAngle,
|
||||
this._moonOpacity,
|
||||
);
|
||||
this._present({
|
||||
sun: {
|
||||
transform: `rotate(${this._sunAngle}rad)`,
|
||||
opacity: String(this._sunOpacity),
|
||||
},
|
||||
moon: {
|
||||
transform: `rotate(${this._moonAngle}rad)`,
|
||||
opacity: String(this._moonOpacity),
|
||||
},
|
||||
sunMarker: this._markerPosition(
|
||||
cx,
|
||||
cy,
|
||||
markerRadius,
|
||||
this._sunAngle,
|
||||
this._sunOpacity,
|
||||
),
|
||||
moonMarker: this._markerPosition(
|
||||
cx,
|
||||
cy,
|
||||
markerRadius - (markersCollide ? COLLIDING_MARKER_EXTRA_INSET_PX : 0),
|
||||
this._moonAngle,
|
||||
this._moonOpacity,
|
||||
),
|
||||
});
|
||||
this._debug = {
|
||||
enabled: true,
|
||||
visible: true,
|
||||
@@ -831,7 +884,7 @@ export class CelestialRing {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Detach the render hook and remove all overlay DOM. */
|
||||
/** Detach the render hook and withdraw the overlay. */
|
||||
destroy() {
|
||||
if (this._ephemerisTimer) {
|
||||
clearInterval(this._ephemerisTimer);
|
||||
@@ -839,6 +892,10 @@ export class CelestialRing {
|
||||
}
|
||||
if (this._removePostRender) this._removePostRender();
|
||||
this._removePostRender = null;
|
||||
this._root?.remove();
|
||||
if (this._shown()) {
|
||||
this._store.connect(null);
|
||||
this._store.publish(null);
|
||||
}
|
||||
this._sunCanvas = this._sunCtx = this._moonCanvas = this._moonCtx = null;
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => {
|
||||
|
||||
test('celestial ring is available only in Normal style', () => {
|
||||
assert.equal(isCelestialRingStyleSupported('normal'), true);
|
||||
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) {
|
||||
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir']) {
|
||||
assert.equal(isCelestialRingStyleSupported(style), false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/celestialRingView.test.mjs — the full-globe ring renders in the
|
||||
// viewer's container from what the ring owner presents, and the owner paints
|
||||
// the canvases the view hands back.
|
||||
import './testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useCelestialRingStore } from './ui/stores/celestialRing.js';
|
||||
import CelestialRingView from './ui/views/panels/CelestialRing.vue';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CelestialRing } from './celestialRing.js';
|
||||
|
||||
// happy-dom has no 2D context; a stand-in takes every drawing call.
|
||||
const context = new Proxy(
|
||||
{},
|
||||
{ get: (target, name) => target[name] ?? (() => context), set: () => true },
|
||||
);
|
||||
globalThis.HTMLCanvasElement.prototype.getContext = () => context;
|
||||
|
||||
function fakeViewer(container) {
|
||||
return {
|
||||
container,
|
||||
camera: {
|
||||
rightWC: new Cesium.Cartesian3(1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 1, 0),
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 400, clientHeight: 300, width: 400, height: 300 },
|
||||
postRender: { addEventListener: () => () => {} },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const store = useCelestialRingStore(pinia);
|
||||
const app = createApp(CelestialRingView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
const rings = [];
|
||||
const create = (options = {}) => {
|
||||
const ring = new CelestialRing(fakeViewer(container), {
|
||||
enabled: false,
|
||||
store,
|
||||
...options,
|
||||
});
|
||||
rings.push(ring);
|
||||
return ring;
|
||||
};
|
||||
t.after(() => {
|
||||
for (const ring of rings) ring.destroy();
|
||||
app.unmount();
|
||||
});
|
||||
const $ = (selector) => container.querySelector(selector);
|
||||
const settle = async () => {
|
||||
for (let flush = 0; flush < 4; flush += 1) await nextTick();
|
||||
};
|
||||
return { container, store, create, $, settle };
|
||||
}
|
||||
|
||||
test('the ring renders its overlay in the viewer container', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
const ring = create();
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.equal(root.className, 'celestial-ring-overlay disabled');
|
||||
assert.equal(root.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(root.hasAttribute('data-globe-visible'), false);
|
||||
assert.deepEqual(
|
||||
[...root.children].map((child) => [child.tagName, child.className]),
|
||||
[
|
||||
['DIV', 'celestial-ring-outline'],
|
||||
['CANVAS', 'celestial-ring-canvas celestial-sun-canvas'],
|
||||
['CANVAS', 'celestial-ring-canvas celestial-moon-canvas'],
|
||||
['SPAN', 'celestial-marker celestial-sun material-symbols-outlined'],
|
||||
['SPAN', 'celestial-marker celestial-moon material-symbols-outlined'],
|
||||
],
|
||||
);
|
||||
assert.equal(root.children[0].style.display, 'none');
|
||||
assert.equal(root.children[3].textContent, 'light_mode');
|
||||
assert.equal(root.children[4].textContent, 'dark_mode');
|
||||
assert.ok(ring._sunCanvas === root.children[1]);
|
||||
assert.ok(ring._moonCanvas === root.children[2]);
|
||||
ring.setEnabled(true);
|
||||
await settle();
|
||||
assert.equal(root.className, 'celestial-ring-overlay');
|
||||
ring.destroy();
|
||||
await settle();
|
||||
assert.equal($('#celestial-ring-overlay'), null);
|
||||
assert.equal(ring._sunCanvas, null);
|
||||
});
|
||||
|
||||
test('a drawn frame places the outline, layers and markers', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
const ring = create({ enabled: true });
|
||||
await settle();
|
||||
ring._present({ visible: true, globeVisible: 'true', ephemerisUpdates: '1' });
|
||||
ring._resize(400, 300);
|
||||
ring._renderEffectLayers(200, 150, 120, 80);
|
||||
ring._present({
|
||||
sun: { transform: 'rotate(1rad)', opacity: '0.5' },
|
||||
sunMarker: ring._markerPosition(200, 150, 100, 0, 1),
|
||||
});
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.equal(root.className, 'celestial-ring-overlay visible');
|
||||
assert.equal(root.dataset.globeVisible, 'true');
|
||||
assert.equal(root.dataset.ephemerisUpdates, '1');
|
||||
const [outline, sun, moon, sunMarker] = root.children;
|
||||
assert.equal(outline.style.display, '');
|
||||
assert.equal(outline.style.left, '80px');
|
||||
assert.equal(outline.style.width, '240px');
|
||||
assert.equal(sun.style.width, '400px');
|
||||
assert.equal(sun.style.transform, 'rotate(1rad)');
|
||||
assert.equal(sun.style.opacity, '0.5');
|
||||
assert.equal(moon.style.height, '300px');
|
||||
assert.equal(sunMarker.style.left, '300px');
|
||||
assert.equal(sunMarker.style.top, '150px');
|
||||
ring.setEnabled(false);
|
||||
await settle();
|
||||
assert.equal(root.className, 'celestial-ring-overlay disabled');
|
||||
assert.equal(outline.style.display, 'none');
|
||||
});
|
||||
|
||||
test("an earlier ring leaves a later ring's overlay alone", async (t) => {
|
||||
const { create, store, $, settle } = fixture(t);
|
||||
const earlier = create();
|
||||
await settle();
|
||||
const later = create();
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.ok(later._sunCanvas === root.children[1]);
|
||||
earlier.setEnabled(true);
|
||||
earlier.destroy();
|
||||
await settle();
|
||||
assert.ok(store.ring.owner === later._owner);
|
||||
assert.equal(
|
||||
$('#celestial-ring-overlay').className,
|
||||
'celestial-ring-overlay disabled',
|
||||
);
|
||||
assert.ok(later._sunCanvas === $('.celestial-sun-canvas'));
|
||||
});
|
||||
|
||||
// A globe framed well inside the keyhole of a 400 x 300 view.
|
||||
const FRAMED = {
|
||||
earthCenterX: 200,
|
||||
earthCenterY: 150,
|
||||
earthRadius: 40,
|
||||
keyholeCenterX: 200,
|
||||
keyholeCenterY: 150,
|
||||
keyholeRadius: 140,
|
||||
};
|
||||
|
||||
/** Run one rendered frame, past the frame-rate throttle. */
|
||||
function frame(ring, disc) {
|
||||
ring._projectedEarthDisc = () => disc;
|
||||
// Fixed sun and moon directions; Cesium's own model would fetch its
|
||||
// orientation data.
|
||||
ring._updateEphemeris = () => {
|
||||
if (ring._ephemerisDirty) {
|
||||
Cesium.Cartesian3.clone(new Cesium.Cartesian3(1, 0, 0), ring._sunFixed);
|
||||
Cesium.Cartesian3.clone(new Cesium.Cartesian3(0, 1, 0), ring._moonFixed);
|
||||
ring._ephemerisDirty = false;
|
||||
ring._ephemerisUpdateCount += 1;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
ring._nextDrawAt = 0;
|
||||
ring._draw();
|
||||
}
|
||||
|
||||
test('a rendered frame publishes the layers and markers it painted', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
const ring = create({ enabled: true });
|
||||
await settle();
|
||||
frame(ring, FRAMED);
|
||||
await settle();
|
||||
const root = $('#celestial-ring-overlay');
|
||||
assert.equal(root.className, 'celestial-ring-overlay visible');
|
||||
assert.equal(root.dataset.globeVisible, 'true');
|
||||
assert.equal(root.dataset.ephemerisUpdates, '1');
|
||||
const [outline, sun, moon, sunMarker, moonMarker] = root.children;
|
||||
const debug = ring.getDebugState();
|
||||
assert.equal(debug.visible, true);
|
||||
assert.equal(outline.style.display, '');
|
||||
// The outline is the keyhole circle, a fixed inset inside its radius.
|
||||
const diameter = parseFloat(outline.style.width);
|
||||
assert.ok(diameter > 0 && diameter < 280);
|
||||
assert.equal(outline.style.height, outline.style.width);
|
||||
assert.equal(outline.style.left, `${200 - diameter / 2}px`);
|
||||
assert.equal(outline.style.top, `${150 - diameter / 2}px`);
|
||||
assert.equal(sun.style.width, '400px');
|
||||
assert.equal(moon.style.height, '300px');
|
||||
assert.equal(sun.style.transform, `rotate(${debug.sunAngle}rad)`);
|
||||
assert.equal(moon.style.transform, `rotate(${debug.moonAngle}rad)`);
|
||||
assert.equal(sun.style.opacity, String(debug.sunOpacity));
|
||||
assert.equal(moon.style.opacity, String(debug.moonOpacity));
|
||||
assert.ok(sunMarker.style.left.endsWith('px'));
|
||||
assert.ok(moonMarker.style.top.endsWith('px'));
|
||||
assert.equal(moonMarker.style.opacity, String(debug.moonOpacity));
|
||||
});
|
||||
|
||||
test('a frame without the globe framed turns the ring off', async (t) => {
|
||||
const { create, $, settle } = fixture(t);
|
||||
let disabled = 0;
|
||||
const ring = create({ enabled: true, onAutoDisable: () => (disabled += 1) });
|
||||
await settle();
|
||||
frame(ring, FRAMED);
|
||||
frame(ring, null);
|
||||
await settle();
|
||||
assert.equal(ring.enabled, false);
|
||||
assert.equal(disabled, 1);
|
||||
assert.equal(
|
||||
$('#celestial-ring-overlay').className,
|
||||
'celestial-ring-overlay disabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('without a rendered view the ring still tracks the globe and turns off', () => {
|
||||
const pinia = createPinia();
|
||||
let disabled = 0;
|
||||
const ring = new CelestialRing(fakeViewer({}), {
|
||||
enabled: true,
|
||||
store: useCelestialRingStore(pinia),
|
||||
onAutoDisable: () => (disabled += 1),
|
||||
});
|
||||
frame(ring, FRAMED);
|
||||
assert.equal(ring.visible, true);
|
||||
frame(ring, null);
|
||||
assert.equal(ring.enabled, false);
|
||||
assert.equal(disabled, 1);
|
||||
ring.destroy();
|
||||
});
|
||||
|
||||
test('a ring the store no longer shows stops drawing', async (t) => {
|
||||
const { create, settle } = fixture(t);
|
||||
let disabled = 0;
|
||||
const earlier = create({
|
||||
enabled: true,
|
||||
onAutoDisable: () => (disabled += 1),
|
||||
});
|
||||
await settle();
|
||||
create();
|
||||
await settle();
|
||||
frame(earlier, null);
|
||||
assert.equal(earlier.enabled, true);
|
||||
assert.equal(disabled, 0);
|
||||
});
|
||||
@@ -174,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
|
||||
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
|
||||
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
|
||||
*/
|
||||
export class CockpitCloudEffectsController {
|
||||
class CockpitCloudEffectsController {
|
||||
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
|
||||
this.weatherService = weatherService;
|
||||
this.viewer = viewer;
|
||||
|
||||
+124
-87
@@ -2,7 +2,7 @@ import {
|
||||
readShellSource,
|
||||
shellMethod,
|
||||
} from './testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../build/application-html.js';
|
||||
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { PanelLayoutController } from './ui/panelLayoutController.js';
|
||||
import { readShellElements } from './ui/shellElements.js';
|
||||
@@ -16,14 +16,20 @@ import {
|
||||
readAircraftInfo,
|
||||
_adoptTrackedEntity,
|
||||
} from './ui/cockpitTrackingController.js';
|
||||
import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js';
|
||||
import { cycleVisionMode } from './ui/cockpitInstruments.js';
|
||||
import { createVisionState } from './ui/cockpitInstrumentState.js';
|
||||
import { COCKPIT_VISION_MODES } from './cockpitVisionPolicy.js';
|
||||
import { updateContext as cockpitContext } from './ui/cockpitContext.js';
|
||||
import {
|
||||
syncSignalLayout,
|
||||
setContextCollapsed,
|
||||
setSignalCollapsed,
|
||||
} from './ui/cockpitLayout.js';
|
||||
import { setBriefAutoRotate } from './ui/cockpitBriefing.js';
|
||||
import { createBriefState } from './ui/cockpitBriefPages.js';
|
||||
import {
|
||||
publishBriefView,
|
||||
setBriefAutoRotate,
|
||||
} from './ui/cockpitBriefing.js';
|
||||
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
|
||||
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
@@ -46,7 +52,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const html = expandApplicationHtml(
|
||||
const html = await renderApplicationHtml(
|
||||
fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'),
|
||||
);
|
||||
const ui = readShellSource();
|
||||
@@ -149,11 +155,10 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
cycleVisionMode.toString(),
|
||||
/const modes = COCKPIT_VISION_MODES;/,
|
||||
);
|
||||
assert.match(
|
||||
setVisionMode.toString(),
|
||||
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),\s*snow: t\('SNOW'\),?\s*\};/,
|
||||
assert.deepEqual(
|
||||
COCKPIT_VISION_MODES.map((mode) => createVisionState(mode).label),
|
||||
['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR'],
|
||||
);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(
|
||||
@@ -227,14 +232,10 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
||||
);
|
||||
});
|
||||
|
||||
test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => {
|
||||
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
|
||||
const keydown = cockpitKeyDown.toString();
|
||||
assert.ok(keydown, 'Cockpit keyboard handler is missing');
|
||||
assert.match(
|
||||
keydown,
|
||||
/document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/,
|
||||
);
|
||||
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
|
||||
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
|
||||
assert.match(
|
||||
keydown,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
@@ -250,10 +251,10 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
|
||||
assert.match(
|
||||
body,
|
||||
/if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/,
|
||||
/if \(!readout\.visible\) \{[\s\S]*?this\.showContextCard\(false\);/,
|
||||
);
|
||||
assert.equal(
|
||||
(body.match(/this\.context\.hidden = true/g) || []).length,
|
||||
(body.match(/this\.showContextCard\(false\)/g) || []).length,
|
||||
1,
|
||||
'the panel has exactly one hide path, and it is the no-snapshot standby case',
|
||||
);
|
||||
@@ -268,7 +269,7 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
// A culled subject holds last-known content behind the CONTACT LOST cue.
|
||||
assert.match(body, /readout\.contactLost/);
|
||||
assert.match(body, /CONTACT LOST/);
|
||||
assert.match(body, /this\.context\.dataset\.state = 'lost'/);
|
||||
assert.match(body, /view\.state = 'lost'/);
|
||||
assert.match(
|
||||
body,
|
||||
/if \(readout\.contactLost\) \{/,
|
||||
@@ -276,14 +277,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
);
|
||||
// PREVIOUS/NEXT must be written before any early return, so the operator can
|
||||
// always step off the current contact — including a lost one.
|
||||
const navIndex = body.indexOf('this.contextNext.disabled');
|
||||
const navIndex = body.indexOf('view.canNext =');
|
||||
const lostIndex = body.indexOf('if (readout.contactLost)');
|
||||
assert.ok(
|
||||
navIndex >= 0 && lostIndex > navIndex,
|
||||
'nav state must be written before the CONTACT LOST return',
|
||||
);
|
||||
assert.equal(
|
||||
(body.match(/this\.contextNext\.disabled/g) || []).length,
|
||||
(body.match(/view\.canNext =/g) || []).length,
|
||||
1,
|
||||
'one owner for the NEXT enabled state, reached by every visible path',
|
||||
);
|
||||
@@ -443,7 +444,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
|
||||
assert.doesNotMatch(
|
||||
cockpitRadio[0],
|
||||
/context-radio-details-btn|radio-filter|radio-tuner/,
|
||||
/radio-filter|radio-tuner/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
@@ -475,14 +476,6 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
css,
|
||||
/\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/,
|
||||
@@ -534,7 +527,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
);
|
||||
assert.match(
|
||||
syncSignalLayout.toString(),
|
||||
/setAttribute\('aria-hidden', String\(hiddenSibling\)\)/,
|
||||
/primaryOnly: utilityLayout\.primaryOnly,\s*hideCollapsed: utilityLayout\.primaryOnly,/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -550,9 +543,14 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below
|
||||
1,
|
||||
'Parameters must have one DOM owner',
|
||||
);
|
||||
// The markup places Parameters below Detection; nothing re-homes it.
|
||||
assert.match(
|
||||
html,
|
||||
/id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(),
|
||||
/const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/,
|
||||
/_sliderPanel/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
@@ -635,17 +633,22 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
|
||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);\s*this\.actions\.setPanelCollapsed\('utilities-panel', true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
'closing Data Layers must restore Contact only after an automatic collapse',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/const COCKPIT_LEFT_PANEL_IDS = new Set\(\['data-panel', 'utilities-panel'\]\);/,
|
||||
'Utilities folds Contact away in Cockpit as Data Layers does',
|
||||
);
|
||||
assert.match(
|
||||
setContextCollapsed.toString(),
|
||||
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
|
||||
@@ -680,6 +683,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
for (const panelId of [
|
||||
'data-panel',
|
||||
'cctv-panel',
|
||||
'utilities-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
@@ -703,7 +707,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
);
|
||||
assert.match(
|
||||
callback[1],
|
||||
/_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/,
|
||||
/_cockpitPanelRestore\.set\(\s*panelId,\s*this\._shownPanel\(panel\)\.collapsed,?\s*\)/,
|
||||
"entry must remember each panel's exact collapsed state before hiding it",
|
||||
);
|
||||
assert.match(
|
||||
@@ -764,7 +768,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
|
||||
);
|
||||
assert.match(
|
||||
collapse,
|
||||
/classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
|
||||
/this\._shownPanel\(panelEl\)\.collapsed === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/,
|
||||
);
|
||||
assert.match(
|
||||
collapse,
|
||||
@@ -802,35 +806,29 @@ test('Reset releases Contact camera ownership through its selection-preserving r
|
||||
assert.ok(resetStart >= 0);
|
||||
assert.ok(contextRelease > resetStart);
|
||||
assert.ok(satelliteRelease > contextRelease);
|
||||
assert.match(
|
||||
readShellElements.toString(),
|
||||
/_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/,
|
||||
);
|
||||
const controls = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'ui', 'locationControls.js'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/,
|
||||
);
|
||||
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
|
||||
assert.match(
|
||||
controls,
|
||||
/for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
|
||||
'both reset controls delegate to the same supplied reset action',
|
||||
assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/);
|
||||
assert.match(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/);
|
||||
// Both reset buttons ask the one reset owner.
|
||||
const globeActions = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/);
|
||||
const cockpitView = readRadioSource(
|
||||
new URL('./ui/views/CockpitView.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/);
|
||||
assert.match(
|
||||
cockpitEnter.toString(),
|
||||
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/,
|
||||
);
|
||||
assert.match(
|
||||
readRadioSource(
|
||||
new URL('./ui/cockpitTrackingController.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/,
|
||||
/Object\.assign\(this\.presence, \{\s*active: true,/,
|
||||
);
|
||||
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*:hidden="!active"/);
|
||||
});
|
||||
|
||||
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
|
||||
@@ -892,21 +890,24 @@ test('Cockpit Radio station changes preserve first-person camera ownership', ()
|
||||
);
|
||||
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
|
||||
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
|
||||
assert.match(radioBindings, /\n cycleRadio,\n/);
|
||||
const panel = fs.readFileSync(
|
||||
new URL('./ui/views/panels/RadioPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const cockpit = fs.readFileSync(
|
||||
new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(panel, /id="radio-prev-btn" @click="act\('cycleRadio', -1\)"/);
|
||||
assert.match(panel, /id="radio-next-btn" @click="act\('cycleRadio', 1\)"/);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/,
|
||||
cockpit,
|
||||
/id="cockpit-radio-prev-btn" @click="act\('cycleRadio', -1, \{ rotate: false \}\)"/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/,
|
||||
cockpit,
|
||||
/id="cockpit-radio-next-btn" @click="act\('cycleRadio', 1, \{ rotate: false \}\)"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -944,7 +945,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
||||
assert.ok(signalLayout, 'Cockpit signal layout method is missing');
|
||||
assert.match(
|
||||
signalLayout,
|
||||
/setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/,
|
||||
/publishUtilityLayout\([\s\S]*?top: `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,/,
|
||||
'Cockpit owns the utility strip anchor and republishes it every layout tick',
|
||||
);
|
||||
assert.match(
|
||||
@@ -980,7 +981,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
||||
);
|
||||
assert.match(
|
||||
signalLayout,
|
||||
/cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/,
|
||||
/maxHeight: `\$\{availableHeight\.toFixed\(2\)\}px`,/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
@@ -1065,15 +1066,16 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
|
||||
assert.doesNotMatch(ui, /\['presets',/);
|
||||
assert.match(
|
||||
CockpitDisplayPortal.toString(),
|
||||
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
|
||||
/window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
|
||||
);
|
||||
// The portal publishes the slots; the Display panel teleports its groups.
|
||||
assert.match(
|
||||
CockpitDisplayPortal.prototype.setActive.toString(),
|
||||
/record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
|
||||
/this\.publish\(nextActive \? this\.slots : null\)/,
|
||||
);
|
||||
assert.match(
|
||||
CockpitDisplayPortal.prototype.destroy.toString(),
|
||||
/this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/,
|
||||
/this\.stop\(\)[\s\S]*?this\.publish\(null\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
ui,
|
||||
@@ -1095,7 +1097,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/,
|
||||
/_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?_presentPanel\(this\._sliderPanel, \{ collapsed: false \}\)/,
|
||||
);
|
||||
assert.match(
|
||||
radioBindings,
|
||||
@@ -1180,7 +1182,7 @@ test('cockpit aircraft handoff invalidates the prior world-position anchor', ()
|
||||
|
||||
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => {
|
||||
const match = html.match(
|
||||
/<button\s+id="cockpit-weather-toggle"[\s\S]*?<\/button>/,
|
||||
/<button[^>]*\sid="cockpit-weather-toggle"[\s\S]*?<\/button>/,
|
||||
);
|
||||
assert.ok(match, 'cockpit weather toggle markup is missing');
|
||||
assert.match(match[0], /aria-label="Enable cockpit weather effects"/);
|
||||
@@ -1192,7 +1194,7 @@ test('cockpit summary presents the focused item as Contact', () => {
|
||||
const match = html.match(/<aside id="cockpit-context"[\s\S]*?<\/aside>/);
|
||||
assert.ok(match, 'cockpit Contact summary is missing');
|
||||
assert.match(match[0], /aria-label="Contact cockpit summary"/);
|
||||
assert.match(match[0], /class="cockpit-context-kicker">CONTACT</);
|
||||
assert.match(match[0], /class="cockpit-card-kicker cockpit-context-kicker">CONTACT</);
|
||||
assert.match(match[0], /aria-label="Contact navigation"/);
|
||||
assert.match(
|
||||
match[0],
|
||||
@@ -1204,9 +1206,13 @@ test('cockpit summary presents the focused item as Contact', () => {
|
||||
);
|
||||
assert.match(match[0], /aria-label="Collapse Contact panel"/);
|
||||
assert.doesNotMatch(match[0], />GLOBAL CONTEXT</);
|
||||
// The toggle renders the collapse the controller presents.
|
||||
assert.match(
|
||||
setContextCollapsed.toString(),
|
||||
/expanded \? t\('Collapse Contact panel'\) : t\('Expand Contact panel'\)/,
|
||||
fs.readFileSync(
|
||||
new URL('./ui/views/panels/CockpitContext.vue', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
/collapsed \? t\('Expand Contact panel'\) : t\('Collapse Contact panel'\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1262,7 +1268,7 @@ test('Global Context standby describes both chooser modes', () => {
|
||||
|
||||
test('cockpit briefing cycle control keeps its state as the accessible name', () => {
|
||||
const match = html.match(
|
||||
/<button\s+id="cockpit-brief-auto"[\s\S]*?<\/button>/,
|
||||
/<button[^>]*\sid="cockpit-brief-auto"[\s\S]*?<\/button>/,
|
||||
);
|
||||
assert.ok(match, 'cockpit briefing cycle toggle is missing');
|
||||
assert.match(match[0], /aria-label="CYCLE OFF"/);
|
||||
@@ -1273,15 +1279,22 @@ test('cockpit briefing cycle control keeps its state as the accessible name', ()
|
||||
/title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./,
|
||||
);
|
||||
|
||||
const update = setBriefAutoRotate.toString();
|
||||
assert.ok(update, 'cockpit briefing cycle state updater is missing');
|
||||
assert.match(
|
||||
update,
|
||||
/const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/,
|
||||
);
|
||||
assert.match(update, /setAttribute\('aria-label', label\)/);
|
||||
assert.match(update, /\.title = help;/);
|
||||
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
|
||||
// The presented label is the state; the help is only the tooltip.
|
||||
const views = [];
|
||||
const owner = {
|
||||
brief: createBriefState(),
|
||||
publishBrief: (view) => views.push(view),
|
||||
publishBriefView,
|
||||
startBriefRotation() {},
|
||||
stopBriefRotation() {},
|
||||
};
|
||||
setBriefAutoRotate.call(owner, true);
|
||||
assert.equal(views.at(-1).autoLabel, 'CYCLE ON');
|
||||
assert.equal(views.at(-1).autoRotate, true);
|
||||
assert.match(views.at(-1).autoHelp, /^Stop automatic page cycling\./);
|
||||
setBriefAutoRotate.call(owner, false);
|
||||
assert.equal(views.at(-1).autoLabel, 'CYCLE OFF');
|
||||
assert.match(views.at(-1).autoHelp, /^Cycle briefing pages automatically/);
|
||||
});
|
||||
|
||||
test('voice Cockpit entry honours a requested contact layer before it enters', () => {
|
||||
@@ -1372,3 +1385,27 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
assert.match(state, /'contacts-inactive'/);
|
||||
assert.match(state, /'no-tracked-aircraft'/);
|
||||
});
|
||||
|
||||
test('Contact folded for one Cockpit left panel comes back only once neither is open', async () => {
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(!this\._cockpitContextCollapsedForDataPanel\)\s*this\._cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed;/,
|
||||
'opening the second left panel keeps the fold the first recorded',
|
||||
);
|
||||
const { PanelChrome } = await import('./ui/panelChrome.js');
|
||||
const previous = globalThis.document;
|
||||
const panels = {};
|
||||
globalThis.document = { getElementById: (id) => panels[id] ?? null };
|
||||
const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } });
|
||||
try {
|
||||
const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
|
||||
panels['data-panel'] = panel('collapsed');
|
||||
assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore');
|
||||
panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed');
|
||||
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded');
|
||||
panels['data-panel'] = panel();
|
||||
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded');
|
||||
} finally {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
});
|
||||
+14
-5
@@ -1,9 +1,18 @@
|
||||
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
|
||||
import {
|
||||
normalizeHeading,
|
||||
roundHeadingDegrees,
|
||||
} from './data/headingDegrees.js';
|
||||
|
||||
/** Normalize a heading into the [0, 360) range. */
|
||||
export function normalizeHeading(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return ((value % 360) + 360) % 360;
|
||||
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
|
||||
export { normalizeHeading };
|
||||
|
||||
/**
|
||||
* Three-digit heading readout, 000–359.
|
||||
* Rounding 359.5° and above lands on north (000), not 360.
|
||||
*/
|
||||
export function formatHeadingReadout(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '---';
|
||||
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
|
||||
}
|
||||
|
||||
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
formatCockpitContextScope,
|
||||
formatCockpitEmergency,
|
||||
formatCompassDivision,
|
||||
formatHeadingReadout,
|
||||
formatSpeedRulerTick,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
@@ -155,6 +156,15 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => {
|
||||
assert.equal(formatCompassDivision(30), '030');
|
||||
});
|
||||
|
||||
test('a heading that rounds to north reads 000, not 360', () => {
|
||||
assert.equal(formatHeadingReadout(359.6), '000');
|
||||
assert.equal(formatHeadingReadout(359.4), '359');
|
||||
assert.equal(formatHeadingReadout(0), '000');
|
||||
assert.equal(formatHeadingReadout(10.2), '010');
|
||||
assert.equal(formatHeadingReadout(-0.6), '359');
|
||||
assert.equal(formatHeadingReadout(Number.NaN), '---');
|
||||
});
|
||||
|
||||
test('great-circle bearing resolves cardinal directions', () => {
|
||||
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
|
||||
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
|
||||
|
||||
+264
-243
@@ -1,187 +1,119 @@
|
||||
// src/cockpitSignalFocus.test.mjs — the Cockpit signal rows keep keyboard
|
||||
// focus while they refresh and reorder, over happy-dom with the real view.
|
||||
import './testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach } from 'node:test';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
const previousDocument = globalThis.document;
|
||||
afterEach(() => {
|
||||
globalThis.document = previousDocument;
|
||||
});
|
||||
import { createBriefState } from './ui/cockpitBriefPages.js';
|
||||
import {
|
||||
handleSignalClick,
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
updateCockpitSignals,
|
||||
} from './ui/cockpitSignals.js';
|
||||
import { useCockpitStore } from './ui/stores/cockpit.js';
|
||||
import CockpitBriefing from './ui/views/panels/CockpitBriefing.vue';
|
||||
|
||||
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
|
||||
// object reference alone is insufficient if reconciliation disconnects it.
|
||||
function fixture() {
|
||||
const document = { activeElement: null };
|
||||
let focusLosses = 0;
|
||||
let focusCalls = 0;
|
||||
class Node {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.dataset = {};
|
||||
this.attributes = new Map();
|
||||
this.className = '';
|
||||
this._text = '';
|
||||
this.classList = {
|
||||
add: (...names) => {
|
||||
this.className = [
|
||||
...new Set([
|
||||
...this.className.split(/\s+/).filter(Boolean),
|
||||
...names,
|
||||
]),
|
||||
].join(' ');
|
||||
},
|
||||
};
|
||||
/**
|
||||
* A browser blurs an element it removes or moves. happy-dom only models the
|
||||
* removal, so count both: any insert or removal of a node that holds focus
|
||||
* is a loss, and a moved node gives its focus up as a browser's would.
|
||||
*/
|
||||
function trackFocus(t) {
|
||||
const counts = { losses: 0, focusCalls: 0 };
|
||||
const holdsFocus = (node) =>
|
||||
node.isConnected && node.contains(document.activeElement);
|
||||
const insertBefore = Node.prototype.insertBefore;
|
||||
t.mock.method(Node.prototype, 'insertBefore', function (node, anchor) {
|
||||
const lost = holdsFocus(node);
|
||||
const result = insertBefore.call(this, node, anchor);
|
||||
if (lost) {
|
||||
counts.losses += 1;
|
||||
document.activeElement.blur();
|
||||
}
|
||||
get isConnected() {
|
||||
return this === document.body || Boolean(this.parentNode?.isConnected);
|
||||
}
|
||||
get firstElementChild() {
|
||||
return this.children[0] || null;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
return (
|
||||
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
get textContent() {
|
||||
return (
|
||||
this._text + this.children.map((node) => node.textContent).join('')
|
||||
);
|
||||
}
|
||||
set textContent(value) {
|
||||
this.replaceChildren();
|
||||
this._text = String(value);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
contains(node) {
|
||||
return (
|
||||
this === node || this.children.some((child) => child.contains(node))
|
||||
);
|
||||
}
|
||||
append(...nodes) {
|
||||
for (const node of nodes) this.insertBefore(node, null);
|
||||
}
|
||||
insertBefore(node, anchor) {
|
||||
assert.ok(
|
||||
anchor === null || anchor.parentNode === this,
|
||||
'insert anchor belongs to its parent',
|
||||
);
|
||||
if (node === anchor) return node;
|
||||
node.remove();
|
||||
const index =
|
||||
anchor === null ? this.children.length : this.children.indexOf(anchor);
|
||||
this.children.splice(index, 0, node);
|
||||
node.parentNode = this;
|
||||
return node;
|
||||
}
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
if (this.contains(document.activeElement)) {
|
||||
document.activeElement = document.body;
|
||||
focusLosses += 1;
|
||||
}
|
||||
this.parentNode.children.splice(
|
||||
this.parentNode.children.indexOf(this),
|
||||
1,
|
||||
);
|
||||
this.parentNode = null;
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
for (const child of [...this.children]) child.remove();
|
||||
this._text = '';
|
||||
this.append(...nodes);
|
||||
}
|
||||
matches(selector) {
|
||||
if (selector === 'button[data-signal-layer][data-signal-id]') {
|
||||
return (
|
||||
this.tagName === 'BUTTON' &&
|
||||
this.dataset.signalLayer != null &&
|
||||
this.dataset.signalId != null
|
||||
);
|
||||
}
|
||||
return selector.startsWith('.')
|
||||
? this.className.split(/\s+/).includes(selector.slice(1))
|
||||
: this.tagName === selector.toUpperCase();
|
||||
}
|
||||
closest(selector) {
|
||||
for (let node = this; node; node = node.parentNode)
|
||||
if (node.matches(selector)) return node;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.flatMap((child) => [
|
||||
...(child.matches(selector) ? [child] : []),
|
||||
...child.querySelectorAll(selector),
|
||||
]);
|
||||
}
|
||||
focus() {
|
||||
assert.ok(
|
||||
this.isConnected,
|
||||
'a focus destination must still be connected',
|
||||
);
|
||||
focusCalls += 1;
|
||||
document.activeElement = this;
|
||||
}
|
||||
}
|
||||
document.body = new Node('body');
|
||||
document.createElement = (tag) => new Node(tag);
|
||||
document.activeElement = document.body;
|
||||
globalThis.document = document;
|
||||
const controller = Object.create(CockpitViewController.prototype);
|
||||
controller.services = { formatAwarenessLabel };
|
||||
controller.signalList = new Node('ol');
|
||||
controller.signalToggle = new Node('button');
|
||||
controller.briefTabs = [
|
||||
new Node('button'),
|
||||
new Node('button'),
|
||||
new Node('button'),
|
||||
];
|
||||
controller.briefPageIndex = 0;
|
||||
controller.signalItems = [];
|
||||
controller.signalSignatures = new Map();
|
||||
controller.scheduleContextLayout = () => {};
|
||||
const display = new Node('button');
|
||||
const radio = new Node('button');
|
||||
const outside = new Node('button');
|
||||
document.body.append(
|
||||
controller.signalToggle,
|
||||
controller.signalList,
|
||||
...controller.briefTabs,
|
||||
display,
|
||||
radio,
|
||||
outside,
|
||||
);
|
||||
return result;
|
||||
});
|
||||
const removeChild = Node.prototype.removeChild;
|
||||
t.mock.method(Node.prototype, 'removeChild', function (node) {
|
||||
if (holdsFocus(node)) counts.losses += 1;
|
||||
return removeChild.call(this, node);
|
||||
});
|
||||
const focus = HTMLElement.prototype.focus;
|
||||
t.mock.method(HTMLElement.prototype, 'focus', function (...args) {
|
||||
assert.ok(this.isConnected, 'a focus destination must still be connected');
|
||||
counts.focusCalls += 1;
|
||||
return focus.apply(this, args);
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitBriefing).use(pinia);
|
||||
app.mount(host);
|
||||
const store = useCockpitStore(pinia);
|
||||
const button = () => {
|
||||
const element = document.createElement('button');
|
||||
element.type = 'button';
|
||||
return document.body.appendChild(element);
|
||||
};
|
||||
const display = button();
|
||||
const radio = button();
|
||||
const outside = button();
|
||||
const counts = trackFocus(t);
|
||||
t.after(() => app.unmount());
|
||||
const selected = [];
|
||||
controller.services.militaryAwarenessLayer = {
|
||||
focusTarget: (...args) => selected.push(args),
|
||||
// The signal presenters need only this much of the controller.
|
||||
const owner = {
|
||||
destroyed: false,
|
||||
brief: createBriefState(),
|
||||
signalItems: [],
|
||||
signalSignatures: new Map(),
|
||||
publishSignals: (rows) => store.publishSignals(rows),
|
||||
publishCockpitSignals,
|
||||
scheduleContextLayout() {},
|
||||
services: {
|
||||
formatAwarenessLabel,
|
||||
militaryAwarenessLayer: {
|
||||
focusTarget: (...args) => selected.push(args),
|
||||
},
|
||||
},
|
||||
};
|
||||
const click = (event) => controller.handleSignalClick(event);
|
||||
const buttons = () => controller.signalList.querySelectorAll('button');
|
||||
// The controller shows the card on entering Cockpit.
|
||||
document.getElementById('cockpit-signal-stream').hidden = false;
|
||||
const list = document.getElementById('cockpit-signal-list');
|
||||
list.addEventListener('click', (event) =>
|
||||
handleSignalClick.call(owner, event),
|
||||
);
|
||||
const buttons = () => [...list.querySelectorAll('button')];
|
||||
// Tab visits the enabled buttons no hidden ancestor removes from view.
|
||||
const tab = () => {
|
||||
const nodes = document.body.querySelectorAll('button');
|
||||
const index = nodes.indexOf(document.activeElement);
|
||||
nodes[(index + 1) % nodes.length].focus();
|
||||
const nodes = [...document.querySelectorAll('button')].filter(
|
||||
(node) => !node.disabled && !node.closest('[hidden]'),
|
||||
);
|
||||
nodes[(nodes.indexOf(document.activeElement) + 1) % nodes.length].focus();
|
||||
};
|
||||
const pageTab = (index) =>
|
||||
document.querySelector(`[data-cockpit-brief-index="${index}"]`);
|
||||
const signalTab = () => pageTab(0);
|
||||
return {
|
||||
controller,
|
||||
document,
|
||||
owner,
|
||||
list,
|
||||
buttons,
|
||||
display,
|
||||
radio,
|
||||
outside,
|
||||
selected,
|
||||
click,
|
||||
tab,
|
||||
losses: () => focusLosses,
|
||||
focusCalls: () => focusCalls,
|
||||
pageTab,
|
||||
signalTab,
|
||||
store,
|
||||
losses: () => counts.losses,
|
||||
focusCalls: () => counts.focusCalls,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,9 +126,14 @@ const item = (id, overrides = {}) => ({
|
||||
timestamp: 1_700_000_000_000,
|
||||
...overrides,
|
||||
});
|
||||
const render = (f, items) => {
|
||||
f.controller.signalItems = items;
|
||||
f.controller.renderCockpitSignals();
|
||||
const render = async (f, items) => {
|
||||
f.owner.signalItems = items;
|
||||
publishCockpitSignals.call(f.owner);
|
||||
await nextTick();
|
||||
};
|
||||
const refresh = async (f, distanceM) => {
|
||||
updateCockpitSignals.call(f.owner, snapshot(distanceM), 0);
|
||||
await nextTick();
|
||||
};
|
||||
const snapshot = (distanceM = 2000) => ({
|
||||
evaluatedAt: 1_700_000_000_000,
|
||||
@@ -210,31 +147,61 @@ const snapshot = (distanceM = 2000) => ({
|
||||
],
|
||||
});
|
||||
|
||||
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
|
||||
const f = fixture();
|
||||
f.controller.updateCockpitSignals(snapshot(), 0);
|
||||
test('rows render the signals the controller presents', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [
|
||||
item('a'),
|
||||
item('status', { tone: 'warning', target: null, title: 'UNKNOWN' }),
|
||||
]);
|
||||
const [action, status] = f.list.children;
|
||||
assert.equal(action.className, 'nearby actionable');
|
||||
assert.equal(action.querySelector('time').textContent, '22:13:20Z');
|
||||
const button = action.querySelector('button.cockpit-signal-target');
|
||||
assert.equal(button.type, 'button');
|
||||
assert.equal(button.dataset.signalLayer, 'flights');
|
||||
assert.equal(button.dataset.signalId, 'a');
|
||||
assert.equal(button.getAttribute('aria-label'), 'Select flight Flight a');
|
||||
assert.equal(
|
||||
button.querySelector('.cockpit-signal-target-label').textContent,
|
||||
'Flight a',
|
||||
);
|
||||
assert.equal(
|
||||
button.querySelector('.cockpit-signal-target-chevron').textContent,
|
||||
'chevron_right',
|
||||
);
|
||||
assert.equal(
|
||||
action.querySelector('div > span').textContent,
|
||||
'COMMERCIAL FLIGHT · 2 KM',
|
||||
);
|
||||
assert.equal(status.className, 'warning');
|
||||
assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN');
|
||||
assert.equal(status.querySelector('button'), null);
|
||||
});
|
||||
|
||||
test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => {
|
||||
const f = fixture(t);
|
||||
await refresh(f);
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
const calls = f.focusCalls();
|
||||
for (let tick = 0; tick < 8; tick += 1)
|
||||
f.controller.updateCockpitSignals(snapshot(2500), 0);
|
||||
assert.equal(f.buttons()[1], target);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
for (let tick = 0; tick < 8; tick += 1) await refresh(f, 2500);
|
||||
assert.equal(f.buttons()[1] === target, true);
|
||||
assert.equal(document.activeElement === target, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
assert.equal(
|
||||
f.focusCalls(),
|
||||
calls,
|
||||
'refresh must preserve focus without repeatedly restoring it',
|
||||
);
|
||||
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
|
||||
assert.match(f.list.textContent, /2\.5\u00a0KM/);
|
||||
});
|
||||
|
||||
test('signal content and accessible name update without replacing the active target', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a')]);
|
||||
test('signal content and accessible name update without replacing the active target', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a')]);
|
||||
const target = f.buttons()[0];
|
||||
target.focus();
|
||||
render(f, [
|
||||
await render(f, [
|
||||
item('a', {
|
||||
title: 'RENAMED',
|
||||
tone: 'track',
|
||||
@@ -242,21 +209,18 @@ test('signal content and accessible name update without replacing the active tar
|
||||
timestamp: 1_700_000_001_000,
|
||||
}),
|
||||
]);
|
||||
assert.equal(f.buttons()[0], target);
|
||||
assert.equal(f.buttons()[0] === target, true);
|
||||
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
|
||||
assert.match(target.textContent, /RENAMED/);
|
||||
assert.match(f.controller.signalList.textContent, /CURRENT/);
|
||||
assert.equal(
|
||||
f.controller.signalList.children[0].className,
|
||||
'track actionable',
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.match(f.list.textContent, /CURRENT/);
|
||||
assert.equal(f.list.children[0].className, 'track actionable');
|
||||
assert.equal(document.activeElement === target, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
});
|
||||
|
||||
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
|
||||
const f = fixture();
|
||||
render(
|
||||
test('signal ranking changes move surrounding rows without disconnecting the focused row', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(
|
||||
f,
|
||||
['a', 'b', 'c', 'd'].map((id) => item(id)),
|
||||
);
|
||||
@@ -267,8 +231,10 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
|
||||
['d', 'c', 'b', 'a'],
|
||||
['b', 'c', 'd', 'a'],
|
||||
['a', 'd', 'c', 'b'],
|
||||
['c', 'a', 'b', 'd'],
|
||||
['b', 'a', 'd', 'c'],
|
||||
]) {
|
||||
render(
|
||||
await render(
|
||||
f,
|
||||
order.map((id) => item(id)),
|
||||
);
|
||||
@@ -276,97 +242,152 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
|
||||
f.buttons().map((button) => button.dataset.signalId),
|
||||
order,
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(document.activeElement === target, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
}
|
||||
assert.equal(f.focusCalls(), calls);
|
||||
});
|
||||
|
||||
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('b')]);
|
||||
test('a new pushed signal preserves existing focus and delegated selection reads the current target', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a'), item('b')]);
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
f.controller.pushCockpitSignal(
|
||||
pushCockpitSignal.call(
|
||||
f.owner,
|
||||
'status',
|
||||
'warning',
|
||||
'INPUT UNKNOWN',
|
||||
'SOURCE UNAVAILABLE',
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
await nextTick();
|
||||
assert.equal(f.list.children.length, 3);
|
||||
assert.equal(document.activeElement === target, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
const event = {
|
||||
target: target.children[0],
|
||||
prevented: false,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
};
|
||||
f.click(event);
|
||||
assert.equal(event.prevented, true);
|
||||
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
target.querySelector('.cockpit-signal-target-label').dispatchEvent(click);
|
||||
assert.equal(click.defaultPrevented, true);
|
||||
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
|
||||
});
|
||||
|
||||
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('b')]);
|
||||
test('removing the focused identity uses the shown briefing tab once and permits forward traversal', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a'), item('b')]);
|
||||
const removed = f.buttons()[1];
|
||||
removed.focus();
|
||||
const calls = f.focusCalls();
|
||||
render(f, [item('a')]);
|
||||
await render(f, [item('a')]);
|
||||
assert.equal(removed.isConnected, false);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
assert.equal(document.activeElement === f.signalTab(), true);
|
||||
assert.equal(f.focusCalls(), calls + 1);
|
||||
assert.equal(f.losses(), 0);
|
||||
assert.equal(f.signalTab().closest('[hidden]'), null);
|
||||
// Traversal carries on forward from the footer tab.
|
||||
f.tab();
|
||||
f.tab();
|
||||
f.tab();
|
||||
assert.equal(f.document.activeElement, f.display);
|
||||
render(f, [item('a', { detail: 'UPDATED' })]);
|
||||
assert.equal(document.activeElement === f.pageTab(1), true);
|
||||
while (document.activeElement !== f.display) f.tab();
|
||||
await render(f, [item('a', { detail: 'UPDATED' })]);
|
||||
assert.equal(
|
||||
f.document.activeElement,
|
||||
f.display,
|
||||
document.activeElement === f.display,
|
||||
true,
|
||||
'later refresh must not pull focus back',
|
||||
);
|
||||
f.tab();
|
||||
assert.equal(f.document.activeElement, f.radio);
|
||||
assert.equal(document.activeElement === f.radio, true);
|
||||
});
|
||||
|
||||
test('a changed target cannot reuse a focused action for another flight', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a', { key: 'shared-status-key' })]);
|
||||
test('a changed target cannot reuse a focused action for another flight', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a', { key: 'shared-status-key' })]);
|
||||
const removed = f.buttons()[0];
|
||||
removed.focus();
|
||||
render(f, [
|
||||
await render(f, [
|
||||
item('b', {
|
||||
key: 'shared-status-key',
|
||||
target: { layerId: 'military', id: 'b' },
|
||||
}),
|
||||
]);
|
||||
const target = f.buttons()[0];
|
||||
assert.notEqual(target, removed);
|
||||
assert.equal(target === removed, false);
|
||||
assert.equal(removed.isConnected, false);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
f.click({ target, preventDefault() {} });
|
||||
assert.equal(document.activeElement === f.signalTab(), true);
|
||||
target.click();
|
||||
assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
|
||||
});
|
||||
|
||||
test('empty, informational and duplicate-key rows have no stale or aliased actions', () => {
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('a')]);
|
||||
test('empty, informational and duplicate-key rows have no stale or aliased actions', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [item('a'), item('a')]);
|
||||
const [first, second] = f.buttons();
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(first === second, false);
|
||||
second.focus();
|
||||
render(f, [item('a'), item('a')]);
|
||||
assert.deepEqual(f.buttons(), [first, second]);
|
||||
assert.equal(f.document.activeElement, second);
|
||||
render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
|
||||
await render(f, [item('a'), item('a')]);
|
||||
const again = f.buttons();
|
||||
assert.equal(again[0] === first && again[1] === second, true);
|
||||
assert.equal(document.activeElement === second, true);
|
||||
await render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
|
||||
assert.equal(f.buttons().length, 0);
|
||||
assert.match(f.controller.signalList.textContent, /UNKNOWN/);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
assert.match(f.list.textContent, /UNKNOWN/);
|
||||
assert.equal(document.activeElement === f.signalTab(), true);
|
||||
f.outside.focus();
|
||||
render(f, []);
|
||||
assert.equal(f.controller.signalList.children.length, 0);
|
||||
render(f, [item('a')]);
|
||||
assert.notEqual(f.buttons()[0], first);
|
||||
assert.equal(f.document.activeElement, f.outside);
|
||||
await render(f, []);
|
||||
assert.equal(f.list.children.length, 0);
|
||||
await render(f, [item('a')]);
|
||||
assert.equal(f.buttons()[0] === first, false);
|
||||
assert.equal(document.activeElement === f.outside, true);
|
||||
assert.equal(f.losses(), 0);
|
||||
});
|
||||
|
||||
test('any reordering of five rows keeps focus wherever it sits', async (t) => {
|
||||
const f = fixture(t);
|
||||
const ids = ['a', 'b', 'c', 'd', 'e'];
|
||||
// A fixed pseudo-random sequence, so a failure reproduces.
|
||||
let seed = 7;
|
||||
const random = () => {
|
||||
seed = (seed * 48271) % 2147483647;
|
||||
return seed / 2147483647;
|
||||
};
|
||||
const shuffle = (list) => {
|
||||
const next = [...list];
|
||||
for (let index = next.length - 1; index > 0; index -= 1) {
|
||||
const swap = Math.floor(random() * (index + 1));
|
||||
[next[index], next[swap]] = [next[swap], next[index]];
|
||||
}
|
||||
return next;
|
||||
};
|
||||
await render(
|
||||
f,
|
||||
ids.map((id) => item(id)),
|
||||
);
|
||||
for (let round = 0; round < 60; round += 1) {
|
||||
const target = f.buttons()[round % ids.length];
|
||||
target.focus();
|
||||
const order = shuffle(ids);
|
||||
await render(
|
||||
f,
|
||||
order.map((id) => item(id)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.buttons().map((button) => button.dataset.signalId),
|
||||
order,
|
||||
);
|
||||
assert.equal(document.activeElement === target, true);
|
||||
}
|
||||
assert.equal(f.losses(), 0);
|
||||
});
|
||||
|
||||
test('the fallback follows the shown page, and the collapse toggle without tabs', async (t) => {
|
||||
const f = fixture(t);
|
||||
f.store.publishBrief({ ...createBriefState(), pageIndex: 2 });
|
||||
await render(f, [item('a'), item('b')]);
|
||||
f.buttons()[1].focus();
|
||||
await render(f, [item('a')]);
|
||||
assert.equal(document.activeElement === f.pageTab(2), true);
|
||||
for (const index of [0, 1, 2]) f.pageTab(index).remove();
|
||||
f.buttons()[0].focus();
|
||||
await render(f, []);
|
||||
assert.equal(
|
||||
document.activeElement === document.getElementById('cockpit-signal-toggle'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -5,7 +5,6 @@ export const COCKPIT_VISION_MODES = Object.freeze([
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
|
||||
const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
@@ -14,7 +13,6 @@ const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
@@ -24,7 +22,6 @@ const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
|
||||
|
||||
@@ -23,7 +23,6 @@ test('Cockpit vision order mirrors the unique map preset order', () => {
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
|
||||
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
|
||||
@@ -36,7 +35,7 @@ test('Cockpit entry selects the vision mode equivalent to the active map style',
|
||||
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
|
||||
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
|
||||
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'optical');
|
||||
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
|
||||
});
|
||||
|
||||
|
||||
@@ -243,6 +243,8 @@ test('Codex login hides spawn errors and can cancel only its owned child', async
|
||||
const child = new EventEmitter();
|
||||
await assert.rejects(
|
||||
startCodexChatGptLogin({
|
||||
environment: {},
|
||||
home: '/home/fixture',
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() =>
|
||||
@@ -264,6 +266,8 @@ test('Codex login hides spawn errors and can cancel only its owned child', async
|
||||
running.emit('exit', null);
|
||||
};
|
||||
const login = await startCodexChatGptLogin({
|
||||
environment: {},
|
||||
home: '/home/fixture',
|
||||
executable: 'codex',
|
||||
spawnImpl() {
|
||||
queueMicrotask(() => running.emit('spawn'));
|
||||
|
||||
@@ -281,7 +281,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
uiSource.indexOf('_syncContactsDetection() {'),
|
||||
uiSource.indexOf('/** Apply a temporary cockpit-only'),
|
||||
);
|
||||
assert.ok(helper.length > 0, 'the Contacts detection helper is present in ui.js');
|
||||
assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source');
|
||||
assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click');
|
||||
assert.match(helper, /active: this\._contextMode === 'flights'/);
|
||||
assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/);
|
||||
@@ -302,7 +302,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'),
|
||||
uiSource.indexOf('_syncIrBoost() {'),
|
||||
);
|
||||
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in ui.js');
|
||||
assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source');
|
||||
assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection');
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { setContextMode as contextModeAction } from './ui/contextActions.js';
|
||||
import { ContextControls } from './ui/contextControls.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
|
||||
// - session bookkeeping ran AFTER the exit early-return, so the compensating
|
||||
@@ -21,8 +20,6 @@ const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', imp
|
||||
// - the right-rail entry ignored the activation result entirely.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const src = readShellSource();
|
||||
|
||||
@@ -97,6 +94,15 @@ test('context restore settles the Contacts coordinator before dependency fanout'
|
||||
assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/);
|
||||
});
|
||||
|
||||
test('Clear All during the Contacts settle stops the snapshot coming back', () => {
|
||||
const restore = _restoreContextSession.toString();
|
||||
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(');
|
||||
const cancelled = restore.indexOf('if (restoreState.cancelled) return;', coordinatorOff);
|
||||
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', coordinatorOff);
|
||||
assert.ok(cancelled > coordinatorOff, 'the cancellation is checked after Contacts settles');
|
||||
assert.ok(fanout > cancelled, 'and before the snapshot fans out');
|
||||
});
|
||||
|
||||
test('context restore preserves its exact pending target after a failed transition', () => {
|
||||
const restore = _restoreContextSession.toString();
|
||||
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
|
||||
@@ -162,7 +168,7 @@ test('every user-facing Context exit route settles through the failure surface',
|
||||
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
|
||||
const radioControls = radioBindings.slice(
|
||||
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
|
||||
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
|
||||
);
|
||||
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
|
||||
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
|
||||
@@ -172,7 +178,7 @@ test('the Radio chip catches lifecycle rejection and semantic false through the
|
||||
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
|
||||
const radioControls = radioBindings.slice(
|
||||
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
|
||||
radioBindings.indexOf('this._storeToken = this.store?.connect?.('),
|
||||
);
|
||||
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
|
||||
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../build/application-html.js';
|
||||
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
import { _syncContextModeButtons } from './ui/contextPresentation.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const ui = readShellSource();
|
||||
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
||||
@@ -25,77 +21,12 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
|
||||
assert.match(button[0], /tabindex="0"/);
|
||||
}
|
||||
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
|
||||
assert.match(syncSource, /button\.tabIndex = 0/);
|
||||
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
|
||||
});
|
||||
|
||||
test('Context transition state preserves focus and Tab availability until settle', () => {
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
|
||||
const attributes = () => new Map();
|
||||
const makeButton = () => {
|
||||
const attrs = attributes();
|
||||
let disabled = false;
|
||||
const button = {
|
||||
attrs,
|
||||
tabIndex: -1,
|
||||
classList: { toggle() {} },
|
||||
setAttribute(name, value) { attrs.set(name, String(value)); },
|
||||
get disabled() { return disabled; },
|
||||
set disabled(value) {
|
||||
disabled = Boolean(value);
|
||||
// Model the browser behavior that exposed this regression: native
|
||||
// disabled drops focus immediately.
|
||||
if (disabled && globalThis.document?.activeElement === button) {
|
||||
globalThis.document.activeElement = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
return button;
|
||||
};
|
||||
const contacts = makeButton();
|
||||
const missions = makeButton();
|
||||
const panel = { classList: { toggle() {} }, setAttribute() {} };
|
||||
const priorDocument = globalThis.document;
|
||||
globalThis.document = { activeElement: missions, getElementById: () => panel };
|
||||
const owner = {
|
||||
_globalContextPanel: panel,
|
||||
actions: { syncDetection() {}, scheduleLayout() {} },
|
||||
_contextMode: null,
|
||||
_contextModeChanging: true,
|
||||
_globalContextFlightsBtn: contacts,
|
||||
_globalContextMissionsBtn: missions,
|
||||
_contextModeStandby: {},
|
||||
_contextFlightsView: {},
|
||||
_contextMissionsView: {},
|
||||
cockpitView: { syncEntry() {} },
|
||||
_syncContactsDetection() {},
|
||||
_scheduleRightPanelLayout() {},
|
||||
};
|
||||
try {
|
||||
_syncContextModeButtons.call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.tabIndex, 0);
|
||||
assert.equal(button.attrs.get('aria-disabled'), 'true');
|
||||
assert.equal(button.attrs.get('aria-busy'), 'true');
|
||||
}
|
||||
|
||||
owner._contextModeChanging = false;
|
||||
owner._contextMode = 'space-missions';
|
||||
_syncContextModeButtons.call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.tabIndex, 0);
|
||||
assert.equal(button.attrs.get('aria-disabled'), 'false');
|
||||
assert.equal(button.attrs.get('aria-busy'), 'false');
|
||||
}
|
||||
} finally {
|
||||
globalThis.document = priorDocument;
|
||||
// The view keeps both tabs' tabindex static and never disables them; the
|
||||
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
|
||||
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
|
||||
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
|
||||
assert.ok(tag, `${id} is missing from the view`);
|
||||
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,20 +65,68 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
|
||||
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
|
||||
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.('));
|
||||
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
|
||||
const clear = clearSelectedLayers.toString();
|
||||
|
||||
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
|
||||
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
|
||||
assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
|
||||
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
|
||||
assert.ok(search, 'nearby sites search is missing');
|
||||
assert.doesNotMatch(search, /:disabled|\sdisabled/);
|
||||
assert.match(search, /:aria-busy=/);
|
||||
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
|
||||
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
|
||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||
}
|
||||
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
||||
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
|
||||
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
|
||||
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
|
||||
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
|
||||
assert.match(cockpitEnable, /:aria-busy=/);
|
||||
// The Radio view never disables its Enable button; it marks it busy.
|
||||
const enable = radioPanel.match(/<UiButton id="radio-enable-btn"[^>]*>/)?.[0];
|
||||
assert.ok(enable, 'Radio Enable button is missing');
|
||||
assert.doesNotMatch(enable, /disabled(?!=")|:disabled/);
|
||||
assert.match(enable, /:aria-busy=/);
|
||||
assert.match(clear, /this\.setClearBusy\(true\)/);
|
||||
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
|
||||
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
|
||||
assert.doesNotMatch(control, /button\.disabled\s*=/);
|
||||
// The globe actions view marks the clear button busy, never disabled.
|
||||
const globeActions = readFileSync(new URL('./ui/views/panels/GlobeActions.vue', import.meta.url), 'utf8');
|
||||
const clearButton = globeActions.match(/<button id="clear-selected-layers"[^>]*>/)?.[0];
|
||||
assert.ok(clearButton, 'Clear selected layers button is missing');
|
||||
assert.doesNotMatch(clearButton, /disabled(?!=")|:disabled=/);
|
||||
assert.match(clearButton, /:aria-busy=/);
|
||||
});
|
||||
|
||||
test('Context header holds only its title and collapse control', () => {
|
||||
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
|
||||
assert.ok(panel, 'Global Context panel is missing');
|
||||
const header = panel[0].match(/<div class="ui-panel__head panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
|
||||
assert.ok(header, 'Global Context header is missing');
|
||||
// The kit's own ui-* classes carry no Core styles.
|
||||
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
|
||||
`${tag}.${cls.split(' ').filter((name) => !/^ui-/.test(name)).join(' ')}`);
|
||||
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
|
||||
});
|
||||
|
||||
test('Context reads as one segmented control with legible type and no halo', () => {
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
|
||||
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
|
||||
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
|
||||
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
|
||||
|
||||
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
|
||||
const modes = rule('.global-context-modes');
|
||||
assert.match(modes, /border:\s*1px solid/);
|
||||
assert.match(modes, /padding:\s*2px/);
|
||||
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
|
||||
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
|
||||
const standby = rule('.context-mode-standby');
|
||||
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
|
||||
assert.doesNotMatch(standby, /dashed/);
|
||||
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
|
||||
// The shared rail title colour (brighter than --text-secondary) keeps the
|
||||
// open Context title legible; no Context-only rule dims it again.
|
||||
assert.equal(
|
||||
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -2,9 +2,13 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Readable } from 'node:stream';
|
||||
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
|
||||
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
|
||||
import { geminiLiveProxy } from '../server/providers/gemini.js';
|
||||
import { GEMINI_DEFAULT_PER_MIN } from '../server/providers/gemini/chat.js';
|
||||
import {
|
||||
makeCostRateLimiter,
|
||||
resolvePerMinuteCap,
|
||||
@@ -168,6 +172,92 @@ test('the realtime token route throttles by default, and says so in a 429', asyn
|
||||
);
|
||||
});
|
||||
|
||||
function chatRequest(handler, input) {
|
||||
const req = Readable.from([Buffer.from(JSON.stringify(input))]);
|
||||
Object.assign(req, {
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
headers: {
|
||||
host: 'localhost:4173',
|
||||
origin: 'http://localhost:4173',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
});
|
||||
const headers = {};
|
||||
const res = Object.assign(new EventEmitter(), {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
res.end = (body = '') => {
|
||||
res.writableEnded = true;
|
||||
resolve({ status: res.statusCode, headers, body: String(body) });
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('typed chat follows each provider throttle by default, before upstream', async (t) => {
|
||||
for (const name of [
|
||||
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
|
||||
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
||||
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
|
||||
'GEV_RATELIMIT_GEMINI_PER_MIN',
|
||||
'GEV_PREFER_CODEX_OAUTH',
|
||||
])
|
||||
env(t, name, undefined);
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
env(t, 'GEMINI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json(
|
||||
String(url).includes('openai')
|
||||
? { choices: [{ message: { role: 'assistant', content: 'Hi' } }] }
|
||||
: {
|
||||
candidates: [
|
||||
{ content: { role: 'model', parts: [{ text: 'Hi' }] } },
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
// The chat fallback must track the Gemini token route's own default.
|
||||
const tokenDefault = readFileSync(
|
||||
new URL('../server/providers/gemini/realtime.js', import.meta.url),
|
||||
'utf8',
|
||||
).match(/GEV_RATELIMIT_GEMINI_PER_MIN \?\? '(\d+)'/)?.[1];
|
||||
assert.equal(Number(tokenDefault), GEMINI_DEFAULT_PER_MIN);
|
||||
|
||||
for (const [route, handler, cap, input] of [
|
||||
[
|
||||
'/api/openai/chat',
|
||||
install(openAiRealtimeProxy({})).get('/api/openai/chat'),
|
||||
OPENAI_DEFAULT_PER_MIN,
|
||||
{ messages: [{ role: 'user', content: 'Hello' }] },
|
||||
],
|
||||
[
|
||||
'/api/gemini/chat',
|
||||
install(geminiLiveProxy({})).get('/api/gemini/chat'),
|
||||
GEMINI_DEFAULT_PER_MIN,
|
||||
{ contents: [{ role: 'user', parts: [{ text: 'Hello' }] }] },
|
||||
],
|
||||
]) {
|
||||
upstreamCalls = 0;
|
||||
const statuses = [];
|
||||
for (let i = 0; i < cap; i += 1)
|
||||
statuses.push((await chatRequest(handler, input)).status);
|
||||
assert.deepEqual(statuses, Array(cap).fill(200), route);
|
||||
const overLimit = await chatRequest(handler, input);
|
||||
assert.equal(overLimit.status, 429, route);
|
||||
assert.equal(overLimit.headers['retry-after'], '60');
|
||||
assert.equal(overLimit.body.includes('fixture-upstream-secret'), false);
|
||||
assert.equal(upstreamCalls, cap, `${route} refuses before upstream`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||
const ui = readShellSource();
|
||||
|
||||
/*
|
||||
* Required-attribution keep-out pin.
|
||||
@@ -247,6 +245,9 @@ function ownBoxEntries() {
|
||||
const compound = lastCompound(part);
|
||||
if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue;
|
||||
if (compound.includes('::')) continue; // a pseudo-element is its own box
|
||||
// The phone shell is its own layout; mobileShell.test.mjs pins its
|
||||
// attribution clearance.
|
||||
if (part.startsWith('body.mobile-shell')) continue;
|
||||
entries.push({ rule, part });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
|
||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||
*/
|
||||
import { getLocale, t } from './i18n/index.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
export function configureCreditKeyboardAccess(root = document) {
|
||||
const expand = root?.querySelector?.(
|
||||
@@ -14,19 +14,19 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
const overlay = lightbox.parentElement;
|
||||
|
||||
// Cesium writes these two texts in English once, when it builds the credit
|
||||
// display, and never rewrites them; English keeps Cesium's own text.
|
||||
if (getLocale() !== 'en') {
|
||||
expand.textContent = t('Data attribution');
|
||||
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
|
||||
if (title) title.textContent = t('Data provided by:');
|
||||
}
|
||||
// display, and never rewrites them; English is Cesium's own text. Called
|
||||
// again after a locale change, this words them again.
|
||||
expand.textContent = t('Data attribution');
|
||||
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
|
||||
if (title) title.textContent = t('Data provided by:');
|
||||
|
||||
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
|
||||
expand.setAttribute('role', 'button');
|
||||
expand.setAttribute('tabindex', '0');
|
||||
expand.setAttribute('aria-haspopup', 'dialog');
|
||||
expand.setAttribute('aria-controls', lightbox.id);
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
if (!expand.hasAttribute?.('aria-expanded'))
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
close.setAttribute('role', 'button');
|
||||
close.setAttribute('tabindex', '0');
|
||||
close.setAttribute('aria-label', t('Close data attribution'));
|
||||
|
||||
@@ -221,3 +221,16 @@ for (const [locale, link, title] of [
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('setting up again after a switch back to English restores the English texts', () => {
|
||||
const f = textFixture();
|
||||
setLocale('ru');
|
||||
try {
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
assert.equal(f.expand.textContent, 'Data attribution');
|
||||
assert.equal(f.title.textContent, 'Data provided by:');
|
||||
});
|
||||
+9
-3
@@ -480,11 +480,16 @@ export function setCyberSonarEnabled(
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Whether the map-mode sweep should alter flight presentation. */
|
||||
/**
|
||||
* Whether the map-mode sweep should alter flight presentation. `hudActive`
|
||||
* is the HUD's visibility from its owner, which a caller acting on it in the
|
||||
* same turn passes; otherwise the rendered `#intel-hud` class answers.
|
||||
*/
|
||||
export function isCyberSonarActive({
|
||||
root = globalThis.document?.documentElement,
|
||||
body = globalThis.document?.body,
|
||||
hud,
|
||||
hudActive,
|
||||
reducedMotion,
|
||||
} = {}) {
|
||||
// Keep optional method calls in the body: production downleveling of nested
|
||||
@@ -498,7 +503,7 @@ export function isCyberSonarActive({
|
||||
return !!(
|
||||
root?.dataset?.uiTheme === 'cyber' &&
|
||||
isCyberSonarEnabled(root) &&
|
||||
hud?.classList?.contains?.('active') &&
|
||||
(hudActive ?? hud?.classList?.contains?.('active')) &&
|
||||
!body?.classList?.contains?.('cockpit-mode') &&
|
||||
!reducedMotion
|
||||
);
|
||||
@@ -508,12 +513,13 @@ export function isCyberMapThemeActive({
|
||||
root = globalThis.document?.documentElement,
|
||||
body = globalThis.document?.body,
|
||||
hud,
|
||||
hudActive,
|
||||
} = {}) {
|
||||
if (hud === undefined)
|
||||
hud = globalThis.document?.getElementById?.('intel-hud');
|
||||
return !!(
|
||||
root?.dataset?.uiTheme === 'cyber' &&
|
||||
hud?.classList?.contains?.('active') &&
|
||||
(hudActive ?? hud?.classList?.contains?.('active')) &&
|
||||
!body?.classList?.contains?.('cockpit-mode')
|
||||
);
|
||||
}
|
||||
|
||||
+166
-13
@@ -150,17 +150,60 @@ export function polygonsContain(polygons, lat, lon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether polygons contain a point. A part whose outer ring crosses the
|
||||
* antimeridian ([179.99, …], [-179.99, …]) is read contiguously in its own
|
||||
* longitude frame, with the point moved into that frame; every other part,
|
||||
* including one genuinely wider than 180°, is tested as stored. Crossing is
|
||||
* read from an edge jumping more than 180°, which assumes densely noded rings
|
||||
* (OSM, Nominatim, tiles); a ring that is both wider than 180° and crossing, or
|
||||
* a non-crossing ring with a single edge over 180°, is not supported.
|
||||
*/
|
||||
export function containsAnchor(polygons, lat, lon) {
|
||||
if (!Number.isFinite(lon)) return false;
|
||||
for (const part of polygons || []) {
|
||||
const outer = part?.[0];
|
||||
if (!Array.isArray(outer) || !outer.length) continue;
|
||||
const reference = outer[0][0];
|
||||
const shift = (x) => x - 360 * Math.round((x - reference) / 360);
|
||||
const span = (ring, f) => {
|
||||
let west = Infinity;
|
||||
let east = -Infinity;
|
||||
for (const [x] of ring) {
|
||||
west = Math.min(west, f(x));
|
||||
east = Math.max(east, f(x));
|
||||
}
|
||||
return east - west;
|
||||
};
|
||||
// An edge jumping more than 180° wraps the line; a polar ring whose
|
||||
// edges run along ±180 collapses when reframed and is read as stored.
|
||||
const jumps = outer.some(
|
||||
([x], i) => i > 0 && Math.abs(x - outer[i - 1][0]) > 180,
|
||||
);
|
||||
const framedSpan = jumps ? span(outer, shift) : 0;
|
||||
const crosses = framedSpan > 0 && framedSpan < 180;
|
||||
const framed = crosses
|
||||
? part.map((ring) => ring.map(([x, y]) => [shift(x), y]))
|
||||
: part;
|
||||
if (polygonsContain([framed], lat, crosses ? shift(lon) : lon)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
|
||||
* the largest part for the box so units cut at the antimeridian (Alaska,
|
||||
* Chukotka) get a box on the right side of the globe.
|
||||
* Chukotka) get a box on the right side of the globe. An entry from another
|
||||
* encoding supplies `decode()` returning the same `[outer, ...holes][]`.
|
||||
*/
|
||||
function geometryOf(entry) {
|
||||
export function geometryOf(entry) {
|
||||
if (entry.geometry) return entry.geometry;
|
||||
const decimals = entry.feature.d ?? entry.decimals;
|
||||
const polygons = entry.feature.polygons.map((poly) =>
|
||||
poly.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
const polygons = entry.decode
|
||||
? entry.decode()
|
||||
: entry.feature.polygons.map((poly) =>
|
||||
poly.map((ring) => decodeRing(ring, decimals)),
|
||||
);
|
||||
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
@@ -178,7 +221,7 @@ function geometryOf(entry) {
|
||||
if (lat > north) north = lat;
|
||||
}
|
||||
}
|
||||
const label = entry.feature.label
|
||||
const label = Array.isArray(entry.feature.label)
|
||||
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
|
||||
: ringCentroid(polygons[0][0]);
|
||||
entry.geometry = {
|
||||
@@ -208,7 +251,7 @@ function ringCentroid(ring) {
|
||||
}
|
||||
|
||||
/** Km from a point to a unit's box (0 inside it). */
|
||||
function boxDistanceKm(bbox, lat, lon) {
|
||||
export function boxDistanceKm(bbox, lat, lon) {
|
||||
const [west, south, east, north] = bbox;
|
||||
const mid = (west + east) / 2;
|
||||
const x = lon - 360 * Math.round((lon - mid) / 360);
|
||||
@@ -378,6 +421,49 @@ const COUNTRY_TARGET_ALIASES = new Map([
|
||||
['drc', 'democratic republic of the congo'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Country aliases that are another name for the very same unit. "Great
|
||||
* Britain" and "Britain" are islands, "America" is also two continents and
|
||||
* "Korea" two countries, so those find a country without naming it.
|
||||
*/
|
||||
const COUNTRY_IDENTITY_ALIASES = new Set([
|
||||
'usa',
|
||||
'us',
|
||||
'u s',
|
||||
'u s a',
|
||||
'united states',
|
||||
'uk',
|
||||
'u k',
|
||||
'russian federation',
|
||||
'czechia',
|
||||
'tanzania',
|
||||
'drc',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The unit an ask names in the matcher's own terms, when its words differ
|
||||
* from the unit's name: "State of Texas" names Texas at admin level 1, and
|
||||
* "USA" or "Country of USA" names United States of America. Null when the
|
||||
* ask is just a name. Names are folded with `normalizeAdminName`.
|
||||
* @param {string} text
|
||||
* @returns {{name: string, level: 'country'|'admin1'}|null}
|
||||
*/
|
||||
export function adminQueryIdentity(text) {
|
||||
const parsed = parseAdminQuery(text);
|
||||
if (!parsed?.name) return null;
|
||||
if (parsed.kind === 'state' && parsed.form === 'prefix')
|
||||
return { name: parsed.name, level: 'admin1' };
|
||||
if (parsed.kind !== 'country' && parsed.kind !== 'any') return null;
|
||||
if (COUNTRY_IDENTITY_ALIASES.has(parsed.name))
|
||||
return {
|
||||
name: COUNTRY_TARGET_ALIASES.get(parsed.name),
|
||||
level: 'country',
|
||||
};
|
||||
return parsed.kind === 'country'
|
||||
? { name: parsed.name, level: 'country' }
|
||||
: null;
|
||||
}
|
||||
|
||||
const COUNTRY_ALIASES = new Map([
|
||||
...COUNTRY_TARGET_ALIASES,
|
||||
['england', 'united kingdom'],
|
||||
@@ -399,8 +485,8 @@ const COUNTY_WORDS = [
|
||||
];
|
||||
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
|
||||
const STATE_PREFIX_RE =
|
||||
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
|
||||
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
|
||||
/^(free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
|
||||
const STATE_SUFFIX_RE = /^(.+) (state|province|prefecture|territory)$/;
|
||||
|
||||
/**
|
||||
* Split an ask into what it names and how.
|
||||
@@ -461,14 +547,17 @@ export function parseAdminQuery(text) {
|
||||
if (prefix)
|
||||
return {
|
||||
kind: 'state',
|
||||
name: prefix[1],
|
||||
name: prefix[2],
|
||||
qualifiers,
|
||||
postal: null,
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'prefix',
|
||||
adminType: prefix[1],
|
||||
};
|
||||
const suffix = STATE_SUFFIX_RE.exec(head);
|
||||
// South Africa's canonical province name is literally "Free State"; do not
|
||||
// strip its final word as though the ask explicitly named an admin type.
|
||||
const suffix = head === 'free state' ? null : STATE_SUFFIX_RE.exec(head);
|
||||
if (suffix)
|
||||
return {
|
||||
kind: 'state',
|
||||
@@ -478,6 +567,7 @@ export function parseAdminQuery(text) {
|
||||
countyWord: null,
|
||||
full: head,
|
||||
form: 'suffix',
|
||||
adminType: suffix[2],
|
||||
};
|
||||
return {
|
||||
kind: 'any',
|
||||
@@ -489,6 +579,30 @@ export function parseAdminQuery(text) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Explicit wording is stronger than a stale same-name geometry. */
|
||||
function explicitStateTypeFits(entry, parsed) {
|
||||
if (!parsed.adminType) return true;
|
||||
const words = new Set(
|
||||
normalizeAdminName(entry.feature?.type)
|
||||
.split(/[^a-z]+/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
const accepted =
|
||||
{
|
||||
'free state': ['state'],
|
||||
state: ['state'],
|
||||
commonwealth: ['state'],
|
||||
province: ['province'],
|
||||
territory: ['territory'],
|
||||
prefecture: ['prefecture'],
|
||||
region: ['region'],
|
||||
land: ['state'],
|
||||
canton: ['canton'],
|
||||
department: ['department', 'departamento'],
|
||||
}[parsed.adminType] || [];
|
||||
return accepted.some((word) => words.has(word));
|
||||
}
|
||||
|
||||
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
|
||||
* itself ("Washington, DC"). */
|
||||
function qualifierMatches(entry, qualifier) {
|
||||
@@ -505,7 +619,7 @@ function qualifierMatches(entry, qualifier) {
|
||||
* Rank candidates: a unit containing `near` first, then the match tier,
|
||||
* then distance from `near`, then larger area. Returns the ranked list.
|
||||
*/
|
||||
function rank(candidates, near) {
|
||||
export function rank(candidates, near) {
|
||||
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
|
||||
const scored = candidates.map(({ entry, tier }) => {
|
||||
const geometry = geometryOf(entry);
|
||||
@@ -626,6 +740,10 @@ async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
|
||||
list = list.filter((i) =>
|
||||
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
|
||||
);
|
||||
// Preserve explicit identity at the selector seam. In particular,
|
||||
// "Bagmati Province" must not select Natural Earth's historical NP-BA
|
||||
// Bagmati Zone merely because the point and normalized name overlap.
|
||||
list = list.filter((i) => explicitStateTypeFits(i.entry, parsed));
|
||||
if (allowAmbiguous) return list;
|
||||
// A bare name answers alone only for a prominent unit — a US state, a
|
||||
// Canadian province, Bavaria — never a French département, an English
|
||||
@@ -721,6 +839,35 @@ export async function findAdminArea(query, { near = null } = {}) {
|
||||
return toResult(ranked[0], candidates.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every prominent unit an ask's words could mean — countries, and states or
|
||||
* provinces that would answer a bare name (see `stateCandidates`) — for a
|
||||
* caller that asks the user which one ("Georgia", "Punjab") instead of
|
||||
* letting the camera decide. Qualifiers narrow the list as in
|
||||
* `findAdminArea`; minor units ("Kent", "Santa Barbara") are left out.
|
||||
*
|
||||
* @param {string} query
|
||||
* @returns {Promise<AdminArea[]>} Largest first; empty when none.
|
||||
*/
|
||||
export async function findAdminCandidates(query) {
|
||||
const parsed = parseAdminQuery(query);
|
||||
if (!parsed?.name || parsed.kind === 'county') return [];
|
||||
const prominent = ({ entry }) =>
|
||||
entry.kind === 'country' ||
|
||||
(entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
|
||||
Boolean(entry.feature.cityState);
|
||||
const candidates = [
|
||||
...(await countryCandidates(parsed)),
|
||||
...(parsed.kind === 'country'
|
||||
? []
|
||||
: await stateCandidates(parsed, { allowAmbiguous: true })),
|
||||
].filter(prominent);
|
||||
const seen = new Set();
|
||||
return rank(candidates, null)
|
||||
.filter(({ entry }) => !seen.has(entry) && seen.add(entry))
|
||||
.map((scored) => toResult(scored, candidates.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoder-confirmed lookup: the geocoder typed a country, state or
|
||||
* county, so the unit must carry one of `names` AND contain the geocoded
|
||||
@@ -738,6 +885,12 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
|
||||
.map(parseAdminQuery)
|
||||
.filter((p) => p?.name);
|
||||
if (!parsedList.length) return null;
|
||||
// If the ask explicitly names an admin type, do not let the geocoder's
|
||||
// untyped alias reopen an incompatible historical same-name boundary.
|
||||
const explicitStates = parsedList.filter(
|
||||
(parsed) => parsed.kind === 'state' && parsed.adminType,
|
||||
);
|
||||
const stateParses = explicitStates.length ? explicitStates : parsedList;
|
||||
const containing = (items) => {
|
||||
const out = [];
|
||||
for (const item of items)
|
||||
@@ -782,7 +935,7 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
|
||||
return toResult(rank(found, { lat, lon })[0], found.length);
|
||||
}
|
||||
const states = [];
|
||||
for (const parsed of parsedList)
|
||||
for (const parsed of stateParses)
|
||||
states.push(
|
||||
...(await stateCandidates(
|
||||
{ ...parsed, qualifiers: [] },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
decodeRing,
|
||||
findAdminArea,
|
||||
findAdminAreaAt,
|
||||
findAdminCandidates,
|
||||
normalizeAdminName,
|
||||
packLoads,
|
||||
parseAdminQuery,
|
||||
@@ -225,6 +226,29 @@ test('geocoder-confirmed lookup: the named unit that contains the point', async
|
||||
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
|
||||
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
|
||||
assert.equal(newYork?.id, 'US-NY');
|
||||
assert.equal(
|
||||
(
|
||||
await findAdminAreaAt(
|
||||
['Ontario Province', 'Ontario'],
|
||||
43.6532,
|
||||
-79.3832,
|
||||
'state',
|
||||
)
|
||||
)?.id,
|
||||
'CA-ON',
|
||||
);
|
||||
// Natural Earth still carries the historical NP-BA Bagmati Zone. An ask
|
||||
// for today's province must not silently select that incompatible geometry.
|
||||
assert.equal(
|
||||
await findAdminAreaAt(
|
||||
['Bagmati Province', 'Bagmati'],
|
||||
27.7172,
|
||||
85.324,
|
||||
'state',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(await findAdminCandidates('Bagmati Province'), []);
|
||||
// A county the geocoder typed, named without the county word.
|
||||
const travis = await findAdminAreaAt(
|
||||
['Travis', 'Travis County'],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
|
||||
|
||||
const KNOT_TO_MPS = 0.514444;
|
||||
const FOOT_TO_M = 0.3048;
|
||||
const FPM_TO_MPS = 0.00508;
|
||||
@@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
|
||||
const geometricFeet = finiteNumber(aircraft?.alt_geom);
|
||||
const groundSpeedKnots = finiteNumber(aircraft?.gs);
|
||||
const verticalRateFpm =
|
||||
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
|
||||
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
|
||||
const track = finiteNumber(aircraft?.track);
|
||||
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Lighting options shared by every aircraft 3D model (Flights, Military
|
||||
* Flights, Local ADS-B).
|
||||
*
|
||||
* Cesium gives each Model a dynamic environment map: it renders the sky around
|
||||
* the model and reads the result back from the GPU (`readPixels`) to light it,
|
||||
* when the model is first placed and again when it moves more than 1 km, when
|
||||
* the scene's atmosphere settings change, or every scene hour under sunlight
|
||||
* lighting. Aircraft models take 94% of their surface from the class colour
|
||||
* (`colorBlendAmount`), so that lighting contributes little; without it they
|
||||
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
|
||||
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
|
||||
* 14.3-14.8 -> 12.1-12.5 ms.
|
||||
*/
|
||||
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
|
||||
enabled: false,
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
|
||||
|
||||
/**
|
||||
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
|
||||
* map (a sky render and GPU readback per model). The preload calls only warm
|
||||
* the glTF cache and are never added to the scene, so they are left out:
|
||||
* drawn models are the calls that set a minimum pixel size.
|
||||
*/
|
||||
const SOURCES = [
|
||||
{
|
||||
name: 'flights',
|
||||
source: readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||
},
|
||||
{
|
||||
name: 'militaryFlights',
|
||||
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||
},
|
||||
{
|
||||
name: 'localAdsb',
|
||||
source: readFileSync(
|
||||
new URL('../layers/localAdsb/models.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
test('the shared option turns the dynamic environment map off', () => {
|
||||
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
|
||||
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
|
||||
});
|
||||
|
||||
for (const { name, source } of SOURCES) {
|
||||
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
|
||||
const calls = [
|
||||
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((options) => /\bminimumPixelSize:/.test(options));
|
||||
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
|
||||
for (const [index, options] of calls.entries())
|
||||
assert.match(
|
||||
options,
|
||||
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
|
||||
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
import {
|
||||
presentSelectedVessel,
|
||||
subscribeSelectedVessel,
|
||||
} from '../hudReadouts.js';
|
||||
|
||||
test('open feed with vessels is healthy (null)', () => {
|
||||
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
|
||||
@@ -856,15 +860,6 @@ function makeTrailSpy() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeClassList(...initial) {
|
||||
const classes = new Set(initial);
|
||||
return {
|
||||
add(value) { classes.add(value); },
|
||||
remove(value) { classes.delete(value); },
|
||||
contains(value) { return classes.has(value); },
|
||||
};
|
||||
}
|
||||
|
||||
function makeInteractionHandler() {
|
||||
return {
|
||||
click: null,
|
||||
@@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
};
|
||||
const record = makeRecord();
|
||||
const trail = makeTrailSpy();
|
||||
presentSelectedVessel({ text: `AIS: ${record.name}` });
|
||||
let vesselReadout;
|
||||
const stopReadout = subscribeSelectedVessel(
|
||||
(readout) => (vesselReadout = readout),
|
||||
);
|
||||
const hud = {
|
||||
textContent: `AIS: ${record.name}`,
|
||||
classList: makeClassList('active'),
|
||||
get readout() {
|
||||
return vesselReadout;
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.window = windowTarget;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
|
||||
getElementById: () => null,
|
||||
};
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
@@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
reinstalledHandlers,
|
||||
interactionHandlerFactory,
|
||||
cleanup() {
|
||||
stopReadout();
|
||||
aisLiveVesselsLayer.destroy();
|
||||
if (hadWindow) globalThis.window = priorWindow;
|
||||
else delete globalThis.window;
|
||||
@@ -1011,6 +1013,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' });
|
||||
@@ -1063,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Clicking away is a deliberate deselect, not the vessel aging out of the
|
||||
// feed. Consumers that keep a readout on screen (the Cockpit Contact
|
||||
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
|
||||
@@ -1091,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
// Empty pick is a deliberate deselect — see the note above.
|
||||
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
|
||||
} finally {
|
||||
@@ -1105,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => {
|
||||
try {
|
||||
harness.keyTarget.dispatch({ key: 'Escape' });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
assert.equal(harness.hud.textContent, 'AIS: --');
|
||||
assert.equal(harness.hud.classList.contains('active'), false);
|
||||
assert.equal(harness.hud.readout, null);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
@@ -1294,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
|
||||
assert.deepEqual(bare.details, []);
|
||||
});
|
||||
|
||||
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
|
||||
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
|
||||
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
|
||||
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
|
||||
'CONTAINER SHIP · 14.5KT · 0°',
|
||||
);
|
||||
});
|
||||
|
||||
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||
try {
|
||||
Object.assign(harness.record, { heading: null, course: 359.7 });
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
|
||||
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
|
||||
assert.equal(unnamed.title, 'MMSI 353136000');
|
||||
@@ -1691,3 +1739,50 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
|
||||
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
|
||||
const sprite = (show) => ({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
show,
|
||||
color: makeColor(1),
|
||||
});
|
||||
const visible = sprite(true);
|
||||
const hidden = Array.from({ length: 50 }, () => sprite(false));
|
||||
const records = [visible, ...hidden].map((billboard) => ({
|
||||
position: billboard.position,
|
||||
billboard,
|
||||
}));
|
||||
const target = {
|
||||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||||
paddingPx: 0,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
let projected = 0;
|
||||
let measured = 0;
|
||||
const apply = (nowMs) =>
|
||||
applyVesselFocusDeemphasis({
|
||||
records,
|
||||
target,
|
||||
nowMs,
|
||||
screenPositionFor: () => {
|
||||
projected += 1;
|
||||
return { x: 50, y: 50 };
|
||||
},
|
||||
cameraDistanceFor: () => {
|
||||
measured += 1;
|
||||
return 1200;
|
||||
},
|
||||
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
|
||||
});
|
||||
|
||||
apply(0);
|
||||
const result = apply(300);
|
||||
|
||||
// Only the on-screen sprite pays for projection and distance.
|
||||
assert.equal(projected, 2);
|
||||
assert.equal(measured, 2);
|
||||
assert.equal(visible.color.alpha, 0.25);
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
@@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
|
||||
* @param {Object} body
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
const metadata = envelope?.MetaData || envelope?.Metadata || {};
|
||||
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
|
||||
if (raw === null || raw === undefined) return null;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
AISSTREAM_STALE_MS,
|
||||
aisStreamRows,
|
||||
ingestAisStreamEnvelope,
|
||||
readAisTrack,
|
||||
} from '../../server/providers/vessels/ais-store.js';
|
||||
|
||||
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
||||
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
|
||||
assert.equal(row.speed, 102.2);
|
||||
assert.equal(row.course, 359.9);
|
||||
});
|
||||
|
||||
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
|
||||
ingestAisStreamEnvelope(
|
||||
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
|
||||
);
|
||||
// AISStream's MetaData carries the last known position on every message,
|
||||
// static data included.
|
||||
assert.equal(
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: {
|
||||
ShipStaticData: {
|
||||
UserID: 244690000,
|
||||
Name: 'EXAMPLE VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
},
|
||||
MetaData: {
|
||||
MMSI: '244690000',
|
||||
latitude: 53.40879,
|
||||
longitude: 6.19741,
|
||||
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const row = rowFor('244690000');
|
||||
assert.equal(row.speed, 12.4);
|
||||
assert.equal(row.course, 86.7);
|
||||
assert.equal(row.heading, 87);
|
||||
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
|
||||
assert.equal(row.destination, 'ROTTERDAM');
|
||||
assert.equal(row.imo, '9876543');
|
||||
});
|
||||
|
||||
// AISStream carries both Class B parts; the unused part has Valid: false.
|
||||
const classBStatic = (part, value) => ({
|
||||
MessageID: 24,
|
||||
RepeatIndicator: 0,
|
||||
Valid: true,
|
||||
Reserved: 0,
|
||||
PartNumber: part === 'B',
|
||||
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
|
||||
ReportB: {
|
||||
Valid: part === 'B',
|
||||
ShipType: part === 'B' ? value : 0,
|
||||
VendorIDName: '',
|
||||
VenderIDModel: 0,
|
||||
VenderIDSerial: 0,
|
||||
CallSign: '',
|
||||
Dimension: { A: 0, B: 0, C: 0, D: 0 },
|
||||
FixType: 0,
|
||||
Spare: 0,
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, scenario] of [
|
||||
{
|
||||
type: 'ShipStaticData',
|
||||
initial: [
|
||||
{
|
||||
Name: 'OLD VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
],
|
||||
updates: [
|
||||
{
|
||||
Name: 'NEW VESSEL',
|
||||
Type: 80,
|
||||
Destination: 'HAMBURG',
|
||||
ImoNumber: 9876544,
|
||||
},
|
||||
],
|
||||
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
|
||||
expected: {
|
||||
name: 'NEW VESSEL',
|
||||
type: '80',
|
||||
destination: 'HAMBURG',
|
||||
imo: '9876544',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'StaticDataReport',
|
||||
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
|
||||
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
|
||||
empty: classBStatic('A', ''),
|
||||
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
|
||||
},
|
||||
].entries()) {
|
||||
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
|
||||
const mmsi = String(244690100 + index);
|
||||
const start = Date.parse('2026-09-17T20:00:00Z');
|
||||
let now = start;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const position = (
|
||||
lat,
|
||||
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
|
||||
) => {
|
||||
const envelope = positionReport(mmsi, body);
|
||||
Object.assign(envelope.MetaData, {
|
||||
latitude: lat,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
});
|
||||
return ingestAisStreamEnvelope(envelope);
|
||||
};
|
||||
const sendStatic = (body) =>
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: scenario.type,
|
||||
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: {
|
||||
MMSI: mmsi,
|
||||
ShipName: 'STALE HEADER',
|
||||
latitude: 54,
|
||||
longitude: 7,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
},
|
||||
});
|
||||
position(53.40879);
|
||||
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
|
||||
now += 60_000;
|
||||
position(53.41879);
|
||||
const before = rowFor(mmsi);
|
||||
const track = readAisTrack(mmsi);
|
||||
assert.equal(track.length, 2);
|
||||
|
||||
now += 5 * 60_000;
|
||||
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
|
||||
// Empty or omitted static fields must not erase known metadata, including
|
||||
// the cached values used when the next actual position arrives.
|
||||
assert.equal(sendStatic(scenario.empty), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
if (scenario.type === 'StaticDataReport') {
|
||||
sendStatic({
|
||||
...classBStatic('A', ''),
|
||||
ReportA: { Valid: false, Name: 'INVALID NAME' },
|
||||
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
|
||||
});
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
}
|
||||
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
|
||||
assert.equal(
|
||||
rowFor(mmsi),
|
||||
undefined,
|
||||
'static traffic must not renew position freshness',
|
||||
);
|
||||
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
|
||||
const next = rowFor(mmsi);
|
||||
for (const [key, value] of Object.entries(scenario.expected))
|
||||
assert.equal(next[key], value, key);
|
||||
assert.equal(next.speed, 0);
|
||||
assert.equal(next.course, 0);
|
||||
assert.equal(next.heading, 0);
|
||||
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
|
||||
assert.equal(readAisTrack(mmsi).length, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
|
||||
const mmsi = '244690200';
|
||||
const staticReport = (body) => ({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: { MMSI: mmsi },
|
||||
});
|
||||
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
|
||||
const extended = positionReport(mmsi, {});
|
||||
extended.MessageType = 'ExtendedClassBPositionReport';
|
||||
extended.Message = {
|
||||
ExtendedClassBPositionReport: {
|
||||
UserID: Number(mmsi),
|
||||
Type: 80,
|
||||
Sog: 12,
|
||||
Cog: 86,
|
||||
},
|
||||
};
|
||||
extended.MetaData.ShipName = 'NEW VESSEL';
|
||||
ingestAisStreamEnvelope(extended);
|
||||
const before = rowFor(mmsi);
|
||||
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
|
||||
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
|
||||
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
|
||||
assert.equal(rowFor(mmsi).type, '80');
|
||||
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
|
||||
});
|
||||
+1
-21
@@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
|
||||
authProbeMs: 3_600_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
|
||||
* pre-watchdog meaning so a keyless install still reads as "feature off".
|
||||
*/
|
||||
export const AIS_WATCHDOG_STATUSES = Object.freeze([
|
||||
'idle',
|
||||
'missing-key',
|
||||
'unsupported',
|
||||
'connecting',
|
||||
'live',
|
||||
'stale',
|
||||
'reconnecting',
|
||||
'down',
|
||||
'auth-failed',
|
||||
]);
|
||||
|
||||
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
|
||||
export const AIS_FAILURE_KINDS = Object.freeze([
|
||||
'transport',
|
||||
'auth',
|
||||
'rate-limit',
|
||||
]);
|
||||
const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
|
||||
|
||||
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
|
||||
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
|
||||
|
||||
@@ -9,6 +9,8 @@ import alprCamerasLayer, {
|
||||
} from './alprCameras.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { shownEntityLabel } from '../voice/speech.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getContextStore,
|
||||
@@ -473,6 +475,29 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
|
||||
}
|
||||
});
|
||||
|
||||
// The voice model reads the label as data; the voice card translates it.
|
||||
test('the camera label stays English data in a translated interface', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
setLocale('ru');
|
||||
h.setFetch(async () =>
|
||||
cameraResponse([
|
||||
cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }),
|
||||
]),
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
h.click(h.source.entities.values[0].id);
|
||||
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
|
||||
assert.equal(
|
||||
shownEntityLabel(getSelectedEntityContext().label),
|
||||
'Камера ALPR',
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
|
||||
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
|
||||
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
|
||||
|
||||
+244
-109
@@ -18,7 +18,11 @@
|
||||
*
|
||||
* Providers (injected — keeps the engine pure and node-testable):
|
||||
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
|
||||
* resolveRegionRing(name, signal?) → Promise<{ring, name}|{error:'region-timeout'}|null>
|
||||
* resolveRegionRing(name, {signal}?) → Promise<{name, geometry|ring, prepared?, source?}
|
||||
* |{error:'region-timeout'}|{needsClarification, candidates}|null>
|
||||
* (geometry = MultiPolygon coordinates; islands and holes all count)
|
||||
* resolveAreaScope(scope) → Promise<{ok:true, area:{areaId, name, prepared,
|
||||
* source, approximate}}|{ok:false, code, error}> (area/drawn/annotation)
|
||||
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
||||
*
|
||||
* @module data/analystEngine
|
||||
@@ -26,6 +30,7 @@
|
||||
|
||||
import { feedProvenanceEnvelope } from './layerSnapshot.js';
|
||||
import { pointInRing } from './naturalEarthRegions.js';
|
||||
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
|
||||
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
@@ -62,11 +67,14 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']);
|
||||
/** Derived per-record field for layers with a time field (hours before now). */
|
||||
const AGE_FIELD = 'ageHours';
|
||||
/** Spatial scope kinds `query()` accepts. */
|
||||
export const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
'view',
|
||||
'region',
|
||||
'radius',
|
||||
'anywhere',
|
||||
'area',
|
||||
'drawn',
|
||||
'annotation',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -74,7 +82,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
* @param {string[]} layerKeys Queried layer ids.
|
||||
* @returns {string[]} Field names, common fields first.
|
||||
*/
|
||||
export function analystFieldsFor(layerKeys) {
|
||||
function analystFieldsFor(layerKeys) {
|
||||
const fields = new Set(COMMON_FIELDS);
|
||||
for (const key of layerKeys) {
|
||||
const layer = ANALYST_LAYERS[key];
|
||||
@@ -104,47 +112,14 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
* One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}.
|
||||
* With `time: true`, both sides compare as epoch ms (ISO strings accepted).
|
||||
*/
|
||||
export function applyFilter(records, filter, { time = false } = {}) {
|
||||
const { field, op } = filter || {};
|
||||
if (!field || !op) return records;
|
||||
const value = time ? toEpochMs(filter.value) : filter.value;
|
||||
return records.filter((r) => {
|
||||
const got = time ? toEpochMs(r[field]) : r[field];
|
||||
if (got === null || got === undefined || (time && !Number.isFinite(got)))
|
||||
return false;
|
||||
if (time && (op === 'eq' || op === 'neq'))
|
||||
return (got === value) === (op === 'eq');
|
||||
switch (op) {
|
||||
case 'gt':
|
||||
return Number(got) > Number(value);
|
||||
case 'gte':
|
||||
return Number(got) >= Number(value);
|
||||
case 'lt':
|
||||
return Number(got) < Number(value);
|
||||
case 'lte':
|
||||
return Number(got) <= Number(value);
|
||||
case 'eq': {
|
||||
if (typeof got === 'boolean' || typeof value === 'boolean')
|
||||
return Boolean(got) === Boolean(value);
|
||||
return String(got).toLowerCase() === String(value).toLowerCase();
|
||||
}
|
||||
case 'neq':
|
||||
return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||
case 'contains':
|
||||
return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere'|'area', …}. */
|
||||
export function applyScope(records, scope, resolved) {
|
||||
if (!scope || scope.kind === 'anywhere') return records;
|
||||
if (resolved?.prepared) {
|
||||
return records.filter((r) =>
|
||||
pointInPreparedArea(resolved.prepared, r.lat, r.lon),
|
||||
);
|
||||
}
|
||||
if (scope.kind === 'region' && resolved?.ring) {
|
||||
return records.filter(
|
||||
(r) =>
|
||||
@@ -167,28 +142,6 @@ export function applyScope(records, scope, resolved) {
|
||||
return records;
|
||||
}
|
||||
|
||||
/** Numeric summary for the narration layer. */
|
||||
function summarize(items, sortField) {
|
||||
const summary = { count: items.length };
|
||||
if (sortField && items.length) {
|
||||
// A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past
|
||||
// the engine's argument limit.
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const item of items) {
|
||||
const value = Number(item[sortField]);
|
||||
if (!Number.isFinite(value)) continue;
|
||||
if (value < min) min = value;
|
||||
if (value > max) max = value;
|
||||
}
|
||||
if (Number.isFinite(min)) {
|
||||
summary[`${sortField}Min`] = min;
|
||||
summary[`${sortField}Max`] = max;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
const OPS_BY_TYPE = Object.freeze({
|
||||
number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
|
||||
time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
|
||||
@@ -213,7 +166,7 @@ const COMMON_UNITS = Object.freeze({
|
||||
* @param {string} field Field name.
|
||||
* @returns {'number'|'text'|'flag'|'time'|null} Field type.
|
||||
*/
|
||||
export function analystFieldType(layerKeys, field) {
|
||||
function analystFieldType(layerKeys, field) {
|
||||
if (!analystFieldsFor(layerKeys).includes(field)) return null;
|
||||
if (COMMON_TYPES[field]) return COMMON_TYPES[field];
|
||||
for (const key of layerKeys) {
|
||||
@@ -380,6 +333,24 @@ function normalizeSpec(spec, layerKeys) {
|
||||
return {
|
||||
refusal: refusal('BAD_SCOPE', 'A region scope needs a place name.'),
|
||||
};
|
||||
if (
|
||||
kind === 'area' &&
|
||||
(typeof scope.areaId !== 'string' || !scope.areaId.trim())
|
||||
)
|
||||
return {
|
||||
refusal: refusal(
|
||||
'BAD_SCOPE',
|
||||
'An area scope needs the areaId resolve_area returned.',
|
||||
),
|
||||
};
|
||||
if (
|
||||
kind === 'annotation' &&
|
||||
scope.id !== undefined &&
|
||||
(typeof scope.id !== 'string' || !scope.id.trim())
|
||||
)
|
||||
return {
|
||||
refusal: refusal('BAD_SCOPE', 'scope.id must be an annotation id.'),
|
||||
};
|
||||
if (scope.center !== undefined && scope.center !== null) {
|
||||
if (!validLat(scope.center.lat) || !validLon(scope.center.lon))
|
||||
return {
|
||||
@@ -516,7 +487,16 @@ const ANSWERABLE = new Set(['ok', 'empty']);
|
||||
|
||||
/** Rows per scan slice before yielding to the page. */
|
||||
const SCAN_CHUNK = 20_000;
|
||||
/**
|
||||
* A slice also ends after this long: a row inside a large area pays a ray
|
||||
* cast over every edge (a 6,000-vertex outline takes ~200 ms per 20,000 rows),
|
||||
* so the row count alone would still make long tasks.
|
||||
*/
|
||||
const SCAN_BUDGET_MS = 8;
|
||||
/** Rows between clock checks. */
|
||||
const SCAN_CLOCK_EVERY = 256;
|
||||
const yieldToPage = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const clockMs = () => globalThis.performance?.now?.() ?? Date.now();
|
||||
|
||||
/**
|
||||
* Compile the filters into one predicate over (layerKey, row).
|
||||
@@ -657,12 +637,19 @@ export async function selectAnalystRows(
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
let seen = 0;
|
||||
let sliceStart = clockMs();
|
||||
for (const { key, rows } of groups) {
|
||||
const kept = [];
|
||||
for (const row of rows) {
|
||||
if (++seen % SCAN_CHUNK === 0) {
|
||||
seen += 1;
|
||||
if (
|
||||
seen % SCAN_CHUNK === 0 ||
|
||||
(seen % SCAN_CLOCK_EVERY === 0 &&
|
||||
clockMs() - sliceStart >= SCAN_BUDGET_MS)
|
||||
) {
|
||||
await yieldToPage();
|
||||
if (!isCurrent()) return null;
|
||||
sliceStart = clockMs();
|
||||
}
|
||||
if (inScope && !inScope(row)) continue;
|
||||
if (!passes(key, row)) continue;
|
||||
@@ -710,9 +697,8 @@ export function createAnalystEngine(providers) {
|
||||
/**
|
||||
* Run one query.
|
||||
* @param {object} spec Query arguments (see the analyst_query tool).
|
||||
* @param {{isCurrent?: () => boolean, signal?: AbortSignal,
|
||||
* beforeCommit?: (result: object) => Promise<object|void>}} [options]
|
||||
* A query that is no longer current
|
||||
* @param {{signal?: AbortSignal, isCurrent?: () => boolean, beforeCommit?: (result: object) =>
|
||||
* Promise<object|void>}} [options] A query that is no longer current
|
||||
* (cancelled or superseded while awaiting a boundary or between scan
|
||||
* slices) returns CANCELLED and never replaces the follow-up memory.
|
||||
* `beforeCommit` lets a caller finish required async projection work and
|
||||
@@ -722,7 +708,7 @@ export function createAnalystEngine(providers) {
|
||||
*/
|
||||
async function query(
|
||||
spec = {},
|
||||
{ isCurrent = () => true, signal = undefined, beforeCommit = null } = {},
|
||||
{ signal = null, isCurrent = () => true, beforeCommit = null } = {},
|
||||
) {
|
||||
const cancelled = () =>
|
||||
refusal('CANCELLED', 'This query was superseded.', { cancelled: true });
|
||||
@@ -733,6 +719,7 @@ export function createAnalystEngine(providers) {
|
||||
{ coverage: { layersQueried: [], scope: 'none' } },
|
||||
);
|
||||
const followUp = Boolean(spec.followUp);
|
||||
const inheritsRememberedScope = followUp && spec.scope == null;
|
||||
// A follow-up re-filters the remembered set. Layers named explicitly
|
||||
// must be that set: "those hospitals" after an aircraft answer is a new
|
||||
// question, never a filter over the old aircraft.
|
||||
@@ -875,23 +862,44 @@ export function createAnalystEngine(providers) {
|
||||
}
|
||||
|
||||
// 2) Spatial scope
|
||||
const rememberedScope =
|
||||
followUp && !spec.scope ? lastResult.scopePresentation || null : null;
|
||||
const rememberedDistanceOf =
|
||||
followUp && !spec.scope ? lastResult.distanceOf || null : null;
|
||||
const rememberedPresentation = inheritsRememberedScope
|
||||
? lastResult.presentation || null
|
||||
: null;
|
||||
const rememberedCoverageNote =
|
||||
rememberedPresentation?.coverageNote ||
|
||||
(inheritsRememberedScope ? lastResult.coverage?.note : null);
|
||||
const rememberedDistanceOf = inheritsRememberedScope
|
||||
? lastResult.distanceOf || null
|
||||
: null;
|
||||
let resolvedScope = null;
|
||||
let scopeNote = rememberedScope?.coverage || 'anywhere';
|
||||
let scopeNote =
|
||||
rememberedPresentation?.coverageScope ||
|
||||
(inheritsRememberedScope ? lastResult.coverage?.scope : null) ||
|
||||
'anywhere';
|
||||
let scopeSource =
|
||||
rememberedPresentation?.coverageScopeSource ||
|
||||
(inheritsRememberedScope ? lastResult.coverage?.scopeSource : null) ||
|
||||
null;
|
||||
// Human phrasing for the same scope, so every spoken count can name what it
|
||||
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
||||
// arriving as a bare number that contradicts the panel.
|
||||
let scopeLabel = rememberedScope?.label || 'anywhere in the loaded data';
|
||||
let scopeDetail = rememberedScope?.detail || scopeLabel;
|
||||
let scopeLabel =
|
||||
rememberedPresentation?.scopeLabel || 'anywhere in the loaded data';
|
||||
let scopeDetail = rememberedPresentation?.display?.scope || scopeLabel;
|
||||
// A follow-up re-filters a set that was already scoped, so it only narrows
|
||||
// further when a new scope is given.
|
||||
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
||||
if (scope.kind === 'region') {
|
||||
const region = await providers.resolveRegionRing(scope.name, signal);
|
||||
if (!isCurrent()) return cancelled();
|
||||
let region;
|
||||
try {
|
||||
region = await providers.resolveRegionRing(scope.name, { signal });
|
||||
} catch (error) {
|
||||
// Cancellation belongs to the query owner, not an error's name: a
|
||||
// resource-local AbortError from a current lookup still propagates.
|
||||
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||
throw error;
|
||||
}
|
||||
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||
if (region?.error === 'region-timeout') {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -900,7 +908,32 @@ export function createAnalystEngine(providers) {
|
||||
coverage: { layersQueried, scope: `region:${scope.name}:timeout` },
|
||||
};
|
||||
}
|
||||
if (!region?.ring) {
|
||||
if (region?.needsClarification) {
|
||||
return refusal(
|
||||
'NEEDS_CLARIFICATION',
|
||||
region.error ||
|
||||
`"${scope.name}" names more than one place — ask which one.`,
|
||||
{
|
||||
needsClarification: true,
|
||||
query: scope.name,
|
||||
candidates: region.candidates || [],
|
||||
// The way back once the user picks: count over that area.
|
||||
then: 'After the user picks, call resolve_area with that candidateId, then analyst_query with scope {kind:"area", areaId}.',
|
||||
coverage: {
|
||||
layersQueried,
|
||||
scope: `region:${scope.name}:ambiguous`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const prepared =
|
||||
region?.prepared ||
|
||||
(region?.geometry
|
||||
? prepareArea(region.geometry)
|
||||
: region?.ring
|
||||
? prepareArea([[region.ring]])
|
||||
: null);
|
||||
if (!prepared) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'REGION_UNRESOLVED',
|
||||
@@ -908,10 +941,39 @@ export function createAnalystEngine(providers) {
|
||||
coverage: { layersQueried, scope: `region:${scope.name}:unresolved` },
|
||||
};
|
||||
}
|
||||
resolvedScope = region;
|
||||
resolvedScope = { ...region, prepared };
|
||||
scopeNote = `region:${region.name}`;
|
||||
scopeSource = region.source || null;
|
||||
scopeLabel = `over ${region.name}`;
|
||||
scopeDetail = scopeLabel;
|
||||
const regionSource = region.sourceLabel || region.source;
|
||||
scopeDetail = `${scopeLabel}${region.approximate ? ' (approximate outline)' : ''}${regionSource ? ` · ${regionSource}` : ''}`;
|
||||
} else if (
|
||||
scope.kind === 'area' ||
|
||||
scope.kind === 'drawn' ||
|
||||
scope.kind === 'annotation'
|
||||
) {
|
||||
const found = providers.resolveAreaScope
|
||||
? await providers.resolveAreaScope(scope)
|
||||
: {
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: 'Area scopes are not available here.',
|
||||
};
|
||||
if (!isCurrent()) return cancelled();
|
||||
if (!found?.ok || !found.area?.prepared) {
|
||||
return refusal(
|
||||
found?.code || 'AREA_UNKNOWN',
|
||||
found?.error || 'That area is not available.',
|
||||
{ coverage: { layersQueried, scope: `${scope.kind}:unresolved` } },
|
||||
);
|
||||
}
|
||||
const area = found.area;
|
||||
resolvedScope = { prepared: area.prepared, name: area.name };
|
||||
scopeNote = `area:${area.areaId || scope.kind}`;
|
||||
scopeLabel = `inside ${area.name}`;
|
||||
const sourceLabel = area.sourceLabel || area.source;
|
||||
scopeDetail = `${scopeLabel}${area.approximate ? ' (approximate outline)' : ''}${sourceLabel ? ` · ${sourceLabel}` : ''}`;
|
||||
if (area.areaId) resolvedScope.areaId = area.areaId;
|
||||
} else if (scope.kind === 'radius') {
|
||||
// An explicit center always wins. Otherwise, when Contacts is active its
|
||||
// SUBJECT is the centre the operator is actually reasoning about: the
|
||||
@@ -956,25 +1018,21 @@ export function createAnalystEngine(providers) {
|
||||
scopeLabel = 'in view';
|
||||
scopeDetail = `within ${Math.round(view.viewRadiusKm)} km of the view centre`;
|
||||
}
|
||||
const inScope =
|
||||
scope.kind === 'region' && resolvedScope?.ring
|
||||
const inScope = resolvedScope?.prepared
|
||||
? (row) => pointInPreparedArea(resolvedScope.prepared, row.lat, row.lon)
|
||||
: (scope.kind === 'radius' || scope.kind === 'view') &&
|
||||
resolvedScope?.center &&
|
||||
Number.isFinite(resolvedScope.km)
|
||||
? (row) =>
|
||||
Number.isFinite(row.lat) &&
|
||||
Number.isFinite(row.lon) &&
|
||||
pointInRing(resolvedScope.ring, row.lat, row.lon)
|
||||
: (scope.kind === 'radius' || scope.kind === 'view') &&
|
||||
resolvedScope?.center &&
|
||||
Number.isFinite(resolvedScope.km)
|
||||
? (row) =>
|
||||
Number.isFinite(row.lat) &&
|
||||
Number.isFinite(row.lon) &&
|
||||
haversineKm(
|
||||
resolvedScope.center.lat,
|
||||
resolvedScope.center.lon,
|
||||
row.lat,
|
||||
row.lon,
|
||||
) <= resolvedScope.km
|
||||
: null;
|
||||
haversineKm(
|
||||
resolvedScope.center.lat,
|
||||
resolvedScope.center.lon,
|
||||
row.lat,
|
||||
row.lon,
|
||||
) <= resolvedScope.km
|
||||
: null;
|
||||
|
||||
// 3) Filter, count and rank in one pass, yielding between slices.
|
||||
const selection = await selectAnalystRows(groups, {
|
||||
@@ -1042,14 +1100,25 @@ export function createAnalystEngine(providers) {
|
||||
// For on-screen text; the model speaks the count and scopeLabel only.
|
||||
display: {
|
||||
scope: scopeDetail,
|
||||
...(windows.length ? { window: windows.join('; ') } : {}),
|
||||
...(caveats.length ? { caveat: caveats.join('; ') } : {}),
|
||||
...(rememberedPresentation?.display?.window || windows.length
|
||||
? {
|
||||
window:
|
||||
rememberedPresentation?.display?.window || windows.join('; '),
|
||||
}
|
||||
: {}),
|
||||
...(rememberedPresentation?.display?.caveat || caveats.length
|
||||
? {
|
||||
caveat:
|
||||
rememberedPresentation?.display?.caveat || caveats.join('; '),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
coverage: {
|
||||
layersQueried,
|
||||
records: totals,
|
||||
scope: scopeNote,
|
||||
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
...(rememberedCoverageNote ? { note: rememberedCoverageNote } : {}),
|
||||
...(queriedSnapshots.length
|
||||
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
||||
: {}),
|
||||
@@ -1059,36 +1128,102 @@ export function createAnalystEngine(providers) {
|
||||
// than implying a view-centred answer.
|
||||
...(resolvedScope?.centeredOn
|
||||
? { centeredOn: resolvedScope.centeredOn }
|
||||
: rememberedPresentation?.centeredOn
|
||||
? { centeredOn: rememberedPresentation.centeredOn }
|
||||
: {}),
|
||||
...(rememberedPresentation?.window
|
||||
? { window: structuredClone(rememberedPresentation.window) }
|
||||
: {}),
|
||||
...(resolvedScope?.areaId
|
||||
? { areaId: resolvedScope.areaId }
|
||||
: rememberedPresentation?.areaId
|
||||
? { areaId: rememberedPresentation.areaId }
|
||||
: {}),
|
||||
};
|
||||
const memoryOverride =
|
||||
typeof beforeCommit === 'function'
|
||||
? (await beforeCommit(result)) || null
|
||||
: null;
|
||||
if (!isCurrent()) return cancelled();
|
||||
const committedCoverage = memoryOverride?.coverage || result.coverage;
|
||||
lastResult = {
|
||||
matched: memoryOverride?.matched || selection.matched,
|
||||
distanceOf: memoryOverride?.distanceOf || rememberedDistanceOf,
|
||||
layerKeys: [...(memoryOverride?.layerKeys || layers)],
|
||||
coverage: committedCoverage,
|
||||
coverage: memoryOverride?.coverage || result.coverage,
|
||||
unanswered: [
|
||||
...(memoryOverride?.unanswered === undefined
|
||||
? unanswered
|
||||
: memoryOverride.unanswered),
|
||||
],
|
||||
scopePresentation: {
|
||||
label: memoryOverride?.scopeLabel || result.scopeLabel,
|
||||
detail: memoryOverride?.scopeDetail || result.display.scope,
|
||||
coverage: committedCoverage.scope,
|
||||
note: committedCoverage.note || null,
|
||||
},
|
||||
presentation: structuredClone(
|
||||
memoryOverride?.presentation || {
|
||||
scopeLabel: result.scopeLabel,
|
||||
display: result.display,
|
||||
coverageScope: result.coverage?.scope,
|
||||
...(result.coverage?.scopeSource
|
||||
? { coverageScopeSource: result.coverage.scopeSource }
|
||||
: {}),
|
||||
...(result.coverage?.note
|
||||
? { coverageNote: result.coverage.note }
|
||||
: {}),
|
||||
...(result.centeredOn ? { centeredOn: result.centeredOn } : {}),
|
||||
...(result.window ? { window: result.window } : {}),
|
||||
...(result.areaId ? { areaId: result.areaId } : {}),
|
||||
},
|
||||
),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
/**
|
||||
* Make another tool's result set "the last answer" (osm_query's places),
|
||||
* so a follow-up filters exactly what the user just heard about.
|
||||
* @param {string} layerKey Queryable layer the rows belong to.
|
||||
* @param {Array<object>} rows Records in that layer's analyst shape.
|
||||
* @param {{scope?: string, presentation?: object|null, truncated?: boolean}} [options]
|
||||
* `presentation` is how the set was scoped ("inside Kathmandu"), so a
|
||||
* follow-up names the same scope instead of "anywhere". `truncated`
|
||||
* says the set is only the first part of a larger one, so follow-ups
|
||||
* stay incomplete (a count is a floor) rather than claiming the whole.
|
||||
*/
|
||||
remember(
|
||||
layerKey,
|
||||
rows,
|
||||
{ scope = 'handoff', presentation = null, truncated = false } = {},
|
||||
) {
|
||||
if (!ANALYST_LAYERS[layerKey] || !Array.isArray(rows)) return false;
|
||||
const capped = truncated === true;
|
||||
// A capped set's true size is unknown.
|
||||
const total = capped ? null : rows.length;
|
||||
lastResult = {
|
||||
...(presentation
|
||||
? { presentation: structuredClone(presentation) }
|
||||
: {}),
|
||||
matched: [{ key: layerKey, rows: rows.slice() }],
|
||||
layerKeys: [layerKey],
|
||||
coverage: {
|
||||
layersQueried: [
|
||||
{
|
||||
layerKey,
|
||||
status: rows.length ? 'ok' : 'empty',
|
||||
returned: rows.length,
|
||||
total,
|
||||
truncated: capped,
|
||||
},
|
||||
],
|
||||
records: {
|
||||
returned: rows.length,
|
||||
total,
|
||||
truncated: capped,
|
||||
},
|
||||
scope,
|
||||
followUp: false,
|
||||
},
|
||||
};
|
||||
return true;
|
||||
},
|
||||
reset() {
|
||||
lastResult = null;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
|
||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
||||
@@ -131,6 +133,179 @@ test('analyst: count flights over a region', async () => {
|
||||
assert.ok(r.coverage.scope.includes('Texland'));
|
||||
});
|
||||
|
||||
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
|
||||
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async () => ({ ...TEXLAND, source }),
|
||||
});
|
||||
const result = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.coverage.scopeSource, source);
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
|
||||
const lookups = [];
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
lookups.push(name);
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const airborne = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'onGround', op: 'eq', value: false }],
|
||||
});
|
||||
const military = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'military', op: 'eq', value: true }],
|
||||
});
|
||||
|
||||
assert.equal(first.count, 3);
|
||||
assert.equal(airborne.count, 2);
|
||||
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
|
||||
for (const result of [first, airborne, military]) {
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.scopeLabel, 'over Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
}
|
||||
assert.deepEqual(lookups, ['Texland']);
|
||||
});
|
||||
|
||||
test('analyst: an explicit replacement region replaces retained provenance', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: {
|
||||
name,
|
||||
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
|
||||
source: 'remote-boundary',
|
||||
},
|
||||
});
|
||||
await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({
|
||||
followUp: true,
|
||||
scope: { kind: 'region', name: 'Northern Texland' },
|
||||
});
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(result.coverage.scope, 'region:Northern Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'remote-boundary');
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
|
||||
for (const scope of [
|
||||
{ kind: 'anywhere' },
|
||||
{ kind: 'view' },
|
||||
{ kind: 'radius', km: 150 },
|
||||
{ kind: 'region', name: 'Uncredited Texland' },
|
||||
]) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => ({
|
||||
...TEXLAND,
|
||||
name,
|
||||
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({ followUp: true, scope });
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
assert.equal(first.coverage.scopeSource, 'natural-earth');
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.equal(result.ok, true, scope.kind);
|
||||
assert.equal(result.count, 3, scope.kind);
|
||||
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
|
||||
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const [label, committedScope, scopeSource] of [
|
||||
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
|
||||
['window without a boundary source', 'window:presented', null],
|
||||
]) {
|
||||
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
|
||||
const committedSnapshot = {
|
||||
id: 'flights', enabled: true, feedState: 'stale',
|
||||
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
|
||||
};
|
||||
let reads = 0;
|
||||
let lookups = 0;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => { reads++; return FLIGHTS; },
|
||||
getLayerSnapshot: () => ({
|
||||
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
|
||||
}),
|
||||
resolveRegionRing: async () => {
|
||||
lookups++;
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
await engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{
|
||||
beforeCommit: async (result) => {
|
||||
assert.equal(result.count, 3);
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
const { scopeSource: _originalSource, ...coverage } = result.coverage;
|
||||
return {
|
||||
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
|
||||
coverage: {
|
||||
...coverage,
|
||||
scope: committedScope,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
feedProvenance: { layers: [committedSnapshot] },
|
||||
},
|
||||
presentation: {
|
||||
scopeLabel: 'the presented cohort',
|
||||
display: { scope: 'the presented cohort window' },
|
||||
coverageScope: committedScope,
|
||||
...(scopeSource ? { coverageScopeSource: scopeSource } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(followUp.count, 1);
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(followUp.scopeLabel, 'the presented cohort');
|
||||
assert.equal(followUp.display.scope, 'the presented cohort window');
|
||||
assert.equal(followUp.coverage.scope, committedScope);
|
||||
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
|
||||
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
|
||||
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
|
||||
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
|
||||
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
|
||||
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -265,6 +440,87 @@ test('analyst: failed pre-commit work cannot replace follow-up memory', async ()
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
|
||||
getLayerSnapshot: (key) => ({
|
||||
enabled: key === 'flights',
|
||||
feedState: key === 'flights' ? 'nominal' : 'off',
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await eng.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
});
|
||||
assert.equal(first.partial, true);
|
||||
assert.deepEqual(first.unanswered, ['military']);
|
||||
|
||||
const followUp = await eng.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.partial, true);
|
||||
assert.deepEqual(followUp.unanswered, ['military']);
|
||||
assert.deepEqual(
|
||||
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
first.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
let current = true;
|
||||
const stale = await eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
isCurrent: () => current,
|
||||
beforeCommit: async () => {
|
||||
current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(
|
||||
followUp.items.map((item) => item.id),
|
||||
['SWA1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
await assert.rejects(
|
||||
eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
beforeCommit: async () => {
|
||||
throw new Error('projection failed');
|
||||
},
|
||||
},
|
||||
),
|
||||
/projection failed/,
|
||||
);
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(
|
||||
followUp.items.map((item) => item.id),
|
||||
['SWA1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: unresolved region is an honest failure, not empty success', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Atlantis' } });
|
||||
assert.equal(r.ok, false);
|
||||
@@ -289,7 +545,7 @@ test('analyst: region lookup receives and obeys the owning action signal', async
|
||||
let receivedSignal = null;
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (_name, signal) => {
|
||||
resolveRegionRing: async (_name, { signal }) => {
|
||||
receivedSignal = signal;
|
||||
await new Promise((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true }),
|
||||
@@ -317,6 +573,138 @@ test('analyst: region lookup receives and obeys the owning action signal', async
|
||||
assert.equal(result.cancelled, true);
|
||||
});
|
||||
|
||||
for (const [label, reason, trackAuthority] of [
|
||||
['default abort reason', undefined, false],
|
||||
['custom error reason', new Error('operator stopped'), false],
|
||||
['primitive reason and revoked authority', 'operator stopped', true],
|
||||
]) {
|
||||
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
|
||||
const started = Promise.withResolvers();
|
||||
const geocode = Promise.withResolvers();
|
||||
const controller = new AbortController();
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
|
||||
method,
|
||||
async () => { throw new Error('offline region query reached feature source'); },
|
||||
])),
|
||||
});
|
||||
const placeSearch = {
|
||||
geocode(name, { signal }) {
|
||||
lookups.push(name);
|
||||
assert.equal(signal, controller.signal);
|
||||
started.resolve();
|
||||
return geocode.promise;
|
||||
},
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name, { signal } = {}) => resolveRegionRingForQuery(
|
||||
name, signal, placeSearch, { budgetMs: Infinity },
|
||||
),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.coverage.scopeSource, 'bundled-admin');
|
||||
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
|
||||
let commits = 0;
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
|
||||
beforeCommit: async () => { commits++; },
|
||||
},
|
||||
);
|
||||
await started.promise;
|
||||
controller.abort(reason);
|
||||
// Let an actual late geocode result cross the resolver's post-await check.
|
||||
geocode.resolve({ place: {
|
||||
lat: 5, lng: -73, label: 'Cordillera Oriental',
|
||||
name: 'Cordillera Oriental', types: ['natural_feature'],
|
||||
} });
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(commits, 0);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
|
||||
assert.equal(followUp.scopeLabel, first.scopeLabel);
|
||||
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
|
||||
const lookup = Promise.withResolvers();
|
||||
let current = true;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: lookup.promise,
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ isCurrent: () => current },
|
||||
);
|
||||
current = false;
|
||||
lookup.reject(new Error('late lookup failed'));
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
});
|
||||
|
||||
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
|
||||
const controller = new AbortController();
|
||||
const lookup = Promise.withResolvers();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: () => lookup.promise,
|
||||
});
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
|
||||
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
|
||||
const controller = new AbortController();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
await assert.rejects(
|
||||
engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
(thrown) => thrown === error,
|
||||
);
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -417,7 +805,9 @@ test('unknown fields, operators, scopes and wrong-typed values are refused with
|
||||
assert.equal(num.code, 'BAD_VALUE');
|
||||
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
|
||||
assert.equal(scope.code, 'UNKNOWN_SCOPE');
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere', 'area', 'drawn', 'annotation']);
|
||||
const noId = await engine.query({ layers: ['flights'], scope: { kind: 'area' } });
|
||||
assert.equal(noId.code, 'BAD_SCOPE', 'an area scope needs its areaId');
|
||||
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
|
||||
assert.equal(sort.code, 'UNKNOWN_FIELD');
|
||||
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
|
||||
@@ -660,3 +1050,193 @@ test('analyst: a follow-up naming other layers than the last answer is refused',
|
||||
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
|
||||
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
|
||||
});
|
||||
|
||||
// ── Area handles: region multipolygons and the area / drawn / annotation scopes ──
|
||||
|
||||
const box = (w, s, e, n) => [[w, s], [e, s], [e, n], [w, n], [w, s]];
|
||||
|
||||
function makeAreaEngine(overrides = {}) {
|
||||
return createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: a region counts every part of a multipolygon and skips its holes', async () => {
|
||||
// Main part around Austin with a hole over the airport ground traffic, and a
|
||||
// separate island over Oregon: the old largest-ring shortcut dropped N123.
|
||||
const engine = makeAreaEngine({
|
||||
resolveRegionRing: async () => ({
|
||||
name: 'Archipelago',
|
||||
geometry: [
|
||||
[box(-100, 28, -94, 33), box(-97.7, 30.1, -97.6, 30.25)],
|
||||
[box(-123, 44, -121, 46)],
|
||||
],
|
||||
source: 'osm',
|
||||
}),
|
||||
});
|
||||
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Archipelago' }, limit: 10 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.items.map((i) => i.id).sort(), ['N123', 'RCH01']);
|
||||
assert.equal(r.count, 2, 'SWA1 and GND1 sit in the hole');
|
||||
assert.match(r.display.scope, /over Archipelago · osm/);
|
||||
});
|
||||
|
||||
test('analyst: an ambiguous region asks instead of counting', async () => {
|
||||
const engine = makeAreaEngine({
|
||||
resolveRegionRing: async () => ({
|
||||
ok: false,
|
||||
needsClarification: true,
|
||||
candidates: [{ candidateId: 'osm:r1', name: 'Georgia', level: 'country' }],
|
||||
error: '"Georgia" matches 2 places — ask which one.',
|
||||
}),
|
||||
});
|
||||
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Georgia' } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'NEEDS_CLARIFICATION');
|
||||
assert.equal(r.candidates.length, 1);
|
||||
assert.match(r.then, /resolve_area.*candidateId.*analyst_query.*kind.*area/, 'the model gets a route back');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
test('analyst: region lookup receives the caller lifetime signal', async () => {
|
||||
const controller = new AbortController();
|
||||
let received;
|
||||
const engine = makeAreaEngine({
|
||||
resolveRegionRing: async (_name, options) => {
|
||||
received = options?.signal;
|
||||
return { error: 'region-timeout' };
|
||||
},
|
||||
});
|
||||
const result = await engine.query(
|
||||
{
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Signal Region' },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(received, controller.signal);
|
||||
assert.equal(result.code, 'region-timeout');
|
||||
});
|
||||
|
||||
test('analyst: an area scope counts inside the stored handle and names it', async () => {
|
||||
const { prepareArea } = await import('./areaGeometry.js');
|
||||
const prepared = prepareArea([[box(-99, 30, -97.5, 31)]]);
|
||||
const seen = [];
|
||||
const engine = makeAreaEngine({
|
||||
resolveAreaScope: async (scope) => {
|
||||
seen.push(scope);
|
||||
if (scope.kind === 'area' && scope.areaId === 'osm:r42')
|
||||
return { ok: true, area: { areaId: 'osm:r42', name: 'Hill Country', prepared, source: 'osm' } };
|
||||
if (scope.kind === 'drawn')
|
||||
return { ok: true, area: { areaId: 'anno:anno-7', name: 'the drawn area', prepared, source: 'drawn' } };
|
||||
return { ok: false, code: 'AREA_UNKNOWN', error: 'No such area' };
|
||||
},
|
||||
});
|
||||
const fires = await engine.query({ layers: ['local-firms'], scope: { kind: 'area', areaId: 'osm:r42' } });
|
||||
assert.equal(fires.ok, true);
|
||||
assert.equal(fires.count, 2);
|
||||
assert.equal(fires.scopeLabel, 'inside Hill Country');
|
||||
assert.equal(fires.areaId, 'osm:r42');
|
||||
assert.equal(fires.coverage.scope, 'area:osm:r42');
|
||||
const followUp = await engine.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.scopeLabel, 'inside Hill Country');
|
||||
assert.match(followUp.display.scope, /inside Hill Country/);
|
||||
assert.equal(followUp.coverage.scope, 'area:osm:r42');
|
||||
assert.equal(followUp.areaId, 'osm:r42');
|
||||
const drawn = await engine.query({ layers: ['flights'], scope: { kind: 'drawn' } });
|
||||
assert.equal(drawn.ok, true);
|
||||
assert.equal(drawn.count, 2, 'SWA1 and GND1 sit inside the drawn box');
|
||||
const missing = await engine.query({ layers: ['flights'], scope: { kind: 'area', areaId: 'osm:r0' } });
|
||||
assert.equal(missing.ok, false);
|
||||
assert.equal(missing.code, 'AREA_UNKNOWN');
|
||||
assert.deepEqual(seen.map((s) => s.kind), ['area', 'drawn', 'area']);
|
||||
});
|
||||
|
||||
test('analyst: an area scope superseded while resolving is cancelled and leaves no memory', async () => {
|
||||
const { prepareArea } = await import('./areaGeometry.js');
|
||||
let current = true;
|
||||
const engine = makeAreaEngine({
|
||||
resolveAreaScope: async () => {
|
||||
current = false; // a newer turn arrived while the area was looked up
|
||||
return { ok: true, area: { name: 'X', prepared: prepareArea([[box(-100, 28, -94, 33)]]) } };
|
||||
},
|
||||
});
|
||||
const r = await engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'annotation' } },
|
||||
{ isCurrent: () => current },
|
||||
);
|
||||
assert.equal(r.code, 'CANCELLED');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
test('analyst: without an area provider the area scopes refuse plainly', async () => {
|
||||
const r = await makeAreaEngine().query({ layers: ['flights'], scope: { kind: 'drawn' } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'AREA_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('a follow-up on handed-off places keeps the area they were found in', async () => {
|
||||
const rows = [
|
||||
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
|
||||
{ id: 'node/2', lat: 27.71, lon: 85.31, name: 'Teaching Hospital', kind: 'hospital', emergency: 'no' },
|
||||
];
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'osm-places' ? rows : []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
|
||||
});
|
||||
assert.equal(
|
||||
engine.remember('osm-places', rows, {
|
||||
presentation: { scopeLabel: 'inside Kathmandu', coverageScope: 'area:area-1', areaId: 'area-1' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const result = await engine.query({
|
||||
layers: ['osm-places'],
|
||||
followUp: true,
|
||||
filters: [{ field: 'emergency', op: 'eq', value: 'yes' }],
|
||||
});
|
||||
assert.equal(result.count, 1);
|
||||
assert.equal(result.scopeLabel, 'inside Kathmandu');
|
||||
assert.equal(result.coverage.scope, 'area:area-1');
|
||||
});
|
||||
|
||||
test('a follow-up on a capped handed-off set is not complete', async () => {
|
||||
const rows = [
|
||||
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
|
||||
];
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'osm-places' ? rows : []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
|
||||
});
|
||||
engine.remember('osm-places', rows, { truncated: true });
|
||||
const result = await engine.query({ layers: ['osm-places'], followUp: true });
|
||||
assert.equal(result.count, 1);
|
||||
assert.equal(result.complete, false);
|
||||
assert.equal(result.coverage.records.truncated, true);
|
||||
assert.equal(result.coverage.records.total, null, 'the true size is unknown');
|
||||
});
|
||||
|
||||
test('a slow scan yields on a time budget, not only every 20,000 rows', async () => {
|
||||
// 4,000 rows that each cost ~0.05 ms to read: ~200 ms of work in one slice
|
||||
// under a row-count budget alone.
|
||||
const slow = () => {
|
||||
const until = performance.now() + 0.05;
|
||||
while (performance.now() < until);
|
||||
return 5;
|
||||
};
|
||||
const rows = Array.from({ length: 4_000 }, (_, i) => ({ id: `S${i}`, lat: 0, lon: 0, get frp() { return slow(); } }));
|
||||
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
|
||||
// Count the scan's yields (other timers run only when it yields), not wall
|
||||
// gaps, so a loaded machine cannot fail it.
|
||||
let ticks = 0;
|
||||
const timer = setInterval(() => ticks++, 0);
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, filters: [{ field: 'frp', op: 'gt', value: 1 }] });
|
||||
clearInterval(timer);
|
||||
assert.equal(result.count, 4_000);
|
||||
assert.ok(ticks >= 5, `the scan yielded ${ticks} times in ~200 ms of work`);
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Area geometry for voice area handles: normalization, containment, size and
|
||||
* display simplification over full MultiPolygons (islands and holes kept).
|
||||
*
|
||||
* Coordinates follow GeoJSON: a MultiPolygon is `Array<Polygon>`, a Polygon is
|
||||
* `Array<Ring>` with the outer ring first and holes after it, a Ring is
|
||||
* `Array<[lon, lat]>`.
|
||||
*
|
||||
* Counting uses the geometry exactly as resolved. Display simplification
|
||||
* (`displayParts`) makes a separate copy for drawing and never feeds back into
|
||||
* containment.
|
||||
*
|
||||
* Antimeridian: a ring whose consecutive longitudes jump by more than 180° is
|
||||
* unwrapped into one continuous span (e.g. 170..190) when the jumps cancel
|
||||
* out. Containment then tests the point at lon, lon + 360 and lon − 360. A ring
|
||||
* that circles a pole (jumps that do not cancel) is used as given.
|
||||
*
|
||||
* Pure: no Cesium, DOM or network.
|
||||
* @module data/areaGeometry
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (degrees) => (degrees * Math.PI) / 180;
|
||||
|
||||
const validPair = (p) =>
|
||||
Array.isArray(p) &&
|
||||
Number.isFinite(p[0]) &&
|
||||
Number.isFinite(p[1]) &&
|
||||
Math.abs(p[0]) <= 540 &&
|
||||
Math.abs(p[1]) <= 90;
|
||||
|
||||
const samePoint = (a, b, eps = 1e-9) =>
|
||||
Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
|
||||
|
||||
/** A ring as closed [lon, lat] pairs, or null with fewer than 3 distinct points. */
|
||||
function cleanRing(ring) {
|
||||
if (!Array.isArray(ring)) return null;
|
||||
const out = [];
|
||||
for (const p of ring) {
|
||||
if (!validPair(p)) continue;
|
||||
const pair = [Number(p[0]), Number(p[1])];
|
||||
if (out.length && samePoint(out[out.length - 1], pair)) continue;
|
||||
out.push(pair);
|
||||
}
|
||||
if (out.length && samePoint(out[0], out[out.length - 1])) out.pop();
|
||||
if (out.length < 3) return null;
|
||||
out.push([out[0][0], out[0][1]]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON Polygon/MultiPolygon geometry (or bare coordinates) → MultiPolygon
|
||||
* coordinates with closed rings; null when nothing usable remains. Degenerate
|
||||
* holes are dropped; a polygon whose outer ring is degenerate is dropped whole.
|
||||
* @param {object|Array} geometry
|
||||
* @returns {Array|null}
|
||||
*/
|
||||
export function normalizeMultiPolygon(geometry) {
|
||||
let polygons;
|
||||
if (Array.isArray(geometry)) {
|
||||
// Bare coordinates: a ring, a polygon or a multipolygon, by nesting depth.
|
||||
const depth = (value) => (Array.isArray(value) ? 1 + depth(value[0]) : 0);
|
||||
const d = depth(geometry);
|
||||
polygons = d === 2 ? [[geometry]] : d === 3 ? [geometry] : geometry;
|
||||
} else if (geometry?.type === 'Polygon') polygons = [geometry.coordinates];
|
||||
else if (geometry?.type === 'MultiPolygon') polygons = geometry.coordinates;
|
||||
else return null;
|
||||
const out = [];
|
||||
for (const polygon of Array.isArray(polygons) ? polygons : []) {
|
||||
if (!Array.isArray(polygon)) continue;
|
||||
const outer = cleanRing(polygon[0]);
|
||||
if (!outer) continue;
|
||||
const holes = polygon.slice(1).map(cleanRing).filter(Boolean);
|
||||
out.push([outer, ...holes]);
|
||||
}
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longitudes made continuous across the antimeridian. Returns the ring as
|
||||
* given when it has no jump, or when its jumps do not cancel (a ring that
|
||||
* circles a pole).
|
||||
*/
|
||||
export function unwrapRing(ring) {
|
||||
let shift = 0;
|
||||
let jumped = false;
|
||||
const out = [[ring[0][0], ring[0][1]]];
|
||||
for (let i = 1; i < ring.length; i += 1) {
|
||||
const delta = ring[i][0] - ring[i - 1][0];
|
||||
if (delta > 180) {
|
||||
shift -= 360;
|
||||
jumped = true;
|
||||
} else if (delta < -180) {
|
||||
shift += 360;
|
||||
jumped = true;
|
||||
}
|
||||
out.push([ring[i][0] + shift, ring[i][1]]);
|
||||
}
|
||||
if (!jumped) return ring;
|
||||
return shift === 0 ? out : ring;
|
||||
}
|
||||
|
||||
function ringBbox(ring) {
|
||||
let w = Infinity;
|
||||
let s = Infinity;
|
||||
let e = -Infinity;
|
||||
let n = -Infinity;
|
||||
for (const [lon, lat] of ring) {
|
||||
if (lon < w) w = lon;
|
||||
if (lon > e) e = lon;
|
||||
if (lat < s) s = lat;
|
||||
if (lat > n) n = lat;
|
||||
}
|
||||
return [w, s, e, n];
|
||||
}
|
||||
|
||||
/** Even-odd ray cast. */
|
||||
function pointInRingXY(ring, lon, lat) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const xi = ring[i][0];
|
||||
const yi = ring[i][1];
|
||||
const xj = ring[j][0];
|
||||
const yj = ring[j][1];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
const wrap180 = (lon) => ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
const wrap360 = (lon) => {
|
||||
const wrapped = lon % 360;
|
||||
return wrapped < 0 ? wrapped + 360 : wrapped;
|
||||
};
|
||||
|
||||
/** The shortest circular longitude interval containing every outer-ring vertex. */
|
||||
function longitudeEnvelope(parts) {
|
||||
const values = parts
|
||||
.flatMap(({ outer }) => outer.map(([lon]) => wrap360(lon)))
|
||||
.sort((a, b) => a - b);
|
||||
if (values.length === 0) return null;
|
||||
let gapIndex = values.length - 1;
|
||||
let largestGap = values[0] + 360 - values.at(-1);
|
||||
for (let i = 0; i < values.length - 1; i += 1) {
|
||||
const gap = values[i + 1] - values[i];
|
||||
if (gap > largestGap) {
|
||||
largestGap = gap;
|
||||
gapIndex = i;
|
||||
}
|
||||
}
|
||||
const start = values[(gapIndex + 1) % values.length];
|
||||
let end = values[gapIndex];
|
||||
if (end < start) end += 360;
|
||||
const span = end - start;
|
||||
if (span >= 360 - 1e-9) return { west: -180, east: 180, crosses: false };
|
||||
const west = start > 180 ? start - 360 : start;
|
||||
const endOnCircle = end >= 360 ? end - 360 : end;
|
||||
const east = endOnCircle > 180 ? endOnCircle - 360 : endOnCircle;
|
||||
return { west, east, crosses: west > east };
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a MultiPolygon for repeated containment tests: unwrapped rings with
|
||||
* per-part bounding boxes, plus the overall bbox as [west, south, east, north]
|
||||
* in −180..180 (west > east when it crosses the antimeridian).
|
||||
* @param {Array} coordinates MultiPolygon coordinates.
|
||||
* @returns {{parts: Array, bbox: number[], vertexCount: number, crossesAntimeridian: boolean}|null}
|
||||
*/
|
||||
export function prepareArea(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates);
|
||||
if (!multi) return null;
|
||||
let vertexCount = 0;
|
||||
let crosses = false;
|
||||
const parts = multi.map((polygon) => {
|
||||
const outer = unwrapRing(polygon[0]);
|
||||
if (outer !== polygon[0]) crosses = true;
|
||||
const holes = polygon.slice(1).map((ring) => {
|
||||
let hole = unwrapRing(ring);
|
||||
// Keep a hole in the same longitude frame as its outer ring.
|
||||
const hb = ringBbox(hole);
|
||||
const ob = ringBbox(outer);
|
||||
const mid = (hb[0] + hb[2]) / 2;
|
||||
if (mid < ob[0]) hole = hole.map(([x, y]) => [x + 360, y]);
|
||||
else if (mid > ob[2]) hole = hole.map(([x, y]) => [x - 360, y]);
|
||||
return hole;
|
||||
});
|
||||
for (const ring of polygon) vertexCount += ring.length;
|
||||
return { outer, holes, bbox: ringBbox(outer) };
|
||||
});
|
||||
let south = Infinity;
|
||||
let north = -Infinity;
|
||||
for (const { bbox } of parts) {
|
||||
south = Math.min(south, bbox[1]);
|
||||
north = Math.max(north, bbox[3]);
|
||||
}
|
||||
const envelope = longitudeEnvelope(parts);
|
||||
const bbox = [envelope.west, south, envelope.east, north];
|
||||
crosses ||= envelope.crosses;
|
||||
return { parts, bbox, vertexCount, crossesAntimeridian: crosses };
|
||||
}
|
||||
|
||||
function inPart(part, lon, lat) {
|
||||
const [w, s, e, n] = part.bbox;
|
||||
if (lat < s || lat > n || lon < w || lon > e) return false;
|
||||
if (!pointInRingXY(part.outer, lon, lat)) return false;
|
||||
for (const hole of part.holes)
|
||||
if (pointInRingXY(hole, lon, lat)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a point lies inside the area: inside some part's outer ring and in
|
||||
* none of that part's holes. Points exactly on an edge follow the ray-cast
|
||||
* convention and may land either side.
|
||||
* @param {object} prepared From prepareArea.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pointInPreparedArea(prepared, lat, lon) {
|
||||
if (!prepared || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
for (const part of prepared.parts) {
|
||||
if (inPart(part, lon, lat)) return true;
|
||||
if (inPart(part, lon + 360, lat)) return true;
|
||||
if (inPart(part, lon - 360, lat)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Spherical-excess ring area in km² (unsigned). */
|
||||
export function ringAreaKm2(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n - 1; i += 1) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[i + 1];
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Area of a MultiPolygon in km², holes subtracted.
|
||||
* @param {Array} coordinates
|
||||
* @returns {number}
|
||||
*/
|
||||
export function multiPolygonAreaKm2(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates);
|
||||
if (!multi) return 0;
|
||||
let total = 0;
|
||||
for (const polygon of multi) {
|
||||
total += ringAreaKm2(unwrapRing(polygon[0]));
|
||||
for (const hole of polygon.slice(1)) total -= ringAreaKm2(unwrapRing(hole));
|
||||
}
|
||||
return Math.max(0, total);
|
||||
}
|
||||
|
||||
/** Counts of parts and holes. */
|
||||
export function multiPolygonCounts(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates) || [];
|
||||
return {
|
||||
parts: multi.length,
|
||||
holes: multi.reduce((sum, polygon) => sum + polygon.length - 1, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mean of the largest part's outer ring vertices, wrapped to −180..180. */
|
||||
export function areaAnchor(coordinates) {
|
||||
const multi = normalizeMultiPolygon(coordinates);
|
||||
if (!multi) return null;
|
||||
let best = null;
|
||||
let bestArea = -1;
|
||||
for (const polygon of multi) {
|
||||
const ring = unwrapRing(polygon[0]);
|
||||
const area = ringAreaKm2(ring);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = ring;
|
||||
}
|
||||
}
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
const pts = best.slice(0, -1);
|
||||
for (const [x, y] of pts) {
|
||||
lon += x;
|
||||
lat += y;
|
||||
}
|
||||
return { lon: wrap180(lon / pts.length), lat: lat / pts.length };
|
||||
}
|
||||
|
||||
// ── Display copies ────────────────────────────────────────────
|
||||
|
||||
function dpRing(ring, tol) {
|
||||
const n = ring.length;
|
||||
if (n <= 5) return ring;
|
||||
const keep = new Uint8Array(n);
|
||||
keep[0] = 1;
|
||||
keep[n - 1] = 1;
|
||||
const stack = [[0, n - 1]];
|
||||
while (stack.length) {
|
||||
const [a, b] = stack.pop();
|
||||
if (b - a < 2) continue;
|
||||
const [ax, ay] = ring[a];
|
||||
const vx = ring[b][0] - ax;
|
||||
const vy = ring[b][1] - ay;
|
||||
const c2 = vx * vx + vy * vy;
|
||||
let worst = -1;
|
||||
let worstD = tol;
|
||||
for (let i = a + 1; i < b; i += 1) {
|
||||
const wx = ring[i][0] - ax;
|
||||
const wy = ring[i][1] - ay;
|
||||
const t =
|
||||
c2 === 0 ? 0 : Math.max(0, Math.min(1, (vx * wx + vy * wy) / c2));
|
||||
const d = Math.hypot(wx - t * vx, wy - t * vy);
|
||||
if (d > worstD) {
|
||||
worstD = d;
|
||||
worst = i;
|
||||
}
|
||||
}
|
||||
if (worst >= 0) {
|
||||
keep[worst] = 1;
|
||||
stack.push([a, worst], [worst, b]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i += 1) if (keep[i]) out.push(ring[i]);
|
||||
return out.length >= 4 ? out : ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simplified copy of the area for drawing only: the largest `maxParts`
|
||||
* parts, each ring Douglas-Peucker simplified until the whole copy fits in
|
||||
* `maxVertices`. Rings keep their antimeridian-unwrapped longitudes so a
|
||||
* crossing part draws as one shape.
|
||||
* @param {Array} coordinates MultiPolygon coordinates.
|
||||
* @param {{maxVertices?: number, maxParts?: number}} [options]
|
||||
* @returns {{parts: Array<{outer: Array, holes: Array}>, drawnParts: number, totalParts: number}}
|
||||
*/
|
||||
export function displayParts(
|
||||
coordinates,
|
||||
{ maxVertices = 6000, maxParts = 40 } = {},
|
||||
) {
|
||||
const multi = normalizeMultiPolygon(coordinates) || [];
|
||||
const ranked = multi
|
||||
.map((polygon) => ({
|
||||
rings: polygon.map((ring) => unwrapRing(ring)),
|
||||
area: ringAreaKm2(unwrapRing(polygon[0])),
|
||||
}))
|
||||
.sort((a, b) => b.area - a.area)
|
||||
.slice(0, maxParts);
|
||||
const bbox = ranked.length ? ringBbox(ranked[0].rings[0]) : [0, 0, 0, 0];
|
||||
let tol = Math.max(
|
||||
1e-6,
|
||||
Math.max(bbox[2] - bbox[0], bbox[3] - bbox[1]) / 1500,
|
||||
);
|
||||
let parts = [];
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
parts = ranked.map(({ rings }) => ({
|
||||
outer: dpRing(rings[0], tol),
|
||||
holes: rings.slice(1).map((ring) => dpRing(ring, tol)),
|
||||
}));
|
||||
const total = parts.reduce(
|
||||
(sum, p) =>
|
||||
sum + p.outer.length + p.holes.reduce((h, r) => h + r.length, 0),
|
||||
0,
|
||||
);
|
||||
if (total <= maxVertices) break;
|
||||
tol *= 2;
|
||||
}
|
||||
return { parts, drawnParts: parts.length, totalParts: multi.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* A circle as a closed ring (planar metres around the centre — accurate for
|
||||
* the few-kilometre buffers it is used for).
|
||||
* @returns {Array<[number, number]>}
|
||||
*/
|
||||
export function circleRing(lat, lon, radiusM, segments = 48) {
|
||||
const mPerDegLat = 111_320;
|
||||
const mPerDegLon = mPerDegLat * Math.max(0.01, Math.cos(toRad(lat)));
|
||||
const ring = [];
|
||||
for (let i = 0; i < segments; i += 1) {
|
||||
const a = (i / segments) * Math.PI * 2;
|
||||
ring.push([
|
||||
lon + (Math.cos(a) * radiusM) / mPerDegLon,
|
||||
lat + (Math.sin(a) * radiusM) / mPerDegLat,
|
||||
]);
|
||||
}
|
||||
ring.push([ring[0][0], ring[0][1]]);
|
||||
return ring;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Area geometry: containment over full MultiPolygons (islands, holes, the
|
||||
// antimeridian), OSM relation assembly and display copies that never touch the
|
||||
// counting geometry.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
circleRing,
|
||||
displayParts,
|
||||
multiPolygonAreaKm2,
|
||||
multiPolygonCounts,
|
||||
normalizeMultiPolygon,
|
||||
pointInPreparedArea,
|
||||
prepareArea,
|
||||
} from './areaGeometry.js';
|
||||
|
||||
const square = (w, s, e, n) => [
|
||||
[w, s],
|
||||
[e, s],
|
||||
[e, n],
|
||||
[w, n],
|
||||
[w, s],
|
||||
];
|
||||
|
||||
test('a point on an island counts, not only on the largest part', () => {
|
||||
// Mainland 0..10, island 20..22: the old largest-ring shortcut dropped the island.
|
||||
const area = prepareArea([[square(0, 0, 10, 10)], [square(20, 0, 22, 2)]]);
|
||||
assert.equal(pointInPreparedArea(area, 5, 5), true);
|
||||
assert.equal(pointInPreparedArea(area, 1, 21), true);
|
||||
assert.equal(pointInPreparedArea(area, 1, 15), false);
|
||||
});
|
||||
|
||||
test('a point inside a hole is outside the area', () => {
|
||||
const area = prepareArea([[square(0, 0, 10, 10), square(4, 4, 6, 6)]]);
|
||||
assert.equal(pointInPreparedArea(area, 5, 5), false);
|
||||
assert.equal(pointInPreparedArea(area, 2, 2), true);
|
||||
// An island inside the hole (an enclave within an enclave) counts again.
|
||||
const nested = prepareArea([
|
||||
[square(0, 0, 10, 10), square(3, 3, 7, 7)],
|
||||
[square(4, 4, 6, 6)],
|
||||
]);
|
||||
assert.equal(pointInPreparedArea(nested, 5, 5), true);
|
||||
assert.equal(pointInPreparedArea(nested, 3.5, 3.5), false);
|
||||
});
|
||||
|
||||
test('a ring crossing the antimeridian contains points on both sides', () => {
|
||||
// 175E → 175W as one continuous ring (a drawn box over Fiji).
|
||||
const ring = [
|
||||
[175, -20],
|
||||
[-175, -20],
|
||||
[-175, -15],
|
||||
[175, -15],
|
||||
[175, -20],
|
||||
];
|
||||
const area = prepareArea([[ring]]);
|
||||
assert.equal(area.crossesAntimeridian, true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 178), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, -178), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 0), false);
|
||||
assert.equal(pointInPreparedArea(area, -17, 170), false);
|
||||
// bbox is west > east across the line.
|
||||
assert.deepEqual(area.bbox, [175, -20, -175, -15]);
|
||||
// Area of a 10°×5° box near 17°S, not the 350° long way round.
|
||||
const km2 = multiPolygonAreaKm2([[ring]]);
|
||||
assert.ok(km2 > 500_000 && km2 < 620_000, `area ${km2}`);
|
||||
});
|
||||
|
||||
test('a multipolygon split at ±180 keeps a tight bbox', () => {
|
||||
const area = prepareArea([
|
||||
[square(177, -19, 180, -16)],
|
||||
[square(-180, -19, -178, -16)],
|
||||
]);
|
||||
assert.equal(area.crossesAntimeridian, true);
|
||||
assert.deepEqual(area.bbox, [177, -19, -178, -16]);
|
||||
assert.equal(pointInPreparedArea(area, -17, -179), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 179), true);
|
||||
});
|
||||
|
||||
test('a crossing part and a separate island share one tight longitude frame', () => {
|
||||
const crossing = [
|
||||
[175, -20],
|
||||
[-175, -20],
|
||||
[-175, -15],
|
||||
[175, -15],
|
||||
[175, -20],
|
||||
];
|
||||
const area = prepareArea([[crossing], [square(-170, -19, -160, -16)]]);
|
||||
assert.equal(area.crossesAntimeridian, true);
|
||||
assert.deepEqual(area.bbox, [175, -20, -160, -15]);
|
||||
assert.equal(pointInPreparedArea(area, -17, 178), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, -165), true);
|
||||
assert.equal(pointInPreparedArea(area, -17, 0), false);
|
||||
});
|
||||
|
||||
test('normalization closes rings and drops degenerate parts', () => {
|
||||
const multi = normalizeMultiPolygon({
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
[
|
||||
[0.2, 0.2],
|
||||
[0.2, 0.2],
|
||||
],
|
||||
],
|
||||
});
|
||||
assert.equal(multi.length, 1);
|
||||
assert.equal(multi[0].length, 1, 'degenerate hole dropped');
|
||||
assert.deepEqual(multi[0][0][0], multi[0][0][3]);
|
||||
assert.equal(
|
||||
normalizeMultiPolygon({ type: 'LineString', coordinates: [] }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeMultiPolygon([
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('area subtracts holes and counts parts', () => {
|
||||
const coords = [
|
||||
[square(0, 0, 1, 1), square(0.25, 0.25, 0.75, 0.75)],
|
||||
[square(5, 5, 6, 6)],
|
||||
];
|
||||
const whole = multiPolygonAreaKm2([[square(0, 0, 1, 1)]]);
|
||||
const km2 = multiPolygonAreaKm2(coords);
|
||||
assert.ok(km2 < whole * 2 && km2 > whole * 1.7, `${km2} vs ${whole}`);
|
||||
assert.deepEqual(multiPolygonCounts(coords), { parts: 2, holes: 1 });
|
||||
});
|
||||
|
||||
test('display copies are bounded and leave the counting geometry intact', () => {
|
||||
const dense = [];
|
||||
for (let i = 0; i < 20_000; i += 1) {
|
||||
const a = (i / 20_000) * Math.PI * 2;
|
||||
dense.push([
|
||||
Math.cos(a) * (1 + 0.01 * Math.sin(a * 200)),
|
||||
Math.sin(a) * (1 + 0.01 * Math.sin(a * 200)),
|
||||
]);
|
||||
}
|
||||
dense.push(dense[0].slice());
|
||||
const coords = [[dense]];
|
||||
const snapshot = JSON.stringify(coords);
|
||||
const shown = displayParts(coords, { maxVertices: 2000 });
|
||||
const total = shown.parts.reduce((sum, p) => sum + p.outer.length, 0);
|
||||
assert.ok(total <= 2000, `${total} vertices`);
|
||||
assert.equal(JSON.stringify(coords), snapshot, 'input untouched');
|
||||
// A point the simplified copy might shave off still counts on the original.
|
||||
const area = prepareArea(coords);
|
||||
assert.equal(pointInPreparedArea(area, 0, 0), true);
|
||||
});
|
||||
|
||||
test('display copies keep the largest parts first', () => {
|
||||
const coords = [
|
||||
[square(0, 0, 1, 1)],
|
||||
[square(10, 10, 15, 15)],
|
||||
[square(20, 20, 20.1, 20.1)],
|
||||
];
|
||||
const shown = displayParts(coords, { maxParts: 2 });
|
||||
assert.equal(shown.totalParts, 3);
|
||||
assert.equal(shown.drawnParts, 2);
|
||||
assert.equal(shown.parts[0].outer[0][0], 10);
|
||||
});
|
||||
|
||||
test('a circle ring is closed and about the requested radius', () => {
|
||||
const ring = circleRing(37.8, -122.39, 500);
|
||||
assert.deepEqual(ring[0], ring[ring.length - 1]);
|
||||
const km2 = multiPolygonAreaKm2([[ring]]);
|
||||
assert.ok(Math.abs(km2 - Math.PI * 0.25) < 0.02, `${km2}`);
|
||||
});
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* Named place → area handle, through the outline ladder that needs no public
|
||||
* Overpass:
|
||||
*
|
||||
* 1. the area store (a query already resolved this session);
|
||||
* 2. bundled Natural Earth physical/marine regions;
|
||||
* 3. bundled countries and states/provinces by name — a name several
|
||||
* prominent units share ("Georgia", "Punjab") asks which one, unless
|
||||
* the view is inside exactly one of them;
|
||||
* 4. the annotation outline ladder (`resolveNamedOutline`): bundled
|
||||
* counties, US Census places and neighbourhood packs, then the guarded
|
||||
* Nominatim outline route, then Overpass only when an operator
|
||||
* configured it.
|
||||
*
|
||||
* Providers are injected, so the ladder runs under node:test:
|
||||
* findNaturalRegion(query) → {name, polygons, kind}|null
|
||||
* findAdminCandidates(query) → Array<AdminArea> (see data/adminBoundaries.js)
|
||||
* resolveNamedOutline(query, {level, levelHint, signal}) →
|
||||
* {ok:true, name, polygons, source, rung, approximate?, country?}
|
||||
* |{ok:false, code, error}
|
||||
* viewCenter() → {lat, lon}|null
|
||||
*
|
||||
* @module data/areaResolver
|
||||
*/
|
||||
|
||||
import { AREA_LEVELS } from './areaStore.js';
|
||||
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
|
||||
import { adminQueryIdentity, normalizeAdminName } from './adminBoundaries.js';
|
||||
import {
|
||||
isAreaCandidateId,
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH,
|
||||
} from '../voice/areaCandidateContract.js';
|
||||
export {
|
||||
AREA_CANDIDATE_ID_PATTERN,
|
||||
isAreaCandidateId,
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH,
|
||||
} from '../voice/areaCandidateContract.js';
|
||||
|
||||
/** Most candidates a clarification offers. */
|
||||
export const MAX_AREA_CANDIDATES = 4;
|
||||
/** Candidates remembered for a follow-up `candidateId`. */
|
||||
const CANDIDATE_MEMORY = 24;
|
||||
|
||||
/**
|
||||
* A place name as a cache and matching key: case-folded and NFKC-normalized,
|
||||
* punctuation dropped, letters, marks and digits in every script kept (東京
|
||||
* and 大阪 are different keys). Empty when the text names nothing.
|
||||
*/
|
||||
export function normalizeAreaQuery(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N} ]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '');
|
||||
}
|
||||
|
||||
/** Words that name the kind of area rather than the area. */
|
||||
const LEVEL_WORDS = [
|
||||
[/\b(province|state|region|oblast|prefecture|canton|governorate)$/, 'admin1'],
|
||||
[/\b(county|district|department)$/, 'admin2'],
|
||||
[/\b(city|town|municipality|metropolitan city)$/, 'city'],
|
||||
[/\b(neighbou?rhood|borough|suburb|quarter)$/, 'district'],
|
||||
[/\b(country|nation)$/, 'country'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Strip a trailing level word ("Bagmati province" → "bagmati", admin1). The
|
||||
* word is kept as a hint, not a filter, since names often contain it.
|
||||
*/
|
||||
export function splitLevelWord(query) {
|
||||
const norm = normalizeAreaQuery(query);
|
||||
for (const [re, level] of LEVEL_WORDS) {
|
||||
if (re.test(norm)) {
|
||||
const bare = norm.replace(re, '').trim();
|
||||
if (bare) return { bare, level };
|
||||
}
|
||||
}
|
||||
return { bare: norm, level: null };
|
||||
}
|
||||
|
||||
/** Area level of a bundled admin unit. */
|
||||
const ADMIN_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
state: 'admin1',
|
||||
county: 'admin2',
|
||||
natural: 'natural',
|
||||
});
|
||||
|
||||
/**
|
||||
* One of several same-name natural places as a clarification choice, located
|
||||
* by its rough centre ("39 N 31 W") since it has no country.
|
||||
*/
|
||||
function naturalCandidate(place, kind) {
|
||||
const { lat, lon } = place.center || {};
|
||||
const locator = Number.isFinite(lat)
|
||||
? `${Math.abs(Math.round(lat))} ${lat >= 0 ? 'N' : 'S'} ${Math.abs(Math.round(lon))} ${lon >= 0 ? 'E' : 'W'}`
|
||||
: '';
|
||||
return {
|
||||
kind: 'natural',
|
||||
name: place.name,
|
||||
country: '',
|
||||
regionCode: locator,
|
||||
polygons: place.polygons.map((ring) => [ring]),
|
||||
source: 'natural-earth',
|
||||
meta: { kind },
|
||||
};
|
||||
}
|
||||
|
||||
const ADMIN_NAME_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
nation: 'country',
|
||||
province: 'admin1',
|
||||
state: 'admin1',
|
||||
region: 'admin1',
|
||||
oblast: 'admin1',
|
||||
prefecture: 'admin1',
|
||||
canton: 'admin1',
|
||||
governorate: 'admin1',
|
||||
county: 'admin2',
|
||||
district: 'admin2',
|
||||
department: 'admin2',
|
||||
});
|
||||
|
||||
/**
|
||||
* Drop accents from Latin letters only, for comparing names: speech often
|
||||
* loses them ("Quebec" for "Québec"), while a Thai or Devanagari mark changes
|
||||
* the name and is kept.
|
||||
*/
|
||||
export function foldLatinMarks(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFD')
|
||||
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
|
||||
.normalize('NFC');
|
||||
}
|
||||
|
||||
function explicitAdminName(text) {
|
||||
const normalized = normalizeAreaQuery(String(text).split(',')[0]);
|
||||
const match = normalized.match(
|
||||
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
|
||||
);
|
||||
return {
|
||||
normalized,
|
||||
bare: foldLatinMarks(
|
||||
match ? normalized.slice(0, -match[0].length).trim() : normalized,
|
||||
),
|
||||
word: match?.[1] || null,
|
||||
level: match ? ADMIN_NAME_LEVEL[match[1]] || null : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail closed when a bundled unit's own name contradicts the requested unit.
|
||||
* With `aliases`, an ask the administrative matcher reads as naming this very
|
||||
* unit at its level ("State of Texas", "USA") also matches; the natural-region
|
||||
* check leaves it off, since "Great Britain" is not the United Kingdom.
|
||||
*/
|
||||
function bundledAdminIdentityRefusal(
|
||||
admin,
|
||||
query,
|
||||
wantedLevel,
|
||||
{ aliases = true } = {},
|
||||
) {
|
||||
const source = explicitAdminName(admin?.name || '');
|
||||
const requested = explicitAdminName(query);
|
||||
const expectedLevel = wantedLevel || requested.level;
|
||||
const emittedLevel = ADMIN_LEVEL[admin?.kind] || null;
|
||||
if (!source.normalized || source.word === 'zone')
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_IDENTITY_UNVERIFIED',
|
||||
error: `The bundled boundary is ${admin?.name || 'an unverified administrative unit'}, not a verified current match for "${query}".`,
|
||||
};
|
||||
const alias = aliases ? adminQueryIdentity(query) : null;
|
||||
const named =
|
||||
source.bare === requested.bare ||
|
||||
Boolean(
|
||||
alias &&
|
||||
alias.level === emittedLevel &&
|
||||
alias.name === normalizeAdminName(admin.name),
|
||||
);
|
||||
if (
|
||||
!named ||
|
||||
(source.level && expectedLevel && source.level !== expectedLevel) ||
|
||||
(expectedLevel && emittedLevel !== expectedLevel)
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_IDENTITY_MISMATCH',
|
||||
error: `The bundled boundary is ${admin.name}, not the requested administrative area "${query}".`,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
function stableCandidateHash(value) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const char of String(value)) {
|
||||
hash ^= char.codePointAt(0);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function candidateIdOf(admin) {
|
||||
const prefix = `ne:${admin.kind}:`;
|
||||
// The region code keeps same-named units apart: "Washington" is both WA
|
||||
// and DC, which would otherwise share one candidate and one stored area.
|
||||
const slug = normalizeAreaQuery(
|
||||
`${admin.country} ${admin.name} ${admin.regionCode || ''}`,
|
||||
).replace(/ /g, '-');
|
||||
const full = `${prefix}${slug}`;
|
||||
if (full.length <= MAX_AREA_CANDIDATE_ID_LENGTH) return full;
|
||||
const suffix = `-${stableCandidateHash(slug)}`;
|
||||
let shortened = '';
|
||||
for (const char of slug) {
|
||||
if (
|
||||
`${prefix}${shortened}${char}${suffix}`.length >
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH
|
||||
)
|
||||
break;
|
||||
shortened += char;
|
||||
}
|
||||
return `${prefix}${shortened.replace(/-+$/u, '')}${suffix}`;
|
||||
}
|
||||
|
||||
/** Short, speakable identity for a clarification choice. */
|
||||
function describeCandidate(admin) {
|
||||
return {
|
||||
candidateId: candidateIdOf(admin),
|
||||
name: admin.name,
|
||||
level: ADMIN_LEVEL[admin.kind] || null,
|
||||
// Tells same-named units apart when spoken: Washington WA vs DC.
|
||||
...(admin.regionCode && admin.kind !== 'country'
|
||||
? { region: String(admin.regionCode) }
|
||||
: {}),
|
||||
...(admin.country && admin.kind !== 'country'
|
||||
? { country: admin.country }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct places among bundled candidates. Within one country, the country
|
||||
* itself stands for its same-named state ("Mexico" is the country, not the
|
||||
* State of Mexico); across countries each name is its own place.
|
||||
*/
|
||||
/** A coarse ~2° cell of the view, or "none", for keying view-picked answers. */
|
||||
function viewCell(center) {
|
||||
if (!Number.isFinite(center?.lat) || !Number.isFinite(center?.lon))
|
||||
return 'none';
|
||||
return `${Math.floor(center.lat / 2)},${Math.floor(center.lon / 2)}`;
|
||||
}
|
||||
|
||||
export function distinctAdminPlaces(candidates) {
|
||||
const byCountry = new Map();
|
||||
for (const admin of candidates) {
|
||||
const list = byCountry.get(admin.country) || [];
|
||||
list.push(admin);
|
||||
byCountry.set(admin.country, list);
|
||||
}
|
||||
const out = [];
|
||||
for (const list of byCountry.values()) {
|
||||
const country = list.find((admin) => admin.kind === 'country');
|
||||
out.push(...(country ? [country] : list));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resolver bound to an area store and providers.
|
||||
* @param {object} options
|
||||
*/
|
||||
export function createAreaResolver({
|
||||
store,
|
||||
findNaturalRegion = async () => null,
|
||||
findAdminCandidates = async () => [],
|
||||
resolveNamedOutline = async () => ({
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: 'Boundary lookup is unavailable here.',
|
||||
}),
|
||||
viewCenter = () => null,
|
||||
onCredit = () => {},
|
||||
now = () => Date.now(),
|
||||
maxInFlight = 8,
|
||||
} = {}) {
|
||||
if (!store) throw new TypeError('An area store is required');
|
||||
/** candidateId → bundled unit and original ask, for a clarification answered later. */
|
||||
const candidateMemory = new Map();
|
||||
/** query key → in-flight lookup, so a repeat joins the first. */
|
||||
const inFlight = new Map();
|
||||
const lifetime = new AbortController();
|
||||
const inFlightLimit = Number.isFinite(maxInFlight)
|
||||
? Math.max(1, Math.floor(maxInFlight))
|
||||
: 8;
|
||||
let activeLookups = 0;
|
||||
let disposed = false;
|
||||
|
||||
const assertActive = () => lifetime.signal.throwIfAborted();
|
||||
|
||||
const remember = (admin, identity) => {
|
||||
const id = candidateIdOf(admin);
|
||||
candidateMemory.delete(id);
|
||||
candidateMemory.set(id, { admin, identity });
|
||||
while (candidateMemory.size > CANDIDATE_MEMORY)
|
||||
candidateMemory.delete(candidateMemory.keys().next().value);
|
||||
};
|
||||
|
||||
function storeAdmin(admin, confidence, identity) {
|
||||
assertActive();
|
||||
const refused = bundledAdminIdentityRefusal(
|
||||
admin,
|
||||
identity?.query,
|
||||
identity?.wantedLevel,
|
||||
);
|
||||
if (refused) return refused;
|
||||
const record = store.put({
|
||||
geometry: admin.polygons,
|
||||
name: admin.name,
|
||||
level: ADMIN_LEVEL[admin.kind] || null,
|
||||
source: admin.source,
|
||||
sourceId: candidateIdOf(admin),
|
||||
meta: {
|
||||
...(admin.kind !== 'country' && admin.country
|
||||
? { country: admin.country }
|
||||
: {}),
|
||||
rung: 'bundled',
|
||||
},
|
||||
});
|
||||
if (!record)
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: `The boundary for ${admin.name} is not a usable polygon.`,
|
||||
};
|
||||
onCredit(admin.source);
|
||||
return { ok: true, record, confidence, rung: 'bundled' };
|
||||
}
|
||||
|
||||
async function lookup({ query, level, within }, { signal }) {
|
||||
const { level: levelHint } = splitLevelWord(query);
|
||||
const searchText = within ? `${query}, ${within}` : query;
|
||||
let bundledRefusal = null;
|
||||
// Offline first: a named natural region, unless an admin level or a
|
||||
// country/state qualifier was given (the pack cannot check `within`).
|
||||
if ((!level || level === 'natural') && !within) {
|
||||
const ne = await findNaturalRegion(query, { signal }).catch(() => null);
|
||||
signal?.throwIfAborted?.();
|
||||
// A region named like a country or state ("India" the peninsula,
|
||||
// "Punjab" the desert) never hides it; asked for by level, it answers.
|
||||
// Only a unit that really has this name counts: "Great Britain" finds
|
||||
// the United Kingdom by alias, which is not a Great Britain.
|
||||
// A feature split into parts is one place with every part; distant
|
||||
// namesakes (Flores in the Azores and Indonesia) are asked about.
|
||||
const places = ne?.places?.length
|
||||
? ne.places
|
||||
: ne?.polygons?.length
|
||||
? [{ name: ne.name, polygons: ne.polygons }]
|
||||
: [];
|
||||
const shadowsAdmin =
|
||||
places.length > 0 &&
|
||||
level !== 'natural' &&
|
||||
(await findAdminCandidates(query, { signal }).catch(() => [])).some(
|
||||
(admin) =>
|
||||
!bundledAdminIdentityRefusal(admin, query, null, {
|
||||
aliases: false,
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted?.();
|
||||
if (places.length > 1 && !shadowsAdmin) {
|
||||
const offered = places
|
||||
.slice(0, MAX_AREA_CANDIDATES)
|
||||
.map((place) => naturalCandidate(place, ne.kind));
|
||||
const identity = { query, wantedLevel: 'natural' };
|
||||
offered.forEach((admin) => remember(admin, identity));
|
||||
return {
|
||||
ok: false,
|
||||
needsClarification: true,
|
||||
code: 'AMBIGUOUS_AREA',
|
||||
candidates: offered.map(describeCandidate),
|
||||
error: `"${query}" names ${offered.length} places — ask which one.`,
|
||||
};
|
||||
}
|
||||
if (places.length === 1 && !shadowsAdmin) {
|
||||
const record = store.put({
|
||||
geometry: places[0].polygons.map((ring) => [ring]),
|
||||
name: ne.name,
|
||||
level: 'natural',
|
||||
source: 'natural-earth',
|
||||
sourceId: `ne:${normalizeAreaQuery(ne.name).replace(/ /g, '-')}`,
|
||||
meta: { kind: ne.kind, rung: 'bundled' },
|
||||
});
|
||||
if (record) {
|
||||
onCredit('natural-earth');
|
||||
return { ok: true, record, confidence: 0.95, rung: 'bundled' };
|
||||
}
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted?.();
|
||||
// Bundled countries and states by name. A name prominent units share is
|
||||
// asked about, never guessed — except when the view is inside one.
|
||||
const wanted = level || levelHint;
|
||||
if (!wanted || wanted === 'country' || wanted === 'admin1') {
|
||||
let admins = await findAdminCandidates(searchText, { signal }).catch(
|
||||
() => [],
|
||||
);
|
||||
signal?.throwIfAborted?.();
|
||||
if (wanted) admins = admins.filter((a) => ADMIN_LEVEL[a.kind] === wanted);
|
||||
let places = distinctAdminPlaces(admins);
|
||||
const identity = { query, wantedLevel: wanted };
|
||||
const checked = places.map((admin) => ({
|
||||
admin,
|
||||
refused: bundledAdminIdentityRefusal(admin, query, wanted),
|
||||
}));
|
||||
places = checked
|
||||
.filter(({ refused }) => !refused)
|
||||
.map(({ admin }) => admin);
|
||||
if (!places.length && checked.length) {
|
||||
const refused = checked.find((entry) => entry.refused)?.refused;
|
||||
// A retired or unverifiable unit is final. A different unit that
|
||||
// merely shares the name ("Azad Kashmir" for "Kashmir") is not an
|
||||
// answer, so the outline ladder still gets to look.
|
||||
if (refused?.code !== 'AREA_IDENTITY_MISMATCH') return refused;
|
||||
bundledRefusal = refused;
|
||||
}
|
||||
if (places.length === 1) {
|
||||
return storeAdmin(places[0], 0.95, identity);
|
||||
}
|
||||
if (places.length > 1) {
|
||||
const center = viewCenter();
|
||||
const holding = center
|
||||
? places.filter((admin) => {
|
||||
const prepared = prepareArea(admin.polygons);
|
||||
return (
|
||||
prepared &&
|
||||
pointInPreparedArea(prepared, center.lat, center.lon)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
// Chosen by the view, so never remembered for the name: asked again
|
||||
// over the other one, it answers the other one.
|
||||
if (holding.length === 1)
|
||||
return { ...storeAdmin(holding[0], 0.85, identity), byView: true };
|
||||
const offered = places.slice(0, MAX_AREA_CANDIDATES);
|
||||
offered.forEach((admin) => remember(admin, identity));
|
||||
return {
|
||||
ok: false,
|
||||
needsClarification: true,
|
||||
code: 'AMBIGUOUS_AREA',
|
||||
candidates: offered.map(describeCandidate),
|
||||
error: `"${query}" names ${offered.length} places — ask which one.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted?.();
|
||||
// Everything else: the annotation outline ladder.
|
||||
const outline = await resolveNamedOutline(searchText, {
|
||||
level,
|
||||
levelHint,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted?.();
|
||||
if (!outline?.ok)
|
||||
return (
|
||||
bundledRefusal || {
|
||||
ok: false,
|
||||
code: outline?.code || 'AREA_NOT_FOUND',
|
||||
error: outline?.error || `No boundary found for "${query}".`,
|
||||
}
|
||||
);
|
||||
const record = store.put({
|
||||
geometry: outline.polygons,
|
||||
name: outline.name || query,
|
||||
level: level || levelHint || outline.adminLevel || null,
|
||||
source: outline.approximate ? 'approximate' : outline.source,
|
||||
approximate: Boolean(outline.approximate),
|
||||
meta: {
|
||||
...(outline.country ? { country: outline.country } : {}),
|
||||
...(outline.adminName ? { adminName: outline.adminName } : {}),
|
||||
...(outline.adminLevel ? { adminLevel: outline.adminLevel } : {}),
|
||||
rung: outline.rung || null,
|
||||
...(outline.basis ? { basis: outline.basis } : {}),
|
||||
},
|
||||
});
|
||||
if (!record)
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: `The boundary for ${query} is not a usable polygon.`,
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
record,
|
||||
confidence: outline.approximate ? 0.5 : 0.8,
|
||||
rung: outline.rung || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a place to an area handle.
|
||||
* @param {{query?: string, level?: string, within?: string, candidateId?: string}} args
|
||||
* @param {{signal?: AbortSignal, budgetMs?: number}} [options] Past the
|
||||
* budget the call answers AREA_TIMEOUT while the lookup continues and
|
||||
* fills the caches, so asking again shortly is fast.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function resolve(args = {}, { signal, budgetMs = 8000 } = {}) {
|
||||
const started = now();
|
||||
const level = AREA_LEVELS.includes(args.level) ? args.level : null;
|
||||
const within = String(args.within || '').trim();
|
||||
const candidateId = String(args.candidateId || '').trim();
|
||||
const finish = (result, cached = false) => ({
|
||||
...result,
|
||||
cached,
|
||||
ms: now() - started,
|
||||
});
|
||||
|
||||
if (disposed)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'Area resolver has been disposed.',
|
||||
});
|
||||
|
||||
if (candidateId) {
|
||||
if (!isAreaCandidateId(candidateId))
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'BAD_CANDIDATE',
|
||||
error:
|
||||
'candidateId is malformed or too long. Resolve the name again.',
|
||||
});
|
||||
const remembered = candidateMemory.get(candidateId);
|
||||
if (!remembered)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'UNKNOWN_CANDIDATE',
|
||||
error: `Unknown candidateId "${candidateId}". Resolve the name again.`,
|
||||
});
|
||||
if (signal?.aborted)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'This request was superseded.',
|
||||
});
|
||||
return finish(storeAdmin(remembered.admin, 0.9, remembered.identity));
|
||||
}
|
||||
|
||||
const query = String(args.query || '').trim();
|
||||
if (!query)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'BAD_AREA',
|
||||
error: 'Name a place to resolve.',
|
||||
});
|
||||
const nameKey = normalizeAreaQuery(query);
|
||||
// The outline ladder and geocoder pick by the view ("Springfield" over
|
||||
// Illinois), so an answer is reused or shared only within the same
|
||||
// ~2° view cell. A name that normalizes to nothing is never cached.
|
||||
const key = nameKey
|
||||
? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}|${viewCell(viewCenter())}`
|
||||
: null;
|
||||
const recalled = key ? store.recallQuery(key) : null;
|
||||
if (recalled)
|
||||
return finish(
|
||||
{ ok: true, record: recalled, confidence: 0.95, rung: 'store' },
|
||||
true,
|
||||
);
|
||||
|
||||
let pending = key ? inFlight.get(key) : null;
|
||||
if (!pending) {
|
||||
if (activeLookups >= inFlightLimit)
|
||||
return finish({
|
||||
ok: false,
|
||||
code: 'AREA_BUSY',
|
||||
error:
|
||||
'Too many area lookups are already running — try again shortly.',
|
||||
});
|
||||
// The shared lookup outlives any one caller's signal: a superseded turn
|
||||
// must not cancel the answer a repeat is waiting on. The resolver's own
|
||||
// lifetime still owns the provider work, so final teardown stops it.
|
||||
activeLookups += 1;
|
||||
pending = lookup({ query, level, within }, { signal: lifetime.signal })
|
||||
.then((result) => {
|
||||
assertActive();
|
||||
if (result.ok && key && !result.byView)
|
||||
store.rememberQuery(key, result.record.areaId);
|
||||
return result;
|
||||
})
|
||||
.catch((error) =>
|
||||
lifetime.signal.aborted
|
||||
? {
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'Area resolver has been disposed.',
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: error?.message || 'Boundary lookup failed.',
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
activeLookups -= 1;
|
||||
if (key) inFlight.delete(key);
|
||||
});
|
||||
if (key) inFlight.set(key, pending);
|
||||
}
|
||||
let timer;
|
||||
const racers = [pending];
|
||||
if (Number.isFinite(budgetMs))
|
||||
racers.push(
|
||||
new Promise((resolveTimeout) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
resolveTimeout({
|
||||
ok: false,
|
||||
code: 'AREA_TIMEOUT',
|
||||
error: `Looking up "${query}" is taking a while — ask again in a moment.`,
|
||||
}),
|
||||
budgetMs,
|
||||
);
|
||||
}),
|
||||
);
|
||||
// Detached once this call settles: a caller's long-lived signal must not
|
||||
// collect one listener per completed lookup.
|
||||
let cancelled = null;
|
||||
if (signal)
|
||||
racers.push(
|
||||
new Promise((resolveAbort) => {
|
||||
const superseded = {
|
||||
ok: false,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'Superseded.',
|
||||
};
|
||||
if (signal.aborted) return resolveAbort(superseded);
|
||||
cancelled = () => resolveAbort(superseded);
|
||||
signal.addEventListener?.('abort', cancelled, { once: true });
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
return finish(await Promise.race(racers));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (cancelled) signal.removeEventListener?.('abort', cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
lifetime.abort();
|
||||
inFlight.clear();
|
||||
candidateMemory.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
resolve,
|
||||
dispose,
|
||||
candidate: (id) => candidateMemory.get(id) || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
// Area handles: the session store and the resolution ladder (bundled Natural
|
||||
// Earth regions → bundled countries and states → the annotation outline
|
||||
// ladder), with every rung mocked. Pins the clarification contract for
|
||||
// ambiguous names and the fast cached path.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAreaStore, summarizeArea } from './areaStore.js';
|
||||
import {
|
||||
AREA_CANDIDATE_ID_PATTERN,
|
||||
createAreaResolver,
|
||||
distinctAdminPlaces,
|
||||
isAreaCandidateId,
|
||||
MAX_AREA_CANDIDATE_ID_LENGTH,
|
||||
splitLevelWord,
|
||||
} from './areaResolver.js';
|
||||
import { pointInPreparedArea } from './areaGeometry.js';
|
||||
import { findAdminCandidates } from './adminBoundaries.js';
|
||||
|
||||
const square = (w, s, e, n) => [
|
||||
[w, s],
|
||||
[e, s],
|
||||
[e, n],
|
||||
[w, n],
|
||||
[w, s],
|
||||
];
|
||||
|
||||
const admin = (kind, name, country, box, extra = {}) => ({
|
||||
kind,
|
||||
name,
|
||||
country,
|
||||
source: 'natural-earth',
|
||||
polygons: [[square(...box)]],
|
||||
...extra,
|
||||
});
|
||||
const GEORGIA_COUNTRY = admin(
|
||||
'country',
|
||||
'Georgia',
|
||||
'Georgia',
|
||||
[40, 41, 46.7, 43.6],
|
||||
);
|
||||
const GEORGIA_STATE = admin(
|
||||
'state',
|
||||
'Georgia',
|
||||
'United States of America',
|
||||
[-85.6, 30.4, -80.8, 35],
|
||||
);
|
||||
const PUNJAB_IN = admin('state', 'Punjab', 'India', [73.8, 29.5, 77, 32.5]);
|
||||
const PUNJAB_PK = admin('state', 'Punjab', 'Pakistan', [69.3, 27.7, 73.7, 34]);
|
||||
const TEXAS = admin(
|
||||
'state',
|
||||
'Texas',
|
||||
'United States of America',
|
||||
[-106, 26, -94, 36],
|
||||
);
|
||||
|
||||
const BAGMATI = {
|
||||
ok: true,
|
||||
name: 'Bagmati Province',
|
||||
polygons: [[square(84.4, 26.9, 86.6, 28.4), square(85.3, 27.6, 85.4, 27.7)]],
|
||||
source: 'osm',
|
||||
rung: 'nominatim',
|
||||
adminName: 'Bagmati Province',
|
||||
};
|
||||
|
||||
function makeResolver(overrides = {}) {
|
||||
const store = createAreaStore();
|
||||
const calls = { outline: [], natural: 0, admin: 0 };
|
||||
const resolver = createAreaResolver({
|
||||
store,
|
||||
findNaturalRegion: async (q) => {
|
||||
calls.natural += 1;
|
||||
return /alps/i.test(q)
|
||||
? {
|
||||
name: 'Alps',
|
||||
kind: 'natural',
|
||||
polygons: [square(5, 44, 16, 48), square(17, 44, 18, 45)],
|
||||
}
|
||||
: null;
|
||||
},
|
||||
findAdminCandidates: async (q) => {
|
||||
calls.admin += 1;
|
||||
if (/^georgia$/i.test(q)) return [GEORGIA_STATE, GEORGIA_COUNTRY];
|
||||
if (/^georgia, united states/i.test(q)) return [GEORGIA_STATE];
|
||||
if (/^punjab$/i.test(q)) return [PUNJAB_PK, PUNJAB_IN];
|
||||
if (/^punjab, pakistan/i.test(q)) return [PUNJAB_PK];
|
||||
if (/^texas$/i.test(q)) return [TEXAS];
|
||||
return [];
|
||||
},
|
||||
resolveNamedOutline: async (q, options) => {
|
||||
calls.outline.push({ q, ...options, signal: undefined });
|
||||
if (/bagmati/i.test(q)) return BAGMATI;
|
||||
return {
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: `No boundary found for "${q}".`,
|
||||
};
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
return { store, resolver, calls };
|
||||
}
|
||||
|
||||
test('the store keeps geometry local and summarizes without coordinates', () => {
|
||||
const store = createAreaStore({ maxEntries: 2 });
|
||||
const a = store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'A',
|
||||
source: 'drawn',
|
||||
level: 'drawn',
|
||||
});
|
||||
const b = store.put({
|
||||
geometry: [[square(2, 2, 3, 3)]],
|
||||
name: 'B',
|
||||
source: 'us-census',
|
||||
sourceId: 'place:1',
|
||||
});
|
||||
assert.equal(b.areaId, 'place:1');
|
||||
assert.equal(b.source, 'us-census');
|
||||
store.get(a.areaId); // A is now the most recently used
|
||||
store.put({ geometry: [[square(4, 4, 5, 5)]], name: 'C', source: 'osm' });
|
||||
assert.equal(store.has('place:1'), false, 'least recently used evicted');
|
||||
assert.ok(store.get(a.areaId));
|
||||
const summary = summarizeArea(a);
|
||||
assert.deepEqual(
|
||||
Object.keys(summary).sort(),
|
||||
[
|
||||
'areaId',
|
||||
'areaKm2',
|
||||
'bbox',
|
||||
'holes',
|
||||
'level',
|
||||
'name',
|
||||
'parts',
|
||||
'source',
|
||||
].sort(),
|
||||
);
|
||||
assert.equal(JSON.stringify(summary).includes('geometry'), false);
|
||||
assert.equal(
|
||||
store.put({
|
||||
geometry: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
],
|
||||
name: 'bad',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('query aliases have their own LRU bound even for one stable area', () => {
|
||||
const store = createAreaStore({ maxEntries: 2, maxQueryEntries: 3 });
|
||||
const area = store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'Stable',
|
||||
source: 'osm',
|
||||
sourceId: 'osm:stable',
|
||||
});
|
||||
for (let i = 0; i < 50; i += 1)
|
||||
store.rememberQuery(`alias-${i}`, area.areaId);
|
||||
assert.equal(store.recallQuery('alias-0'), null, 'old aliases are evicted');
|
||||
assert.equal(store.recallQuery('alias-46'), null, 'the bound is independent');
|
||||
assert.equal(store.recallQuery('alias-47')?.areaId, area.areaId);
|
||||
assert.equal(store.recallQuery('alias-49')?.areaId, area.areaId);
|
||||
assert.equal(store.size, 1, 'alias churn does not evict the area record');
|
||||
});
|
||||
|
||||
test('a stable source keeps one handle when stored again', () => {
|
||||
const store = createAreaStore();
|
||||
for (let i = 0; i < 2; i += 1)
|
||||
store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'X',
|
||||
source: 'natural-earth',
|
||||
sourceId: 'ne:state:x',
|
||||
});
|
||||
assert.equal(store.size, 1);
|
||||
});
|
||||
|
||||
test('a named natural region resolves offline with every part', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'the Alps' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.record.source, 'natural-earth');
|
||||
assert.equal(result.record.parts, 2);
|
||||
assert.equal(calls.outline.length, 0, 'no lookup');
|
||||
assert.ok(result.ms < 300);
|
||||
});
|
||||
|
||||
test('a bundled state resolves by name with no lookup', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Texas' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.rung, 'bundled');
|
||||
assert.equal(result.record.level, 'admin1');
|
||||
assert.equal(result.record.source, 'natural-earth');
|
||||
assert.equal(result.record.meta.country, 'United States of America');
|
||||
assert.equal(calls.outline.length, 0);
|
||||
});
|
||||
|
||||
test('Bagmati goes to the outline ladder and keeps its hole', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.rung, 'nominatim');
|
||||
assert.equal(result.record.source, 'osm');
|
||||
assert.equal(result.record.level, 'admin1', 'the level word is kept');
|
||||
assert.equal(result.record.holes, 1);
|
||||
assert.equal(pointInPreparedArea(result.record.prepared, 27.2, 85), true);
|
||||
assert.equal(
|
||||
pointInPreparedArea(result.record.prepared, 27.65, 85.35),
|
||||
false,
|
||||
'inside the hole',
|
||||
);
|
||||
assert.equal(calls.outline[0].levelHint, 'admin1');
|
||||
});
|
||||
|
||||
test('a legacy bundled zone is never stored as the requested current province', async () => {
|
||||
const legacy = admin(
|
||||
'state',
|
||||
'Bagmati Zone',
|
||||
'Nepal',
|
||||
[84.645, 27.357, 86.055, 28.335],
|
||||
);
|
||||
const { store, resolver } = makeResolver({
|
||||
findAdminCandidates: async () => [legacy],
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
|
||||
assert.equal(result.record, undefined);
|
||||
assert.equal(store.size, 0);
|
||||
const repeated = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(repeated.code, 'AREA_IDENTITY_UNVERIFIED');
|
||||
assert.equal(repeated.cached, false);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('legacy zone identity cannot escape through view choice or candidate memory', async () => {
|
||||
const nepal = admin(
|
||||
'state',
|
||||
'Bagmati Zone',
|
||||
'Nepal',
|
||||
[84.645, 27.357, 86.055, 28.335],
|
||||
);
|
||||
const namesake = admin(
|
||||
'state',
|
||||
'Bagmati Zone',
|
||||
'Elsewhere',
|
||||
[10, 10, 11, 11],
|
||||
);
|
||||
const { store, resolver } = makeResolver({
|
||||
findAdminCandidates: async () => [nepal, namesake],
|
||||
viewCenter: () => ({ lat: 27.7, lon: 85.3 }),
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'Bagmati province' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
|
||||
assert.equal(result.needsClarification, undefined);
|
||||
assert.equal(result.candidates, undefined);
|
||||
assert.equal(store.size, 0);
|
||||
assert.equal(
|
||||
(await resolver.resolve({ candidateId: 'ne:state:nepal-bagmati-zone' }))
|
||||
.code,
|
||||
'UNKNOWN_CANDIDATE',
|
||||
);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('a repeat is answered from the store without a lookup', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
await resolver.resolve({ query: 'Bagmati' });
|
||||
const again = await resolver.resolve({ query: 'bagmati' });
|
||||
assert.equal(again.ok, true);
|
||||
assert.equal(again.cached, true);
|
||||
assert.equal(calls.outline.length, 1);
|
||||
});
|
||||
|
||||
test('an ambiguous name asks instead of guessing, and the choice resolves', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Georgia' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.needsClarification, true);
|
||||
assert.equal(result.candidates.length, 2);
|
||||
assert.deepEqual(result.candidates[0], {
|
||||
candidateId: 'ne:state:united-states-of-america-georgia',
|
||||
name: 'Georgia',
|
||||
level: 'admin1',
|
||||
country: 'United States of America',
|
||||
});
|
||||
assert.equal(result.candidates[1].level, 'country');
|
||||
assert.equal(calls.outline.length, 0, 'no lookup spent on a question');
|
||||
const chosen = await resolver.resolve({
|
||||
candidateId: result.candidates[0].candidateId,
|
||||
});
|
||||
assert.equal(chosen.ok, true);
|
||||
assert.equal(chosen.record.meta.country, 'United States of America');
|
||||
});
|
||||
|
||||
test('an already-aborted clarification choice does not persist page state', async () => {
|
||||
const { store, resolver } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Georgia' });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const chosen = await resolver.resolve(
|
||||
{ candidateId: result.candidates[0].candidateId },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(chosen.code, 'CANCELLED');
|
||||
assert.equal(chosen.cancelled, true);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('candidate IDs accept every emitted choice and reject malformed or oversized input', async () => {
|
||||
const { store, resolver } = makeResolver();
|
||||
const result = await resolver.resolve({ query: 'Georgia' });
|
||||
const grammar = new RegExp(AREA_CANDIDATE_ID_PATTERN, 'u');
|
||||
for (const choice of result.candidates) {
|
||||
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
|
||||
assert.match(choice.candidateId, grammar);
|
||||
assert.equal(isAreaCandidateId(choice.candidateId), true);
|
||||
assert.equal(
|
||||
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
|
||||
true,
|
||||
);
|
||||
}
|
||||
assert.equal(result.candidates[0].candidateId.length, 41);
|
||||
|
||||
store.put({
|
||||
geometry: [[square(0, 0, 1, 1)]],
|
||||
name: 'Not a candidate',
|
||||
source: 'osm',
|
||||
sourceId: 'area-1',
|
||||
});
|
||||
for (const bad of [
|
||||
'area-1',
|
||||
'ne:state:bad slug',
|
||||
'ne:state:../../etc',
|
||||
'ne:state:!!!',
|
||||
'ne:state:a_b',
|
||||
'ne:planet:earth',
|
||||
`ne:state:${'a'.repeat(MAX_AREA_CANDIDATE_ID_LENGTH)}`,
|
||||
]) {
|
||||
const rejected = await resolver.resolve({ candidateId: bad });
|
||||
assert.equal(rejected.code, 'BAD_CANDIDATE');
|
||||
}
|
||||
});
|
||||
|
||||
test('long bundled names produce bounded, distinct candidate handles', async () => {
|
||||
const name = 'A'.repeat(70);
|
||||
const candidates = [
|
||||
admin('state', name, `B${'b'.repeat(70)}`, [0, 0, 1, 1]),
|
||||
admin('state', name, `C${'c'.repeat(70)}`, [2, 2, 3, 3]),
|
||||
];
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async () => candidates,
|
||||
});
|
||||
const result = await resolver.resolve({ query: name });
|
||||
assert.equal(result.needsClarification, true);
|
||||
assert.equal(
|
||||
new Set(result.candidates.map((choice) => choice.candidateId)).size,
|
||||
2,
|
||||
);
|
||||
for (const choice of result.candidates) {
|
||||
assert.equal(isAreaCandidateId(choice.candidateId), true);
|
||||
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
|
||||
assert.equal(
|
||||
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('within or level settles an ambiguous name', async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const state = await resolver.resolve({
|
||||
query: 'Georgia',
|
||||
within: 'United States',
|
||||
});
|
||||
assert.equal(state.ok, true);
|
||||
assert.equal(state.record.level, 'admin1');
|
||||
const country = await resolver.resolve({
|
||||
query: 'Georgia',
|
||||
level: 'country',
|
||||
});
|
||||
assert.equal(country.ok, true);
|
||||
assert.equal(country.record.level, 'country');
|
||||
const both = await resolver.resolve({ query: 'Punjab', level: 'admin1' });
|
||||
assert.equal(both.needsClarification, true, 'both Punjabs are admin1');
|
||||
const pk = await resolver.resolve({ query: 'Punjab', within: 'Pakistan' });
|
||||
assert.equal(pk.record.meta.country, 'Pakistan');
|
||||
});
|
||||
|
||||
test('the view settles an ambiguous name only when it is inside one of them', async () => {
|
||||
const { resolver } = makeResolver({
|
||||
viewCenter: () => ({ lat: 31, lon: 75.5 }),
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.record.meta.country, 'India');
|
||||
const away = makeResolver({ viewCenter: () => ({ lat: 0, lon: 0 }) });
|
||||
const asked = await away.resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(asked.needsClarification, true);
|
||||
// A choice the view made is not remembered for the name.
|
||||
let view = { lat: 31, lon: 75.5 };
|
||||
const moving = makeResolver({ viewCenter: () => view });
|
||||
await moving.resolver.resolve({ query: 'Punjab' });
|
||||
view = { lat: 31, lon: 71 };
|
||||
const again = await moving.resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(again.record.meta.country, 'Pakistan');
|
||||
});
|
||||
|
||||
test('a qualifier skips the natural-region pack, which cannot check it', async () => {
|
||||
const { resolver, calls } = makeResolver();
|
||||
await resolver.resolve({ query: 'the Alps', within: 'Austria' });
|
||||
assert.equal(calls.natural, 0);
|
||||
assert.equal(calls.outline.length, 1);
|
||||
});
|
||||
|
||||
test('the area just stored survives eviction even when every other area is pinned', () => {
|
||||
const store = createAreaStore({ maxEntries: 2, isPinned: () => true });
|
||||
for (let i = 0; i < 3; i += 1)
|
||||
store.put({
|
||||
geometry: [[square(i, 0, i + 0.5, 0.5)]],
|
||||
name: `A${i}`,
|
||||
source: 'drawn',
|
||||
});
|
||||
const last = store.put({
|
||||
geometry: [[square(9, 0, 9.5, 0.5)]],
|
||||
name: 'new',
|
||||
source: 'drawn',
|
||||
});
|
||||
assert.ok(store.get(last.areaId), 'the new handle is usable');
|
||||
});
|
||||
|
||||
test('a country stands for its same-named state; namesakes abroad are rivals', () => {
|
||||
const mexico = admin('country', 'Mexico', 'Mexico', [-118, 14, -86, 33]);
|
||||
const state = admin(
|
||||
'state',
|
||||
'State of Mexico',
|
||||
'Mexico',
|
||||
[-100, 18, -98, 20],
|
||||
);
|
||||
assert.deepEqual(distinctAdminPlaces([state, mexico]), [mexico]);
|
||||
assert.equal(distinctAdminPlaces([PUNJAB_IN, PUNJAB_PK]).length, 2);
|
||||
});
|
||||
|
||||
test('bundled candidates: shared prominent names list every unit, minor ones none', async () => {
|
||||
const names = async (q) =>
|
||||
(await findAdminCandidates(q)).map((a) => `${a.kind}:${a.country}`);
|
||||
assert.deepEqual((await names('Punjab')).sort(), [
|
||||
'state:India',
|
||||
'state:Pakistan',
|
||||
]);
|
||||
assert.deepEqual((await names('Georgia')).sort(), [
|
||||
'country:Georgia',
|
||||
'state:United States of America',
|
||||
]);
|
||||
assert.deepEqual(await names('Punjab, India'), ['state:India']);
|
||||
assert.deepEqual(await names('Kent'), [], 'minor units are the geocoder’s');
|
||||
});
|
||||
|
||||
test('an outline ladder refusal passes through with its code', async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const miss = await resolver.resolve({ query: 'Nowhere Province' });
|
||||
assert.equal(miss.ok, false);
|
||||
assert.equal(miss.code, 'AREA_NOT_FOUND');
|
||||
const down = makeResolver({
|
||||
resolveNamedOutline: async () => ({
|
||||
ok: false,
|
||||
code: 'AREA_UNAVAILABLE',
|
||||
error: 'No outline source can trace it here.',
|
||||
}),
|
||||
});
|
||||
const failed = await down.resolver.resolve({ query: 'Kathmandu' });
|
||||
assert.equal(failed.code, 'AREA_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('an approximate outline is stored and labelled as such', async () => {
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async () => ({
|
||||
ok: true,
|
||||
name: 'Mission',
|
||||
polygons: [[square(0, 0, 0.01, 0.01)]],
|
||||
source: 'osm',
|
||||
rung: 'overpass',
|
||||
approximate: true,
|
||||
basis: 'buffer sized to the place',
|
||||
}),
|
||||
});
|
||||
const result = await resolver.resolve({ query: 'the Mission' });
|
||||
assert.equal(result.record.approximate, true);
|
||||
assert.equal(result.record.source, 'approximate');
|
||||
assert.equal(result.record.meta.basis, 'buffer sized to the place');
|
||||
});
|
||||
|
||||
test('a slow lookup times out but keeps filling the cache', async () => {
|
||||
let release;
|
||||
const gate = new Promise((r) => {
|
||||
release = r;
|
||||
});
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async () => {
|
||||
await gate;
|
||||
return BAGMATI;
|
||||
},
|
||||
});
|
||||
const first = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
|
||||
assert.equal(first.code, 'AREA_TIMEOUT');
|
||||
release();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const second = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
|
||||
assert.equal(second.ok, true);
|
||||
assert.equal(second.cached, true);
|
||||
});
|
||||
|
||||
test('a cancelled caller returns at once without cancelling the shared lookup', async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await resolver.resolve(
|
||||
{ query: 'Bagmati' },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const again = await resolver.resolve({ query: 'Bagmati' });
|
||||
assert.equal(again.ok, true);
|
||||
});
|
||||
|
||||
test('distinct area lookups are hard-bounded while same-key callers still coalesce', async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
const { resolver } = makeResolver({
|
||||
maxInFlight: 1,
|
||||
resolveNamedOutline: async (query) => {
|
||||
calls += 1;
|
||||
await gate;
|
||||
return { ...BAGMATI, name: query };
|
||||
},
|
||||
});
|
||||
const first = resolver.resolve({ query: 'First Area' });
|
||||
const joined = resolver.resolve({ query: 'First Area' });
|
||||
const refused = await resolver.resolve({ query: 'Second Area' });
|
||||
assert.equal(refused.code, 'AREA_BUSY');
|
||||
release();
|
||||
const [a, b] = await Promise.all([first, joined]);
|
||||
assert.equal(calls, 1, 'the same key shares the one live provider lookup');
|
||||
assert.equal(a.ok, true);
|
||||
assert.equal(b.ok, true);
|
||||
});
|
||||
|
||||
test("settled lookups leave no listener on the caller's signal", async () => {
|
||||
const { resolver } = makeResolver();
|
||||
const controller = new AbortController();
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
const add = controller.signal.addEventListener.bind(controller.signal);
|
||||
const remove = controller.signal.removeEventListener.bind(controller.signal);
|
||||
controller.signal.addEventListener = (...args) => {
|
||||
if (args[0] === 'abort') added += 1;
|
||||
return add(...args);
|
||||
};
|
||||
controller.signal.removeEventListener = (...args) => {
|
||||
if (args[0] === 'abort') removed += 1;
|
||||
return remove(...args);
|
||||
};
|
||||
for (const query of ['Texas', 'Bagmati', 'Nowhere at all'])
|
||||
await resolver.resolve({ query }, { signal: controller.signal });
|
||||
assert.ok(added > 0, 'the lookups listened while running');
|
||||
assert.equal(removed, added, 'every listener is detached once settled');
|
||||
|
||||
const aborted = AbortSignal.abort();
|
||||
let attached = 0;
|
||||
aborted.addEventListener = () => {
|
||||
attached += 1;
|
||||
};
|
||||
const late = await resolver.resolve(
|
||||
{ query: 'Punjab, Pakistan' },
|
||||
{ signal: aborted },
|
||||
);
|
||||
assert.equal(late.code, 'CANCELLED');
|
||||
assert.equal(attached, 0, 'an aborted signal gets no listener');
|
||||
});
|
||||
|
||||
test('resolver disposal aborts provider lifetime and prevents late store/cache writes', async () => {
|
||||
let release;
|
||||
let providerSignal;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { resolver, store } = makeResolver({
|
||||
resolveNamedOutline: async (_query, { signal }) => {
|
||||
providerSignal = signal;
|
||||
await gate; // Deliberately ignores abort until it returns.
|
||||
return BAGMATI;
|
||||
},
|
||||
});
|
||||
const pending = resolver.resolve({ query: 'Late Area' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
resolver.dispose();
|
||||
assert.equal(providerSignal.aborted, true);
|
||||
release();
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(store.size, 0, 'late provider data never reaches the store');
|
||||
const after = await resolver.resolve({ query: 'Late Area' });
|
||||
assert.equal(after.code, 'CANCELLED');
|
||||
});
|
||||
|
||||
test('level words map to area levels', () => {
|
||||
assert.deepEqual(splitLevelWord('Bagmati Province'), {
|
||||
bare: 'bagmati',
|
||||
level: 'admin1',
|
||||
});
|
||||
assert.deepEqual(splitLevelWord('Kathmandu'), {
|
||||
bare: 'kathmandu',
|
||||
level: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('non-Latin names are distinct keys: 東京 then 大阪 looks up both', async () => {
|
||||
const asked = [];
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async (q) => {
|
||||
asked.push(q);
|
||||
return { ...BAGMATI, name: q };
|
||||
},
|
||||
});
|
||||
const first = await resolver.resolve({ query: '東京' });
|
||||
const second = await resolver.resolve({ query: '大阪' });
|
||||
assert.notEqual(first.record.areaId, second.record.areaId);
|
||||
assert.equal(second.cached, false, 'not the cached Tokyo');
|
||||
assert.deepEqual(asked, ['東京', '大阪']);
|
||||
// The same name with a different `within` is a different question.
|
||||
await resolver.resolve({ query: '東京', within: '日本' });
|
||||
assert.equal(asked.length, 3);
|
||||
assert.equal((await resolver.resolve({ query: '東京' })).cached, true);
|
||||
});
|
||||
|
||||
test('a name that normalizes to nothing is never cached or shared', async () => {
|
||||
let calls = 0;
|
||||
const { resolver } = makeResolver({
|
||||
resolveNamedOutline: async () => {
|
||||
calls += 1;
|
||||
return BAGMATI;
|
||||
},
|
||||
});
|
||||
await resolver.resolve({ query: '!!!' });
|
||||
await resolver.resolve({ query: '???' });
|
||||
assert.equal(calls, 2, 'two punctuation-only asks are two lookups');
|
||||
});
|
||||
|
||||
test('a natural region never hides a country or state of the same name', async () => {
|
||||
const desert = {
|
||||
name: 'Punjab',
|
||||
kind: 'natural',
|
||||
polygons: [square(70, 28, 76, 33)],
|
||||
};
|
||||
const { resolver } = makeResolver({
|
||||
findNaturalRegion: async (q) => (/^punjab$/i.test(q) ? desert : null),
|
||||
});
|
||||
const bare = await resolver.resolve({ query: 'Punjab' });
|
||||
assert.equal(bare.ok, false);
|
||||
assert.equal(bare.code, 'AMBIGUOUS_AREA', 'the two states are asked about');
|
||||
const natural = await resolver.resolve({ query: 'Punjab', level: 'natural' });
|
||||
assert.equal(natural.ok, true);
|
||||
assert.equal(natural.record.level, 'natural', 'asked for, the region wins');
|
||||
});
|
||||
|
||||
test('same-named states in one country get their own candidate and area', async () => {
|
||||
const wa = admin(
|
||||
'state',
|
||||
'Washington',
|
||||
'United States of America',
|
||||
[-124, 45.5, -117, 49],
|
||||
{ regionCode: 'WA' },
|
||||
);
|
||||
const dc = admin(
|
||||
'state',
|
||||
'Washington',
|
||||
'United States of America',
|
||||
[-77.2, 38.8, -76.9, 39],
|
||||
{ regionCode: 'DC' },
|
||||
);
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async (q) => (/^washington$/i.test(q) ? [wa, dc] : []),
|
||||
});
|
||||
const asked = await resolver.resolve({ query: 'Washington' });
|
||||
assert.equal(asked.code, 'AMBIGUOUS_AREA');
|
||||
const ids = asked.candidates.map((c) => c.candidateId);
|
||||
assert.equal(new Set(ids).size, 2, `distinct ids: ${ids.join(', ')}`);
|
||||
assert.deepEqual(
|
||||
asked.candidates.map((c) => c.region),
|
||||
['WA', 'DC'],
|
||||
'the choices can be told apart',
|
||||
);
|
||||
const picked = await Promise.all(
|
||||
ids.map((candidateId) => resolver.resolve({ candidateId })),
|
||||
);
|
||||
assert.ok(picked.every((p) => p.ok));
|
||||
assert.notEqual(
|
||||
picked[0].record.areaId,
|
||||
picked[1].record.areaId,
|
||||
'two areas, not one overwritten',
|
||||
);
|
||||
assert.equal(
|
||||
pointInPreparedArea(picked[0].record.prepared, 47, -120),
|
||||
true,
|
||||
'Washington State',
|
||||
);
|
||||
assert.equal(
|
||||
pointInPreparedArea(picked[1].record.prepared, 38.9, -77),
|
||||
true,
|
||||
'District of Columbia',
|
||||
);
|
||||
});
|
||||
|
||||
test('a bundled name mismatch falls through to the outline ladder', async () => {
|
||||
const azad = admin('state', 'Azad Kashmir', 'Pakistan', [73, 33, 75, 35.5]);
|
||||
const kashmir = {
|
||||
ok: true,
|
||||
name: 'Kashmir',
|
||||
polygons: [[square(73, 32, 80, 37)]],
|
||||
source: 'osm',
|
||||
rung: 'nominatim',
|
||||
};
|
||||
let outlineOk = true;
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async (q) => (/^kashmir$/i.test(q) ? [azad] : []),
|
||||
resolveNamedOutline: async () =>
|
||||
outlineOk
|
||||
? kashmir
|
||||
: { ok: false, code: 'AREA_NOT_FOUND', error: 'none' },
|
||||
});
|
||||
const found = await resolver.resolve({ query: 'Kashmir' });
|
||||
assert.equal(found.ok, true, found.error);
|
||||
assert.equal(found.record.name, 'Kashmir');
|
||||
outlineOk = false;
|
||||
const { resolver: offline } = makeResolver({
|
||||
findAdminCandidates: async () => [azad],
|
||||
resolveNamedOutline: async () => ({
|
||||
ok: false,
|
||||
code: 'AREA_NOT_FOUND',
|
||||
error: 'none',
|
||||
}),
|
||||
});
|
||||
const refused = await offline.resolve({ query: 'Kashmir' });
|
||||
assert.equal(
|
||||
refused.code,
|
||||
'AREA_IDENTITY_MISMATCH',
|
||||
'the bundled refusal is kept',
|
||||
);
|
||||
});
|
||||
|
||||
test('a view-picked outline answer is not reused for the same name elsewhere', async () => {
|
||||
let view = { lat: 39.8, lon: -89.6 }; // Springfield, Illinois
|
||||
const springfield = (center) => ({
|
||||
ok: true,
|
||||
name:
|
||||
center.lon < -80 ? 'Springfield, Illinois' : 'Springfield, Massachusetts',
|
||||
polygons: [
|
||||
[
|
||||
square(
|
||||
center.lon - 0.1,
|
||||
center.lat - 0.1,
|
||||
center.lon + 0.1,
|
||||
center.lat + 0.1,
|
||||
),
|
||||
],
|
||||
],
|
||||
source: 'osm',
|
||||
rung: 'nominatim',
|
||||
});
|
||||
const { resolver } = makeResolver({
|
||||
viewCenter: () => view,
|
||||
resolveNamedOutline: async () => springfield(view),
|
||||
});
|
||||
const illinois = await resolver.resolve({ query: 'Springfield' });
|
||||
assert.equal(illinois.record.name, 'Springfield, Illinois');
|
||||
const again = await resolver.resolve({ query: 'Springfield' });
|
||||
assert.equal(again.cached, true, 'the same view still reuses it');
|
||||
view = { lat: 42.1, lon: -72.6 }; // Springfield, Massachusetts
|
||||
const massachusetts = await resolver.resolve({ query: 'Springfield' });
|
||||
assert.equal(massachusetts.cached, false);
|
||||
assert.equal(massachusetts.record.name, 'Springfield, Massachusetts');
|
||||
});
|
||||
|
||||
test('a name spoken without accents matches its accented unit, Latin letters only', async () => {
|
||||
const quebec = admin('state', 'Québec', 'Canada', [-79.8, 45, -57.1, 62.6]);
|
||||
const cordobaAr = admin(
|
||||
'state',
|
||||
'Córdoba',
|
||||
'Argentina',
|
||||
[-65.8, -35, -61.8, -29.5],
|
||||
);
|
||||
const cordobaEs = admin('state', 'Córdoba', 'Spain', [-5.6, 37.2, -4, 38.7]);
|
||||
const thai = admin('state', 'ป่าย', 'Thailand', [98, 19, 99, 20]);
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async (q) =>
|
||||
/^quebec$/i.test(q)
|
||||
? [quebec]
|
||||
: /^cordoba$/i.test(q)
|
||||
? [cordobaAr, cordobaEs]
|
||||
: q === 'ปาย'
|
||||
? [thai]
|
||||
: [],
|
||||
});
|
||||
const qc = await resolver.resolve({ query: 'Quebec' });
|
||||
assert.equal(qc.ok, true, qc.error);
|
||||
assert.equal(qc.record.name, 'Québec');
|
||||
const cordoba = await resolver.resolve({ query: 'Cordoba' });
|
||||
assert.equal(cordoba.code, 'AMBIGUOUS_AREA', 'both Córdobas are asked about');
|
||||
assert.equal(cordoba.candidates.length, 2);
|
||||
const tone = await resolver.resolve({ query: 'ปาย' });
|
||||
assert.equal(tone.ok, false, 'a Thai tone mark still tells names apart');
|
||||
});
|
||||
|
||||
test('a natural name for two distant places is asked about, and the choice resolves', async () => {
|
||||
const azores = {
|
||||
name: 'Flores',
|
||||
featurecla: 'Island',
|
||||
polygons: [square(-31.3, 39.4, -31.1, 39.5)],
|
||||
areaKm2: 140,
|
||||
center: { lat: 39.45, lon: -31.2 },
|
||||
};
|
||||
const indonesia = {
|
||||
name: 'Flores',
|
||||
featurecla: 'Island',
|
||||
polygons: [square(119.8, -8.9, 123, -8.1)],
|
||||
areaKm2: 14000,
|
||||
center: { lat: -8.5, lon: 121.4 },
|
||||
};
|
||||
const { resolver } = makeResolver({
|
||||
findNaturalRegion: async (q) =>
|
||||
/^flores$/i.test(q)
|
||||
? {
|
||||
name: 'Flores',
|
||||
kind: 'natural',
|
||||
polygons: indonesia.polygons,
|
||||
places: [indonesia, azores],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const asked = await resolver.resolve({ query: 'Flores' });
|
||||
assert.equal(asked.code, 'AMBIGUOUS_AREA');
|
||||
assert.equal(asked.candidates.length, 2);
|
||||
assert.ok(asked.candidates.every((c) => c.level === 'natural' && c.region));
|
||||
const picked = await resolver.resolve({
|
||||
candidateId: asked.candidates[1].candidateId,
|
||||
});
|
||||
assert.equal(picked.ok, true, picked.error);
|
||||
assert.equal(picked.record.level, 'natural');
|
||||
assert.equal(
|
||||
pointInPreparedArea(picked.record.prepared, 39.45, -31.2),
|
||||
true,
|
||||
'the Azores Flores',
|
||||
);
|
||||
});
|
||||
|
||||
test('a natural feature in several parts is stored whole', async () => {
|
||||
const north = square(33.7, 2.4, 44.2, 15.4);
|
||||
const south = square(28.9, -15.6, 35.8, 2.4);
|
||||
const { resolver } = makeResolver({
|
||||
findNaturalRegion: async (q) =>
|
||||
/rift/i.test(q)
|
||||
? {
|
||||
name: 'Great Rift Valley',
|
||||
kind: 'natural',
|
||||
polygons: [north],
|
||||
places: [
|
||||
{
|
||||
name: 'Great Rift Valley',
|
||||
featurecla: 'Valley',
|
||||
polygons: [north, south],
|
||||
areaKm2: 2,
|
||||
center: { lat: 0, lon: 36 },
|
||||
},
|
||||
],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const rift = await resolver.resolve({ query: 'Great Rift Valley' });
|
||||
assert.equal(rift.ok, true);
|
||||
assert.equal(
|
||||
pointInPreparedArea(rift.record.prepared, -10, 33),
|
||||
true,
|
||||
'the southern part counts',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Session-scoped store of area handles. A voice turn refers to an area by its
|
||||
* `areaId`; the geometry itself stays here and never goes to the model.
|
||||
*
|
||||
* Records are immutable once stored. A source with a stable identity (an OSM
|
||||
* relation, a Natural Earth region, an annotation) keeps the same id when it
|
||||
* is stored again, so repeating "outline Bagmati" does not mint new handles.
|
||||
* Least recently used records are evicted past `maxEntries`, except those
|
||||
* `isPinned` reports as still in use.
|
||||
*
|
||||
* Pure: no Cesium, DOM or network.
|
||||
* @module data/areaStore
|
||||
*/
|
||||
|
||||
import {
|
||||
multiPolygonAreaKm2,
|
||||
multiPolygonCounts,
|
||||
normalizeMultiPolygon,
|
||||
prepareArea,
|
||||
} from './areaGeometry.js';
|
||||
|
||||
/** Where an area came from, as the model and the panel name it. */
|
||||
export const AREA_SOURCES = Object.freeze([
|
||||
'natural-earth',
|
||||
'us-census',
|
||||
'wof',
|
||||
'datasf',
|
||||
'osm',
|
||||
'openfreemap',
|
||||
'annotation',
|
||||
'drawn',
|
||||
'approximate',
|
||||
]);
|
||||
|
||||
/** Levels a resolved area can have. */
|
||||
export const AREA_LEVELS = Object.freeze([
|
||||
'country',
|
||||
'admin1',
|
||||
'admin2',
|
||||
'city',
|
||||
'district',
|
||||
'natural',
|
||||
'site',
|
||||
'drawn',
|
||||
]);
|
||||
|
||||
const round = (value, digits) => {
|
||||
const f = 10 ** digits;
|
||||
return Math.round(value * f) / f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an area store.
|
||||
* @param {{maxEntries?: number, maxQueryEntries?: number, now?: () => number, isPinned?: (areaId: string) => boolean}} [options]
|
||||
*/
|
||||
export function createAreaStore({
|
||||
maxEntries = 32,
|
||||
maxQueryEntries = Math.max(32, maxEntries * 8),
|
||||
now = () => Date.now(),
|
||||
isPinned = () => false,
|
||||
} = {}) {
|
||||
/** Insertion order doubles as recency: get() re-inserts. */
|
||||
const records = new Map();
|
||||
/** query key → areaId, for cached resolutions. */
|
||||
const queries = new Map();
|
||||
let seq = 0;
|
||||
|
||||
function forgetRecordQueries(areaId) {
|
||||
for (const [key, queried] of queries)
|
||||
if (queried === areaId) queries.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop least recently used records past the bound, skipping pinned ones
|
||||
* (an area an outline on the map still refers to keeps its geometry for as
|
||||
* long as the outline exists; the annotation cap bounds how many that is).
|
||||
*/
|
||||
function evict(keep) {
|
||||
if (records.size <= maxEntries) return;
|
||||
for (const id of [...records.keys()]) {
|
||||
if (records.size <= maxEntries) break;
|
||||
// The record just stored is always kept; pinned ones may exceed the bound.
|
||||
if (id === keep || isPinned(id)) continue;
|
||||
records.delete(id);
|
||||
forgetRecordQueries(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an area. Returns the stored record, or null when the geometry is
|
||||
* unusable.
|
||||
* @param {object} input
|
||||
* @param {object|Array} input.geometry GeoJSON Polygon/MultiPolygon or coordinates.
|
||||
* @param {string} input.name
|
||||
* @param {string} input.source One of AREA_SOURCES.
|
||||
* @param {string} [input.sourceId] Stable identity, e.g. `ne:state:india-punjab`.
|
||||
* @param {string} [input.level]
|
||||
* @param {boolean} [input.approximate]
|
||||
* @param {object} [input.meta] Extra facts (country, license, completeness).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function put(input) {
|
||||
const geometry = normalizeMultiPolygon(input?.geometry);
|
||||
if (!geometry) return null;
|
||||
const prepared = prepareArea(geometry);
|
||||
if (!prepared) return null;
|
||||
const areaId = input.sourceId
|
||||
? String(input.sourceId)
|
||||
: `area-${(seq += 1)}`;
|
||||
const counts = multiPolygonCounts(geometry);
|
||||
const record = Object.freeze({
|
||||
areaId,
|
||||
name: String(input.name || 'Unnamed area').slice(0, 160),
|
||||
level: AREA_LEVELS.includes(input.level) ? input.level : null,
|
||||
source: AREA_SOURCES.includes(input.source)
|
||||
? input.source
|
||||
: 'approximate',
|
||||
approximate: Boolean(input.approximate),
|
||||
geometry,
|
||||
prepared,
|
||||
bbox: prepared.bbox,
|
||||
areaKm2: multiPolygonAreaKm2(geometry),
|
||||
parts: counts.parts,
|
||||
holes: counts.holes,
|
||||
meta: Object.freeze({ ...(input.meta || {}) }),
|
||||
storedAt: now(),
|
||||
});
|
||||
records.delete(areaId);
|
||||
records.set(areaId, record);
|
||||
evict(areaId);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** The record for an id, marking it recently used; null when unknown or evicted. */
|
||||
function get(areaId) {
|
||||
const record = records.get(String(areaId ?? ''));
|
||||
if (!record) return null;
|
||||
records.delete(record.areaId);
|
||||
records.set(record.areaId, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
return {
|
||||
put,
|
||||
get,
|
||||
has: (areaId) => records.has(String(areaId ?? '')),
|
||||
/** Remember which area a query resolved to. */
|
||||
rememberQuery(key, areaId) {
|
||||
if (!key || !records.has(areaId)) return;
|
||||
queries.delete(key);
|
||||
queries.set(key, areaId);
|
||||
while (queries.size > maxQueryEntries)
|
||||
queries.delete(queries.keys().next().value);
|
||||
},
|
||||
/** The area a query resolved to earlier, if still stored. */
|
||||
recallQuery(key) {
|
||||
const id = queries.get(key);
|
||||
const record = id ? get(id) : null;
|
||||
if (!record) {
|
||||
queries.delete(key);
|
||||
return null;
|
||||
}
|
||||
queries.delete(key);
|
||||
queries.set(key, id);
|
||||
return record;
|
||||
},
|
||||
/** Newest first. */
|
||||
list: () => [...records.values()].reverse(),
|
||||
/** The most recently stored or used record matching `predicate`. */
|
||||
latest(predicate = () => true) {
|
||||
return [...records.values()].reverse().find(predicate) || null;
|
||||
},
|
||||
delete(areaId) {
|
||||
const id = String(areaId ?? '');
|
||||
const deleted = records.delete(id);
|
||||
if (deleted) forgetRecordQueries(id);
|
||||
return deleted;
|
||||
},
|
||||
clear() {
|
||||
records.clear();
|
||||
queries.clear();
|
||||
},
|
||||
get size() {
|
||||
return records.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing summary of an area: identity, size and provenance, never
|
||||
* coordinates beyond a rounded bbox.
|
||||
* @param {object} record
|
||||
* @returns {object}
|
||||
*/
|
||||
export function summarizeArea(record) {
|
||||
if (!record) return null;
|
||||
return {
|
||||
areaId: record.areaId,
|
||||
name: record.name,
|
||||
level: record.level,
|
||||
source: record.source,
|
||||
...(record.meta.country ? { country: record.meta.country } : {}),
|
||||
bbox: record.bbox.map((v) => round(v, 3)),
|
||||
areaKm2:
|
||||
record.areaKm2 >= 100
|
||||
? Math.round(record.areaKm2)
|
||||
: round(record.areaKm2, 2),
|
||||
parts: record.parts,
|
||||
holes: record.holes,
|
||||
...(record.approximate ? { approximate: true } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { t, msg, onLocaleChange } from '../i18n/index.js';
|
||||
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
'youtube.com',
|
||||
@@ -569,7 +569,8 @@ function anchorOnScreen(viewer, anchor, root) {
|
||||
return { x, y, width: rootBounds.width, height: rootBounds.height };
|
||||
}
|
||||
|
||||
function placeCard(card, leader, anchor) {
|
||||
/** Place a media card beside its map anchor, in the lane between the panels. */
|
||||
export function placeCard(card, leader, anchor) {
|
||||
const margin = 18;
|
||||
const gap = 42;
|
||||
const width = card.offsetWidth || 392;
|
||||
@@ -580,22 +581,22 @@ function placeCard(card, leader, anchor) {
|
||||
let laneRight = anchor.width - margin;
|
||||
let laneBottom = anchor.height - margin;
|
||||
if (rootBounds && documentRef?.querySelector) {
|
||||
const leftPanel = documentRef
|
||||
.querySelector('#scene-panel')
|
||||
?.getBoundingClientRect?.();
|
||||
const rightRail = documentRef
|
||||
.querySelector('#right-context-rail')
|
||||
?.getBoundingClientRect?.();
|
||||
const commandDock = documentRef
|
||||
.querySelector('#command-dock')
|
||||
?.getBoundingClientRect?.();
|
||||
if (leftPanel?.width > 0)
|
||||
laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
|
||||
if (rightRail?.width > 0)
|
||||
laneRight = Math.min(
|
||||
laneRight,
|
||||
rightRail.left - rootBounds.left - margin,
|
||||
);
|
||||
// The scene panel and the context rail swap sides in right-to-left
|
||||
// languages: each narrows the lane from the side it sits on.
|
||||
for (const selector of ['#scene-panel', '#right-context-rail']) {
|
||||
const panel = documentRef
|
||||
.querySelector(selector)
|
||||
?.getBoundingClientRect?.();
|
||||
if (!(panel?.width > 0)) continue;
|
||||
const start = panel.left - rootBounds.left;
|
||||
const end = panel.right - rootBounds.left;
|
||||
if (start + end < rootBounds.width)
|
||||
laneLeft = Math.max(laneLeft, end + margin);
|
||||
else laneRight = Math.min(laneRight, start - margin);
|
||||
}
|
||||
if (commandDock?.height > 0)
|
||||
laneBottom = Math.min(
|
||||
laneBottom,
|
||||
@@ -674,6 +675,30 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
let record = null;
|
||||
let warmRecord = null;
|
||||
let generation = 0;
|
||||
// Text the cards show, kept as the calls that word it so a locale change
|
||||
// words it again. A node is forgotten once it has left the page.
|
||||
const worded = new Map();
|
||||
function forgetDetached() {
|
||||
for (const node of worded.keys())
|
||||
if (!node.isConnected) worded.delete(node);
|
||||
}
|
||||
function word(node, property, text) {
|
||||
let properties = worded.get(node);
|
||||
if (!properties) worded.set(node, (properties = new Map()));
|
||||
properties.set(property, text);
|
||||
writeWord(node, property, text());
|
||||
}
|
||||
function writeWord(node, property, value) {
|
||||
if (property === 'textContent' || property === 'title')
|
||||
node[property] = value;
|
||||
else node.setAttribute(property, value);
|
||||
}
|
||||
const offLocale = onLocaleChange(() => {
|
||||
forgetDetached();
|
||||
for (const [node, properties] of worded)
|
||||
for (const [property, text] of properties)
|
||||
writeWord(node, property, text());
|
||||
});
|
||||
const leavingRecords = new Set();
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
@@ -818,6 +843,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
current.youtubeSession?.destroy();
|
||||
current.card?.remove?.();
|
||||
leavingRecords.delete(current);
|
||||
forgetDetached();
|
||||
}
|
||||
|
||||
const position = () => {
|
||||
@@ -871,6 +897,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
destroyFacebookSession(warmRecord.facebookSession);
|
||||
else warmRecord.iframe?.remove?.();
|
||||
warmRecord = null;
|
||||
forgetDetached();
|
||||
}
|
||||
|
||||
function takeWarmFacebook(key, host) {
|
||||
@@ -917,9 +944,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
startAtSec,
|
||||
endAtSec,
|
||||
});
|
||||
iframe.title = t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
word(iframe, 'title', () =>
|
||||
t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
}),
|
||||
);
|
||||
iframe.tabIndex = -1;
|
||||
iframe.setAttribute('aria-hidden', 'true');
|
||||
iframe.loading = 'eager';
|
||||
@@ -930,7 +959,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
async function mountX(current, token) {
|
||||
current.status.textContent = t('LOADING X POST…');
|
||||
word(current.status, 'textContent', () => t('LOADING X POST…'));
|
||||
try {
|
||||
const api = await xLoader(documentRef, globalRef);
|
||||
if (token !== generation || record !== current) return;
|
||||
@@ -950,14 +979,18 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
throw new Error(
|
||||
msg('The X post is unavailable or cannot be embedded.'),
|
||||
);
|
||||
current.status.textContent = t('X POST · PROVIDER CONTROLS');
|
||||
word(current.status, 'textContent', () =>
|
||||
t('X POST · PROVIDER CONTROLS'),
|
||||
);
|
||||
position();
|
||||
} catch (error) {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
});
|
||||
word(current.status, 'textContent', () =>
|
||||
t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
}),
|
||||
);
|
||||
position();
|
||||
}
|
||||
}
|
||||
@@ -970,22 +1003,28 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
session.shouldPlay = autoplay === true;
|
||||
session.onReady = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
|
||||
word(current.status, 'textContent', () =>
|
||||
t('FACEBOOK · PROVIDER CONTROLS'),
|
||||
);
|
||||
current.card.classList.remove('is-unavailable');
|
||||
position();
|
||||
};
|
||||
session.onError = (error) => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
|
||||
});
|
||||
word(current.status, 'textContent', () =>
|
||||
t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(
|
||||
String(error?.message || msg('Facebook embed unavailable')),
|
||||
),
|
||||
}),
|
||||
);
|
||||
position();
|
||||
};
|
||||
session.onPlaybackBlocked = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = t(
|
||||
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
|
||||
word(current.status, 'textContent', () =>
|
||||
t('AUTOPLAY BLOCKED · USE PROVIDER CONTROLS'),
|
||||
);
|
||||
};
|
||||
if (session.ready) session.onReady();
|
||||
@@ -1036,8 +1075,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
|
||||
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
|
||||
card.dataset.provider = source.provider;
|
||||
card.setAttribute(
|
||||
'aria-label',
|
||||
word(card, 'aria-label', () =>
|
||||
t('Embedded {provider} media for {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
@@ -1054,22 +1092,26 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
leader.innerHTML =
|
||||
'<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
|
||||
const player = documentRef.createElement('div');
|
||||
player.className = 'bhote-embedded-callout-player';
|
||||
player.className = 'bhote-embedded-callout-player ui-scroll';
|
||||
const footer = documentRef.createElement('footer');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = bhoteEventText(
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase();
|
||||
word(title, 'textContent', () =>
|
||||
bhoteEventText(
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase(),
|
||||
);
|
||||
const status = documentRef.createElement('small');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
word(status, 'textContent', () =>
|
||||
t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
}),
|
||||
);
|
||||
const link = documentRef.createElement('a');
|
||||
link.href = source.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = t('OPEN ORIGINAL ↗');
|
||||
word(link, 'textContent', () => t('OPEN ORIGINAL ↗'));
|
||||
footer.append(title, status, link);
|
||||
const body = documentRef.createElement('div');
|
||||
body.className = 'bhote-embedded-callout-body';
|
||||
@@ -1109,12 +1151,14 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
url.searchParams.set('origin', globalRef.location.origin);
|
||||
iframe.src = url.href;
|
||||
}
|
||||
iframe.title = t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
});
|
||||
word(iframe, 'title', () =>
|
||||
t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
}),
|
||||
);
|
||||
iframe.allow =
|
||||
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||
iframe.allowFullscreen = true;
|
||||
@@ -1131,9 +1175,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
record.youtubeSession.getState().phase,
|
||||
)
|
||||
) {
|
||||
status.textContent = t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
word(status, 'textContent', () =>
|
||||
t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
}),
|
||||
);
|
||||
}
|
||||
position();
|
||||
},
|
||||
@@ -1151,11 +1197,13 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
isCurrent: () => token === generation && record === current,
|
||||
onStatus: (phase) => {
|
||||
if (phase !== 'completed')
|
||||
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
});
|
||||
word(status, 'textContent', () =>
|
||||
t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1165,6 +1213,8 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
offLocale();
|
||||
worded.clear();
|
||||
hide({ immediate: true });
|
||||
for (const current of [...leavingRecords]) removeRecord(current);
|
||||
clearWarm();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createBhoteKoshiEmbeddedMedia,
|
||||
createTrimmedYouTubePlayback,
|
||||
embeddedMediaFrameUrl,
|
||||
placeCard,
|
||||
publicEmbeddedMediaUrl,
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
@@ -348,7 +349,7 @@ test('camera updates retain a preload until the visible player loads', () => {
|
||||
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
|
||||
const card = root.children.find(node => node.tag === 'section');
|
||||
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
|
||||
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
|
||||
const player = body.children.find(node => node.className.split(' ').includes('bhote-embedded-callout-player'));
|
||||
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
|
||||
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
|
||||
media.destroy();
|
||||
@@ -394,3 +395,25 @@ test('pausing cancels a mounted Facebook preload and rejects its late ready call
|
||||
assert.deepEqual(playerCalls, []);
|
||||
media.destroy();
|
||||
});
|
||||
|
||||
test('a media card stays between the panels when right-to-left swaps their sides', () => {
|
||||
const rect = (left, right, top = 0, bottom = 900) => ({ left, right, top, bottom, width: right - left, height: bottom - top });
|
||||
// Arabic: the scene panel sits on the right, the context rail on the left.
|
||||
const panels = {
|
||||
'#scene-panel': rect(1040, 1420),
|
||||
'#right-context-rail': rect(20, 340),
|
||||
'#command-dock': rect(500, 940, 820, 880),
|
||||
};
|
||||
const card = {
|
||||
offsetWidth: 392,
|
||||
offsetHeight: 360,
|
||||
parentElement: { getBoundingClientRect: () => rect(0, 1440) },
|
||||
ownerDocument: { querySelector: (selector) => ({ getBoundingClientRect: () => panels[selector] }) },
|
||||
classList: { contains: () => false },
|
||||
style: {},
|
||||
};
|
||||
placeCard(card, { querySelector: () => null }, { x: 700, y: 400, width: 1440, height: 900 });
|
||||
const left = parseFloat(card.style.left);
|
||||
assert.ok(left >= 340 + 18, `card at ${left}px lands on the context rail`);
|
||||
assert.ok(left + 392 <= 1040 - 18, `card at ${left}px lands on the scene panel`);
|
||||
});
|
||||
+164
-203
@@ -25,7 +25,9 @@ import {
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
||||
import { createImagerySplit } from '../ui/imagerySplit.js';
|
||||
import { formatDate, t, tn, msg } from '../i18n/index.js';
|
||||
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
|
||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||
@@ -412,14 +414,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
|
||||
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
|
||||
]);
|
||||
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
|
||||
function imageryDateText(observedAt) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
|
||||
if (!match) return '';
|
||||
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
|
||||
const year = part({ year: 'numeric' });
|
||||
// Year-first languages (2021年10月16日) keep their own order and units.
|
||||
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
|
||||
if (whole.startsWith(year)) return whole;
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -445,18 +451,6 @@ function imageryEndpointText(entry) {
|
||||
return [name, date].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** Name, then the date kept on one line so the year never wraps alone. */
|
||||
function renderImageryEndpoint(element, entry) {
|
||||
if (!element) return;
|
||||
const { name, date } = imageryEndpointLabel(entry);
|
||||
element.textContent = date ? `${name} · ` : name;
|
||||
if (!date) return;
|
||||
const dateElement = document.createElement('span');
|
||||
dateElement.className = 'bhote-event-date';
|
||||
dateElement.textContent = date;
|
||||
element.appendChild(dateElement);
|
||||
}
|
||||
|
||||
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
||||
export function evidenceCorroborationLabel(observation) {
|
||||
const count = Math.max(
|
||||
@@ -925,185 +919,70 @@ function setElementProperty(element, property, value) {
|
||||
}
|
||||
|
||||
function setElementAttribute(element, attribute, value) {
|
||||
if (!element || element.getAttribute?.(attribute) === value) return false;
|
||||
element.setAttribute(attribute, value);
|
||||
if (!element || element.attributes[attribute] === value) return false;
|
||||
element.attributes[attribute] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectPanelReferences(panel) {
|
||||
/** One panel control's state, as the panel view renders it. */
|
||||
function panelRecord(initial = {}) {
|
||||
return {
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
value: '',
|
||||
...initial,
|
||||
attributes: { ...initial.attributes },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's controls as the layer writes them, starting from the panel's
|
||||
* markup. The panel view renders what the layer publishes from them.
|
||||
*/
|
||||
function createPanelRecords(event) {
|
||||
return Object.freeze({
|
||||
imagerySection: panel.querySelector('[data-role="imagery-comparison"]'),
|
||||
observedStatus: panel.querySelector('.bhote-event-status.observed'),
|
||||
beforeDate: panel.querySelector('[data-role="before-date"]'),
|
||||
afterDate: panel.querySelector('[data-role="after-date"]'),
|
||||
time: panel.querySelector('[data-role="time"]'),
|
||||
caveat: panel.querySelector('[data-role="caveat"]'),
|
||||
imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'),
|
||||
reportList: panel.querySelector('.bhote-event-report-list'),
|
||||
playButton: panel.querySelector('[data-action="play"]'),
|
||||
playSceneButton: panel.querySelector('[data-action="play-scene"]'),
|
||||
cinematicButton: panel.querySelector('[data-action="cinematic"]'),
|
||||
storyReplayButton: panel.querySelector('[data-action="story-replay"]'),
|
||||
corridorButton: panel.querySelector('[data-action="corridor"]'),
|
||||
previousButton: panel.querySelector('[data-action="previous-beat"]'),
|
||||
nextButton: panel.querySelector('[data-action="next-beat"]'),
|
||||
openSourceButton: panel.querySelector('[data-action="open-source"]'),
|
||||
progressInput: panel.querySelector('[data-role="progress"]'),
|
||||
splitInput: panel.querySelector('[data-role="split"]'),
|
||||
splitReadout: panel.querySelector('[data-role="split-readout"]'),
|
||||
timelineLabel: panel.querySelector('[data-role="timeline-label"]'),
|
||||
beatIndex: panel.querySelector('[data-role="beat-index"]'),
|
||||
beatTitle: panel.querySelector('[data-role="beat-title"]'),
|
||||
beatMeta: panel.querySelector('[data-role="beat-meta"]'),
|
||||
root: { sceneBeat: false },
|
||||
imagerySection: panelRecord(),
|
||||
observedStatus: panelRecord(),
|
||||
time: panelRecord({
|
||||
textContent: `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`,
|
||||
}),
|
||||
caveat: panelRecord({
|
||||
textContent: bhoteEventText(event.reconstruction.caveat),
|
||||
}),
|
||||
imageryCloudNote: panelRecord(),
|
||||
playButton: panelRecord({ textContent: t('▶ PLAY') }),
|
||||
playSceneButton: panelRecord({
|
||||
textContent: t('▶ PLAY SCENE'),
|
||||
hidden: true,
|
||||
}),
|
||||
cinematicButton: panelRecord({
|
||||
textContent: t('◉ CINEMATIC'),
|
||||
attributes: { 'aria-pressed': 'false' },
|
||||
}),
|
||||
storyReplayButton: panelRecord({ textContent: t('↺ FULL STORY') }),
|
||||
corridorButton: panelRecord(),
|
||||
previousButton: panelRecord({
|
||||
attributes: { 'aria-label': t('Previous story beat') },
|
||||
}),
|
||||
nextButton: panelRecord({
|
||||
attributes: { 'aria-label': t('Next story beat') },
|
||||
}),
|
||||
openSourceButton: panelRecord(),
|
||||
progressInput: panelRecord({
|
||||
value: '0',
|
||||
attributes: { 'aria-label': t('Schematic downstream progression') },
|
||||
}),
|
||||
splitInput: panelRecord({ value: '50' }),
|
||||
splitReadout: panelRecord({ textContent: '50 / 50' }),
|
||||
timelineLabel: panelRecord({ textContent: t('RECONSTRUCTION CLOCK') }),
|
||||
beatIndex: panelRecord({ textContent: '01 / 06' }),
|
||||
beatTitle: panelRecord({ textContent: t('CAUSE') }),
|
||||
beatMeta: panelRecord({ textContent: t('CAPTURE TIME UNVERIFIED') }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape translated text for the panel markup template. */
|
||||
function escapeMarkup(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function createPanel(event, handlers) {
|
||||
const panel = document.createElement('aside');
|
||||
panel.id = 'bhote-koshi-event-panel';
|
||||
panel.setAttribute(
|
||||
'aria-label',
|
||||
t('Bhote Koshi flood reconstruction controls'),
|
||||
);
|
||||
panel.innerHTML = `
|
||||
<div class="bhote-event-header">
|
||||
<div>
|
||||
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
|
||||
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
|
||||
</div>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
|
||||
</div>
|
||||
<div class="bhote-event-status-row">
|
||||
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
|
||||
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
|
||||
</div>
|
||||
<section class="bhote-event-section" data-role="imagery-comparison">
|
||||
<div class="bhote-event-section-head">
|
||||
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
|
||||
<span data-role="split-readout">50 / 50</span>
|
||||
</div>
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
|
||||
<div class="bhote-event-dates">
|
||||
<span data-role="before-date"></span>
|
||||
<span data-role="after-date"></span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="bhote-event-section">
|
||||
<div class="bhote-event-section-head">
|
||||
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
|
||||
<span data-role="time"></span>
|
||||
</div>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
|
||||
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
|
||||
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
|
||||
<div class="bhote-event-story-current">
|
||||
<span data-role="beat-index">01 / 06</span>
|
||||
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
|
||||
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
|
||||
</div>
|
||||
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
|
||||
</div>
|
||||
<div class="bhote-event-actions">
|
||||
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
|
||||
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
|
||||
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
|
||||
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
|
||||
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
|
||||
</div>
|
||||
</section>
|
||||
<details class="bhote-event-evidence">
|
||||
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
|
||||
<div class="bhote-event-report-list"></div>
|
||||
</details>
|
||||
<div class="bhote-event-credit">
|
||||
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||
</div>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
|
||||
`;
|
||||
|
||||
const refs = collectPanelReferences(panel);
|
||||
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
|
||||
renderImageryEndpoint(refs.afterDate, event.imagery.after);
|
||||
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
|
||||
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
|
||||
|
||||
for (const report of event.fieldReports) {
|
||||
const button = document.createElement('button');
|
||||
const label = document.createElement('span');
|
||||
const status = document.createElement('small');
|
||||
button.type = 'button';
|
||||
button.className = 'bhote-event-report';
|
||||
label.textContent = bhoteEventText(report.label);
|
||||
status.textContent = bhoteEventText(report.status);
|
||||
button.append(label, status);
|
||||
button.addEventListener('click', () => openExternal(report.url));
|
||||
refs.reportList.appendChild(button);
|
||||
}
|
||||
|
||||
panel
|
||||
.querySelector('[data-action="close"]')
|
||||
.addEventListener('click', handlers.close);
|
||||
panel
|
||||
.querySelector('[data-action="play"]')
|
||||
.addEventListener('click', handlers.play);
|
||||
panel
|
||||
.querySelector('[data-action="play-scene"]')
|
||||
.addEventListener('click', handlers.playScene);
|
||||
panel
|
||||
.querySelector('[data-action="cinematic"]')
|
||||
.addEventListener('click', handlers.toggleCinematic);
|
||||
panel
|
||||
.querySelector('[data-action="previous-beat"]')
|
||||
.addEventListener('click', handlers.previousBeat);
|
||||
panel
|
||||
.querySelector('[data-action="next-beat"]')
|
||||
.addEventListener('click', handlers.nextBeat);
|
||||
panel
|
||||
.querySelector('[data-action="story-replay"]')
|
||||
.addEventListener('click', handlers.replayCinematic);
|
||||
panel
|
||||
.querySelector('[data-action="open-source"]')
|
||||
.addEventListener('click', handlers.openActiveSource);
|
||||
panel
|
||||
.querySelector('[data-action="geolocation-map"]')
|
||||
.addEventListener('click', handlers.openGeolocationMap);
|
||||
panel
|
||||
.querySelector('[data-action="corridor"]')
|
||||
.addEventListener('click', handlers.focusCorridor);
|
||||
panel
|
||||
.querySelector('[data-role="split"]')
|
||||
.addEventListener('input', (inputEvent) => {
|
||||
handlers.setSplit(Number(inputEvent.target.value) / 100);
|
||||
});
|
||||
panel
|
||||
.querySelector('[data-role="progress"]')
|
||||
.addEventListener('input', (inputEvent) => {
|
||||
handlers.previewProgress(Number(inputEvent.target.value) / 1000);
|
||||
});
|
||||
panel
|
||||
.querySelector('[data-role="progress"]')
|
||||
.addEventListener('change', (inputEvent) => {
|
||||
handlers.commitProgress(Number(inputEvent.target.value) / 1000, {
|
||||
final: true,
|
||||
});
|
||||
});
|
||||
const rightRail = document.getElementById?.('right-context-rail');
|
||||
rightRail?.classList.add('bhote-event-active');
|
||||
(rightRail || document.body).appendChild(panel);
|
||||
return { panel, refs };
|
||||
}
|
||||
|
||||
export function createBhoteKoshiEventLayer({
|
||||
overlayHost = DEFAULT_OVERLAY_HOST,
|
||||
renderHost = DEFAULT_RENDER_HOST,
|
||||
@@ -1115,14 +994,22 @@ export function createBhoteKoshiEventLayer({
|
||||
evidenceFrameUpdater = updateEvidenceFrame,
|
||||
videoFactory = DEFAULT_VIDEO_FACTORY,
|
||||
embeddedMediaFactory = createBhoteKoshiEmbeddedMedia,
|
||||
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
|
||||
} = {}) {
|
||||
let _viewer = null;
|
||||
let _dataManager = null;
|
||||
let _mapStackController = null;
|
||||
let _event = null;
|
||||
let _enabled = false;
|
||||
let _panel = null;
|
||||
// The panel's controls; null while the layer is off. Their state is
|
||||
// published to the panel store, which the context rail renders.
|
||||
let _panelRefs = null;
|
||||
let _panelOwner = null;
|
||||
let _panelPublished = '';
|
||||
// The dates and field reports, which stay as they were when the panel
|
||||
// opened, until a locale change words them again.
|
||||
let _panelFixed = null;
|
||||
let _offPanelLocale = null;
|
||||
let _splitControl = null;
|
||||
let _beforeLayer = null;
|
||||
let _afterLayer = null;
|
||||
@@ -2719,9 +2606,9 @@ export function createBhoteKoshiEventLayer({
|
||||
id: 'bhote-koshi-split-line',
|
||||
handleClass: 'bhote-koshi-split-handle',
|
||||
cssProperty: '--bhote-koshi-split',
|
||||
beforeTitle: imageryEndpointText(event.imagery.before),
|
||||
afterTitle: imageryEndpointText(event.imagery.after),
|
||||
ariaLabel: t('Historical reference and post-event image divider'),
|
||||
beforeTitle: () => imageryEndpointText(event.imagery.before),
|
||||
afterTitle: () => imageryEndpointText(event.imagery.after),
|
||||
ariaLabel: () => t('Historical reference and post-event image divider'),
|
||||
formatValueText: (beforePercent, afterPercent) =>
|
||||
t(
|
||||
'A historical reference {before} percent, B post-event {after} percent',
|
||||
@@ -2752,15 +2639,63 @@ export function createBhoteKoshiEventLayer({
|
||||
_lastCinematicProgress = _progress;
|
||||
}
|
||||
|
||||
/** Present the panel's controls as they stand; an unchanged panel is not sent again. */
|
||||
function publishPanel() {
|
||||
if (!_panelRefs) return;
|
||||
const { root, ...controls } = _panelRefs;
|
||||
const refs = Object.fromEntries(
|
||||
Object.entries(controls).map(([key, record]) => [
|
||||
key,
|
||||
{ ...record, attributes: { ...record.attributes } },
|
||||
]),
|
||||
);
|
||||
const signature = JSON.stringify([root.sceneBeat, refs]);
|
||||
if (signature === _panelPublished) return;
|
||||
_panelPublished = signature;
|
||||
_panelFixed ??= {
|
||||
beforeDate: imageryEndpointLabel(_event.imagery.before),
|
||||
afterDate: imageryEndpointLabel(_event.imagery.after),
|
||||
reports: _event.fieldReports.map((report) => ({
|
||||
label: bhoteEventText(report.label),
|
||||
status: bhoteEventText(report.status),
|
||||
})),
|
||||
};
|
||||
panelStore.publish({
|
||||
owner: _panelOwner,
|
||||
sceneBeat: root.sceneBeat,
|
||||
refs,
|
||||
..._panelFixed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Word the panel again in the current locale: fresh records, written by
|
||||
* the same syncs that keep them current.
|
||||
*/
|
||||
function relocalizePanel() {
|
||||
if (!_panelRefs || !_event) return;
|
||||
_panelRefs = createPanelRecords(_event);
|
||||
_panelFixed = null;
|
||||
writeCinematicButton();
|
||||
syncPanel();
|
||||
syncPresentationMode();
|
||||
}
|
||||
|
||||
function syncCinematicButton() {
|
||||
if (writeCinematicButton()) publishPanel();
|
||||
}
|
||||
|
||||
/** Write the cinematic button's label and state; false without a panel. */
|
||||
function writeCinematicButton() {
|
||||
const button = _panelRefs?.cinematicButton;
|
||||
if (!button) return;
|
||||
if (!button) return false;
|
||||
setElementProperty(
|
||||
button,
|
||||
'textContent',
|
||||
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
|
||||
);
|
||||
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
||||
return true;
|
||||
}
|
||||
|
||||
function claimCinematicCamera() {
|
||||
@@ -3025,6 +2960,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'textContent',
|
||||
`~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`,
|
||||
);
|
||||
publishPanel();
|
||||
if (_sceneSeekFrame != null) return;
|
||||
const schedule =
|
||||
globalThis.requestAnimationFrame ||
|
||||
@@ -3298,7 +3234,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'disabled',
|
||||
!observation?.media?.sourceUrl,
|
||||
);
|
||||
syncCinematicButton();
|
||||
writeCinematicButton();
|
||||
setElementProperty(
|
||||
_panelRefs.splitInput,
|
||||
'value',
|
||||
@@ -3310,6 +3246,7 @@ export function createBhoteKoshiEventLayer({
|
||||
'textContent',
|
||||
`${beforePercent} / ${100 - beforePercent}`,
|
||||
);
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
function syncPresentationMode() {
|
||||
@@ -3326,7 +3263,7 @@ export function createBhoteKoshiEventLayer({
|
||||
const evidenceAvailable =
|
||||
!sceneDirected ||
|
||||
(_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat);
|
||||
_panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected);
|
||||
if (_panelRefs) _panelRefs.root.sceneBeat = sceneDirected;
|
||||
if (_panelRefs?.playSceneButton)
|
||||
_panelRefs.playSceneButton.hidden = !sceneDirected;
|
||||
if (_panelRefs?.imagerySection)
|
||||
@@ -3360,6 +3297,7 @@ export function createBhoteKoshiEventLayer({
|
||||
}
|
||||
if (_panelRefs?.openSourceButton)
|
||||
_panelRefs.openSourceButton.hidden = !evidenceAvailable;
|
||||
publishPanel();
|
||||
overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable);
|
||||
}
|
||||
|
||||
@@ -3557,7 +3495,12 @@ export function createBhoteKoshiEventLayer({
|
||||
throwIfEnableCancelled(signal, _enabled);
|
||||
createFloodDataSource();
|
||||
createSplitControl(event);
|
||||
const panelView = createPanel(event, {
|
||||
_panelRefs = createPanelRecords(event);
|
||||
_panelOwner = {};
|
||||
_panelPublished = '';
|
||||
_panelFixed = null;
|
||||
const owner = _panelOwner;
|
||||
const controls = {
|
||||
close: () =>
|
||||
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
|
||||
origin: 'user',
|
||||
@@ -3574,9 +3517,23 @@ export function createBhoteKoshiEventLayer({
|
||||
commitProgress,
|
||||
setSplit,
|
||||
focusCorridor,
|
||||
});
|
||||
_panel = panelView.panel;
|
||||
_panelRefs = panelView.refs;
|
||||
openReport: (index) => {
|
||||
const url = event.fieldReports[index]?.url;
|
||||
if (url) openExternal(url);
|
||||
},
|
||||
};
|
||||
// Only the panel this enable published may act.
|
||||
panelStore.connect(
|
||||
Object.fromEntries(
|
||||
Object.entries(controls).map(([name, control]) => [
|
||||
name,
|
||||
(...args) => (_panelOwner === owner ? control(...args) : undefined),
|
||||
]),
|
||||
),
|
||||
);
|
||||
publishPanel();
|
||||
_offPanelLocale?.();
|
||||
_offPanelLocale = onLocaleChange(relocalizePanel);
|
||||
syncPresentationMode();
|
||||
overlayHost.setVisible(
|
||||
BHOTE_KOSHI_OVERLAY_SOURCE_ID,
|
||||
@@ -3669,12 +3626,16 @@ export function createBhoteKoshiEventLayer({
|
||||
_surgePoint = null;
|
||||
_lastSurgePosition = null;
|
||||
_lastSurgeVisible = null;
|
||||
_panel?.remove();
|
||||
document
|
||||
.getElementById?.('right-context-rail')
|
||||
?.classList.remove('bhote-event-active');
|
||||
_panel = null;
|
||||
if (_panelOwner && panelStore.panel?.owner === _panelOwner) {
|
||||
panelStore.connect(null);
|
||||
panelStore.publish(null);
|
||||
}
|
||||
_offPanelLocale?.();
|
||||
_offPanelLocale = null;
|
||||
_panelOwner = null;
|
||||
_panelRefs = null;
|
||||
_panelPublished = '';
|
||||
_panelFixed = null;
|
||||
_splitControl?.destroy();
|
||||
_splitControl = null;
|
||||
_progress = 0;
|
||||
|
||||
@@ -3,6 +3,9 @@ import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { claimPointer, releasePointer } from './inputOwnership.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
|
||||
import { useImagerySplitStore } from '../ui/stores/imagerySplit.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LAYER_ID,
|
||||
@@ -36,6 +39,95 @@ import { SceneDirector } from '../scenes/director.js';
|
||||
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
|
||||
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
|
||||
|
||||
/** The event's divider as the imagery split store holds it, or undefined. */
|
||||
const eventSplit = () =>
|
||||
useImagerySplitStore(applicationPinia()).splits.findLast(
|
||||
(split) => split.view.id === 'bhote-koshi-split-line',
|
||||
);
|
||||
|
||||
// The panel controls the layer presents, by the selector the panel view gives each.
|
||||
const PANEL_REFS = {
|
||||
'[data-role="imagery-comparison"]': 'imagerySection',
|
||||
'[data-role="time"]': 'time',
|
||||
'[data-role="caveat"]': 'caveat',
|
||||
'[data-role="imagery-cloud-note"]': 'imageryCloudNote',
|
||||
'[data-role="progress"]': 'progressInput',
|
||||
'[data-role="split"]': 'splitInput',
|
||||
'[data-role="timeline-label"]': 'timelineLabel',
|
||||
'[data-role="beat-index"]': 'beatIndex',
|
||||
'[data-role="beat-title"]': 'beatTitle',
|
||||
'[data-role="beat-meta"]': 'beatMeta',
|
||||
'[data-action="play"]': 'playButton',
|
||||
'[data-action="play-scene"]': 'playSceneButton',
|
||||
'[data-action="cinematic"]': 'cinematicButton',
|
||||
'[data-action="story-replay"]': 'storyReplayButton',
|
||||
'[data-action="corridor"]': 'corridorButton',
|
||||
'[data-action="previous-beat"]': 'previousButton',
|
||||
'[data-action="next-beat"]': 'nextButton',
|
||||
'[data-action="open-source"]': 'openSourceButton',
|
||||
};
|
||||
// What each control calls, as the panel view wires it.
|
||||
const PANEL_EVENTS = {
|
||||
playButton: { click: 'play' },
|
||||
playSceneButton: { click: 'playScene' },
|
||||
cinematicButton: { click: 'toggleCinematic' },
|
||||
storyReplayButton: { click: 'replayCinematic' },
|
||||
corridorButton: { click: 'focusCorridor' },
|
||||
previousButton: { click: 'previousBeat' },
|
||||
nextButton: { click: 'nextBeat' },
|
||||
openSourceButton: { click: 'openActiveSource' },
|
||||
progressInput: {
|
||||
input: (controls, value) => controls.previewProgress(Number(value) / 1000),
|
||||
change: (controls, value) => controls.commitProgress(Number(value) / 1000, { final: true }),
|
||||
},
|
||||
splitInput: { input: (controls, value) => controls.setSplit(Number(value) / 100) },
|
||||
};
|
||||
|
||||
/**
|
||||
* The event panel as the layer presents it to the panel store, or undefined
|
||||
* while it is off. Controls read the latest presentation, and dispatching an
|
||||
* event calls the connected controls as the panel view does.
|
||||
*/
|
||||
function eventPanel() {
|
||||
const store = useBhoteKoshiPanelStore(applicationPinia());
|
||||
if (!store.panel) return undefined;
|
||||
const control = (key) => {
|
||||
let typed = null;
|
||||
const record = () => store.panel.refs[key];
|
||||
return {
|
||||
get textContent() { return record().textContent; },
|
||||
get hidden() { return record().hidden; },
|
||||
get disabled() { return record().disabled; },
|
||||
get value() { return typed ?? record().value; },
|
||||
set value(value) { typed = value; },
|
||||
getAttribute: (name) => record().attributes[name] ?? null,
|
||||
dispatch(type) {
|
||||
const value = typed ?? record().value;
|
||||
typed = null;
|
||||
const handler = PANEL_EVENTS[key]?.[type];
|
||||
return typeof handler === 'function'
|
||||
? handler(store.controls, value)
|
||||
: store.controls[handler]?.();
|
||||
},
|
||||
};
|
||||
};
|
||||
const named = (name) => ({ dispatch: () => store.controls[name]() });
|
||||
return {
|
||||
querySelector(selector) {
|
||||
if (selector === '.bhote-event-report-list') {
|
||||
return {
|
||||
children: store.panel.reports.map((_, index) => ({
|
||||
dispatch: () => store.controls.openReport(index),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (selector === '[data-action="geolocation-map"]') return named('openGeolocationMap');
|
||||
if (selector === '[data-action="close"]') return named('close');
|
||||
return control(PANEL_REFS[selector]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div') {
|
||||
this.tagName = tagName;
|
||||
@@ -840,6 +932,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
const { body, removedProperties } = installEventDom();
|
||||
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
|
||||
const clearedTimeouts = [];
|
||||
globalThis.window = {
|
||||
setTimeout: () => 71,
|
||||
@@ -879,7 +972,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
|
||||
assert.equal(viewer._test.imagery.length, 2);
|
||||
assert.equal(viewer._test.dataSources.length, 1);
|
||||
assert.equal(body.children.length, 2);
|
||||
assert.ok(eventPanel());
|
||||
assert.ok(eventSplit());
|
||||
|
||||
activeMap = 'operator-choice';
|
||||
generation += 1;
|
||||
@@ -888,7 +982,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
assert.deepEqual(mapCalls, ['esri-imagery']);
|
||||
assert.equal(viewer._test.removedImagery.length, 2);
|
||||
assert.equal(viewer._test.removedDataSources.length, 1);
|
||||
assert.ok(body.children.every((element) => element.removed));
|
||||
assert.equal(eventPanel(), undefined);
|
||||
assert.equal(eventSplit(), undefined);
|
||||
assert.equal(viewer.scene.splitPosition, 0.37);
|
||||
assert.deepEqual(clearedTimeouts, [71]);
|
||||
assert.ok(overlayCalls.some(([kind]) => kind === 'clear'));
|
||||
@@ -934,7 +1029,7 @@ test('Bhote Koshi disable stops active playback and releases its global render h
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'scene' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
assert.equal(panel.querySelector('[data-action="play-scene"]').hidden, true);
|
||||
assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false);
|
||||
panel.querySelector('[data-action="play"]').dispatch('click');
|
||||
@@ -1075,6 +1170,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
const { body } = installEventDom();
|
||||
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
|
||||
const timers = [];
|
||||
const openedUrls = [];
|
||||
const cancelledFrames = [];
|
||||
@@ -1181,11 +1277,10 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.deepEqual(timers, []);
|
||||
assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair');
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
assert.ok(panel);
|
||||
const splitLine = body.children.find((element) => element.id === 'bhote-koshi-split-line');
|
||||
assert.ok(splitLine);
|
||||
assert.equal(splitLine.hidden, true);
|
||||
assert.ok(eventSplit());
|
||||
assert.equal(eventSplit().view.hidden, true);
|
||||
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true);
|
||||
assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i);
|
||||
assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i);
|
||||
@@ -1220,8 +1315,6 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.equal(panel.querySelector('[data-action="cinematic"]').disabled, true);
|
||||
assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false);
|
||||
assert.equal(panel.querySelector('[data-action="corridor"]').disabled, false);
|
||||
assert.doesNotMatch(panel.innerHTML, /data-action="witness"|◎ WITNESS/);
|
||||
assert.match(panel.innerHTML, /data-action="open-source"/);
|
||||
const fullStory = panel.querySelector('[data-action="story-replay"]');
|
||||
assert.equal(fullStory.textContent, '↺ FULL STORY');
|
||||
assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback');
|
||||
@@ -1240,30 +1333,27 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
beatId: 'timure-cluster',
|
||||
sceneControls: { imageryComparison: true },
|
||||
}, { origin: 'scene' });
|
||||
assert.equal(splitLine.hidden, false);
|
||||
assert.equal(eventSplit().view.hidden, false);
|
||||
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false);
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true));
|
||||
const splitHandle = splitLine.children.find(
|
||||
(element) => element.className === 'bhote-koshi-split-handle',
|
||||
);
|
||||
assert.ok(splitHandle);
|
||||
assert.equal(splitHandle.textContent, '↔');
|
||||
assert.equal(splitHandle.getAttribute('aria-valuenow'), '62');
|
||||
splitHandle.dispatch('pointerdown', {
|
||||
assert.equal(eventSplit().view.handleClass, 'bhote-koshi-split-handle');
|
||||
assert.equal(eventSplit().view.valueNow, 62);
|
||||
const splitHandle = eventSplit().controls;
|
||||
splitHandle.pointerdown({
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
splitHandle.dispatch('pointermove', {
|
||||
splitHandle.pointermove({
|
||||
clientX: 750,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
splitHandle.dispatch('pointerup', { pointerId: 4 });
|
||||
splitHandle.pointerup({ pointerId: 4 });
|
||||
assert.equal(viewer.scene.splitPosition, 0.75);
|
||||
assert.equal(splitHandle.getAttribute('aria-valuenow'), '75');
|
||||
splitHandle.dispatch('keydown', {
|
||||
assert.equal(eventSplit().view.valueNow, 75);
|
||||
splitHandle.keydown({
|
||||
key: 'ArrowLeft',
|
||||
shiftKey: true,
|
||||
preventDefault() {},
|
||||
@@ -1276,7 +1366,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
split: 0.62,
|
||||
sceneControls: {},
|
||||
}, { origin: 'scene' });
|
||||
assert.equal(splitLine.hidden, true);
|
||||
assert.equal(eventSplit().view.hidden, true);
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
|
||||
assert.equal(viewer.scene.splitPosition, 0.62);
|
||||
assert.equal(overlayVisibility.at(-1).visible, false);
|
||||
@@ -1347,7 +1437,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.equal(layer.getCinematicState().active, false);
|
||||
assert.equal(viewer._test.cameraLooks.length, 0);
|
||||
assert.deepEqual(mapCalls, []);
|
||||
assert.equal(body.children.filter((element) => element.tagName === 'aside').length, 1);
|
||||
assert.ok(eventPanel());
|
||||
assert.equal(viewer.scene.splitPosition, 0.62);
|
||||
for (const beatId of [
|
||||
'debris-dammed-lake',
|
||||
@@ -1594,7 +1684,7 @@ test('Bhote Koshi publishes the evidence spine once and scrubs through cards and
|
||||
assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 0);
|
||||
assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1));
|
||||
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
const progress = panel.querySelector('[data-role="progress"]');
|
||||
progress.value = '285';
|
||||
progress.dispatch('input');
|
||||
@@ -1685,7 +1775,7 @@ test('Bhote Koshi keeps one local fallback video active and unloads it on beat c
|
||||
videos[0].dispatch('loadeddata');
|
||||
assert.equal(layer.getCinematicState().mediaReady, true);
|
||||
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
const progress = panel.querySelector('[data-role="progress"]');
|
||||
progress.value = '285';
|
||||
progress.dispatch('input');
|
||||
@@ -1808,7 +1898,7 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
|
||||
assert.ok(video.pauseCalls >= 1);
|
||||
assert.ok(video.cancelledFrameHandles.length >= 1);
|
||||
assert.equal(layer.getCinematicState().mediaEvidenceId, null);
|
||||
assert.equal(body.children.find((element) => element.tagName === 'aside')?.removed, false);
|
||||
assert.ok(eventPanel());
|
||||
await layer.disable();
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
@@ -2121,10 +2211,8 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
assert.match(panel.innerHTML, /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/);
|
||||
assert.match(panel.innerHTML, /RECONSTRUCTION CLOCK/);
|
||||
assert.match(panel.innerHTML, /OPEN PUBLIC MAP/);
|
||||
const panel = eventPanel();
|
||||
assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'RECONSTRUCTION CLOCK');
|
||||
const entries = publications[0].entries;
|
||||
const cards = entries.filter((entry) => entry.variant === 'thumbnail');
|
||||
const breadcrumbs = entries.filter((entry) => entry.variant === 'label');
|
||||
@@ -2203,7 +2291,7 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
|
||||
}
|
||||
});
|
||||
|
||||
test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or camera churn', async () => {
|
||||
test('Bhote Koshi repeated event frames republish no panel and churn no camera', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -2238,26 +2326,19 @@ test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or c
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
const cinematic = panel.querySelector('[data-action="cinematic"]');
|
||||
const progress = panel.querySelector('[data-role="progress"]');
|
||||
cinematic.dispatch('click');
|
||||
progress.value = '500';
|
||||
progress.dispatch('input');
|
||||
|
||||
const queryCount = panel._queryCount;
|
||||
const writeCount = [...panel._selectors.values()].reduce((sum, element) => (
|
||||
sum + element._writeCount
|
||||
), 0);
|
||||
const panelStore = useBhoteKoshiPanelStore(applicationPinia());
|
||||
const published = panelStore.panel;
|
||||
const cameraLookCount = viewer._test.cameraLooks.length;
|
||||
for (let index = 0; index < 20; index += 1) progress.dispatch('input');
|
||||
|
||||
assert.equal(panel._queryCount, queryCount, 'hot path uses cached panel references');
|
||||
assert.equal(
|
||||
[...panel._selectors.values()].reduce((sum, element) => sum + element._writeCount, 0),
|
||||
writeCount,
|
||||
'unchanged frames do not rewrite panel state',
|
||||
);
|
||||
assert.ok(panelStore.panel === published, 'unchanged frames do not republish the panel');
|
||||
assert.equal(
|
||||
viewer._test.cameraLooks.length,
|
||||
cameraLookCount,
|
||||
@@ -2328,7 +2409,7 @@ test('Bhote Koshi cinematic camera stays synchronized through pause and determin
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
panel.querySelector('[data-action="cinematic"]').dispatch('click');
|
||||
assert.equal(layer.getCinematicState().active, true);
|
||||
assert.equal(layer.getPlaybackState().playing, true);
|
||||
@@ -2413,7 +2494,7 @@ test('Bhote Koshi replay restores cinematic ownership unless the operator releas
|
||||
|
||||
try {
|
||||
await layer.enable(viewer, { origin: 'local-restore' });
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
const panel = eventPanel();
|
||||
panel.querySelector('[data-action="cinematic"]').dispatch('click');
|
||||
animationCallbacks[0](91001);
|
||||
assert.equal(layer.getPlaybackState().progress, 1);
|
||||
|
||||
Loaded 100 of 1889 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user