Merge branch 'main' into perf/vessels-idle-render

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-29 01:18:39 +00:00
commit 1c0a30adaf
49 files changed
+2466 -71

No files matched your search

+29
View File
@@ -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',
+4 -1
View File
@@ -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,7 +14,9 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
visibilityTarget: document,
// 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 () => {
+22
View File
@@ -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;
+56
View File
@@ -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 = () => {
@@ -211,6 +213,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(
+2
View File
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_bumpSceneSeekGeneration:
SceneDirector.prototype._bumpSceneSeekGeneration,
_cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
+6 -1
View File
@@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) {
/**
* Fetch one upstream chunk with jittered exponential backoff. Network errors,
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
* Retry sleeps and retry attempts share a 10s budget after attempt one.
* Retry sleeps and retry attempts share a 10s budget after attempt one. A
* Retry-After longer than the budget left ends the retries at once: sleeping
* out the budget cannot reach the time the upstream asked for, and it would
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
*
* Dependencies are injectable for deterministic offline tests.
* @param {Array<[number, number]>} points
@@ -145,6 +148,7 @@ export async function fetchTerrainChunkWithRetry(
const res = await fetchImpl(url, signal ? { signal } : {});
if (!res.ok) {
const error = new Error(`HTTP ${res.status}`);
error.status = res.status;
error.retryable = res.status === 429 || res.status >= 500;
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
throw error;
@@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry(
if (remaining <= 0) break;
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
if (retryAfterMs != null && retryAfterMs > remaining) break;
const backoffMs = Math.round(
350 * 2 ** attempt * (0.75 + random() * 0.5),
);
+30
View File
@@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => {
assert.equal(results[0].id, 'recovered');
});
test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => {
let clock = 3_000_000;
let attempts = 0;
const sleeps = [];
await assert.rejects(
fetchTerrainChunkWithRetry([[1, 1]], {
now: () => clock,
random: () => 0.5,
makeSignal: () => undefined,
sleep: async (ms) => {
sleeps.push(ms);
clock += ms;
},
fetchImpl: async () => {
attempts += 1;
return {
ok: false,
status: 429,
headers: {
get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null),
},
};
},
}),
(error) => error.status === 429 && error.retryAfter === '30',
);
assert.equal(attempts, 1);
assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After');
});
test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => {
let clock = 2_000_000;
let attempts = 0;
+9
View File
@@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot(
{
getFlowSessionStats: () => ({ tilesFetched: 0 }),
resetFlowTileCache: () => {},
// The layer reads stale cached tiles through this; a source without it
// answers with fresh segments only.
fetchFlowDetail: async (...args) => ({
segments: await sourceSlot.source.fetchFlowForBounds(...args),
stale: false,
oldestTileAt: null,
}),
},
);
export const configureTrafficSource = sourceSlot.configure;
/** The source the traffic layer reads, as configured. */
export const trafficSource = sourceSlot.source;
const layer = createApplicationTraffic({
source: sourceSlot.source,
});
+105 -19
View File
@@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js';
const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/;
test('a superseded flow fetch is not an outage', () => {
assert.equal(deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), null);
assert.equal(
deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }),
null,
);
assert.equal(deriveTrafficFlowError(null), null);
assert.equal(deriveTrafficFlowError(undefined), null);
});
@@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => {
test('flow failures map onto short, specific reasons', () => {
const reason = (message) => deriveTrafficFlowError(new Error(message));
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
assert.equal(reason('flow tile 12/1/1: HTTP 429'), 'TomTom daily budget reached');
assert.equal(reason('flow tile 12/1/1: HTTP 502'), 'TomTom upstream unreachable');
assert.equal(reason('flow tile 12/1/1: HTTP 504'), 'TomTom upstream unreachable');
assert.equal(reason('flow tile 12/1/1: HTTP 418'), 'TomTom flow error (HTTP 418)');
assert.equal(
reason('flow tile 12/1/1: HTTP 429'),
'TomTom daily budget reached',
);
assert.equal(
reason('flow tile 12/1/1: HTTP 502'),
'TomTom upstream unreachable',
);
assert.equal(
reason('flow tile 12/1/1: HTTP 504'),
'TomTom upstream unreachable',
);
assert.equal(
reason('flow tile 12/1/1: HTTP 418'),
'TomTom flow error (HTTP 418)',
);
assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable');
});
@@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => {
trafficFeedPresentation({ liveMode: false, fetching: false }),
trafficFeedPresentation({ liveMode: false, fetching: true }),
trafficFeedPresentation({ statusUnavailable: true }),
trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }),
trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom flow unavailable',
}),
trafficFeedPresentation({
liveMode: true,
fetching: true,
flowError: 'TomTom flow unavailable',
}),
].map((feed) => feed.loadingLabel);
for (const label of labels) {
assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`);
assert.ok(label.startsWith('SIMULATED'), `fallback label must lead with the mode: ${label}`);
assert.ok(
label.startsWith('SIMULATED'),
`fallback label must lead with the mode: ${label}`,
);
}
});
test('simulating because the status probe failed reads differently from keyless by design', () => {
const probeDown = trafficFeedPresentation({ statusUnavailable: true });
assert.equal(probeDown.mode, 'sim');
assert.equal(probeDown.loadingLabel, 'SIMULATED — traffic service unreachable');
assert.equal(
probeDown.loadingLabel,
'SIMULATED — traffic service unreachable',
);
});
test('a healthy keyed layer reports live flow with its real coverage', () => {
@@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => {
assert.deepEqual(idle, {
mode: 'live',
error: null,
loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
loadingLabel:
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
});
assert.equal(
trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel,
@@ -100,7 +129,11 @@ test('a mid-session flow outage degrades instead of reporting stale live coverag
fetching: true,
flowError: 'TomTom daily budget reached',
});
assert.deepEqual(busy, down, 'the degraded state reads the same whether or not a load is in flight');
assert.deepEqual(
busy,
down,
'the degraded state reads the same whether or not a load is in flight',
);
});
test('the rendered steady-state meta line carries the SIMULATED copy', () => {
@@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
assert.equal(
mgr._buildMetaText({
source: 'OpenStreetMap',
stats: stats(trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
})),
stats: stats(
trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
}),
),
}),
'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached',
);
@@ -128,17 +163,26 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => {
const settled = { count: 4200, lastUpdate: Date.now() };
assert.equal(
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }),
layerFeedState({
...settled,
...trafficFeedPresentation({ liveMode: false }),
}),
'fallback',
);
assert.equal(
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }),
layerFeedState({
...settled,
...trafficFeedPresentation({ liveMode: true }),
}),
'nominal',
);
assert.equal(
layerFeedState({
...settled,
...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
...trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom flow unavailable',
}),
}),
'degraded',
);
@@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => {
const stats = trafficLayer.getStats();
assert.equal(stats.mode, 'sim');
assert.equal(stats.error, null);
assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`);
assert.ok(
!LIVE_CLAIM.test(stats.loadingLabel),
`boot label implies live data: ${stats.loadingLabel}`,
);
assert.equal(layerFeedState(stats), 'fallback');
});
@@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => {
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
assert.ok(!LIVE_CLAIM.test(feed.loadingLabel));
});
test('the traffic layer source passes fetchFlowDetail through, so stale tiles reach the layer', async () => {
const { trafficSource, configureTrafficSource } =
await import('./traffic.js');
assert.equal(typeof trafficSource.fetchFlowDetail, 'function');
const base = {
requestRoads: async () => ({}),
getStatus: async () => ({}),
fetchFlowForBounds: async () => ['segment'],
};
let release = configureTrafficSource({
...base,
fetchFlowDetail: async () => ({
segments: ['segment'],
stale: true,
oldestTileAt: 1000,
}),
});
try {
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
segments: ['segment'],
stale: true,
oldestTileAt: 1000,
});
} finally {
release();
}
// A source without fetchFlowDetail answers with fresh segments.
release = configureTrafficSource(base);
try {
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
segments: ['segment'],
stale: false,
oldestTileAt: null,
});
} finally {
release();
}
});
@@ -274,7 +274,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele
_claimCameraOwnership: () =>
assert.fail('Must reload the released pack before camera seek'),
});
assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false);
assert.equal(await director._seekLoadedShot({ id: 'scene' }, { shot }), null);
});
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
+52 -13
View File
@@ -39,6 +39,7 @@ import {
} from './hudLayouts.js';
import { formatNumber, msg, t } from './i18n/index.js';
import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -202,7 +203,13 @@ export class IntelHUD {
this._dataManagerUnsubscribe = null;
this._summaryDirty = true;
this._summaryRequest = null;
this._summaryUnconfigured = false;
// The last place lookup and the view it answered for (see
// getBasemapLabelContext), reused until the view changes enough.
this._placeLabels = null;
this._lastSummarySignature = '';
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
this._lastSummaryText = null;
this._summaryRevision = 0;
// One-shot guards so the very first summary lands immediately instead of
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
@@ -363,9 +370,11 @@ export class IntelHUD {
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
}, 800);
// Camera-derived data — 4 updates/second (250ms)
// Camera-derived data — 4 updates/second (250ms). Skipped in a hidden
// page, except an inline panel, whose host may report it hidden while it
// is on screen (see keepPanelRendering in app/embed.js).
this._updateInterval = setInterval(() => {
if (!this._visible) return;
if (!this._visible || (document.hidden && !isEmbeddedInline())) return;
this._updateCameraData();
}, 250);
@@ -826,6 +835,15 @@ export class IntelHUD {
if (this.summaryPolicy.canRequest?.() === false) return;
const revision = this._summaryRevision;
// The place lookups below are paid Google requests made only to feed the
// AI summary, so a server without one must not be asked to make them.
if (!(await this._summaryAvailable())) {
if (revision !== this._summaryRevision) return;
this._summaryDirty = false;
this._setSummaryText(fallbackText, animate);
return;
}
if (revision !== this._summaryRevision) return;
// Every caller invokes this as `void this._updateSummary(...)`, so nothing
// owns the returned promise — a rejection escaping from here lands as an
// unhandled rejection in the console. Building the context walks the live
@@ -848,6 +866,9 @@ export class IntelHUD {
const signature = JSON.stringify(context);
if (!force && signature === this._lastSummarySignature) {
this._summaryDirty = false;
// A settle painted the fallback line over the reply this context
// already has; reused place labels make that common, so restore it.
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText);
return;
}
if (this._summaryRequest) return;
@@ -855,6 +876,7 @@ export class IntelHUD {
if (force) this._setSummaryText(fallbackText, false);
this._summaryDirty = false;
this._lastSummarySignature = signature;
this._lastSummaryText = null;
const controller = new AbortController();
let timedOut = false;
const timeout = window.setTimeout(() => {
@@ -870,18 +892,16 @@ export class IntelHUD {
const data = response.data;
if (revision !== this._summaryRevision) return;
if (isHudSummaryUnconfigured(response.status, data)) {
this._summaryUnconfigured = true;
this._setSummaryText(fallbackText, animate);
return;
}
if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
this._setSummaryText(
hudSummaryMatchesProvenance(data.summary, context.feedProvenance)
? data.summary
: fallbackText,
animate,
);
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
this._lastSummaryText = data.summary;
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
} catch (error) {
// The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not.
@@ -915,12 +935,31 @@ export class IntelHUD {
if (el) el.textContent = text;
}
/**
* False once the summary service reports no AI summary: by its capability
* probe, when it has one, or by a deliberate unconfigured reply. Unknown
* stays available, as before.
*/
async _summaryAvailable() {
if (this._summaryUnconfigured) return false;
try {
if ((await this.summaryService?.configured?.()) === false)
this._summaryUnconfigured = true;
} catch {
/* an unanswered probe leaves the summary as it was */
}
return !this._summaryUnconfigured;
}
async _summaryContext() {
const labels = await getBasemapLabelContext(
this.viewer,
this.placeSearch,
this.basemapContext,
);
const revision = this._summaryRevision;
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, {
...this.basemapContext,
previous: this._placeLabels,
});
// A slower build for a view the camera has left must not replace the
// newer view's lookup.
if (revision === this._summaryRevision) this._placeLabels = labels;
const enabledLayers =
this._dataManager
?.getAll?.()
+87
View File
@@ -0,0 +1,87 @@
// The HUD's 250 ms telemetry readout skips ticks in a hidden page, except in
// an inline panel, whose host may report it hidden while it is on screen.
//
// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports
// from, so a module hook swaps it for a stub before the import (the same
// setup as hudAltitudeDatum.test.mjs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
return next(specifier, context);
},
load(url, context, next) {
if (url === MGRS_STUB_URL) {
return {
format: 'module',
shortCircuit: true,
source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
};
}
return next(url, context);
},
});
const { IntelHUD } = await import('./hud.js');
function installHiddenPage() {
const elements = new Map(
['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode']
.map((id) => [id, { textContent: '' }]),
);
const previousDocument = globalThis.document;
globalThis.document = {
hidden: true,
visibilityState: 'hidden',
getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
};
const viewer = {
camera: {
pitch: -Math.PI / 2,
positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
return {
alt: () => elements.get('hud-alt').textContent,
viewer,
restore() {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
},
};
}
test('the HUD readout pauses in a hidden page but keeps updating in an inline panel', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const page = installHiddenPage();
let hud;
try {
hud = new IntelHUD(page.viewer);
hud._visible = true;
t.mock.timers.tick(250);
assert.equal(page.alt(), '', 'a hidden page skips the telemetry tick');
globalThis.GEV_EMBED_INLINE = true;
t.mock.timers.tick(250);
assert.match(
page.alt(),
/^ALT: 500m/,
'an inline panel reported hidden keeps its readout updating',
);
} finally {
delete globalThis.GEV_EMBED_INLINE;
hud?.destroy();
page.restore();
}
});
+80
View File
@@ -0,0 +1,80 @@
import { greatCircleMeters } from './geoDistance.js';
/**
* When the HUD's passive place lookup may spend another reverse geocode or
* Nearby Places request. Every request is a paid Google call, and the HUD
* asks on its own whenever the camera settles, so a new view only earns a
* new lookup once it shows a meaningfully different area: the center has
* moved by a quarter of the visible width, or the camera's distance to the
* center has doubled or halved. The width is measured at the view center
* from that distance, so terrain under the camera and a tilted view scale it
* as they scale what is on screen.
*/
const MOVE_FRACTION = 0.25;
const RANGE_RATIO = 2;
/** Street-level views still need a small move before they count as new. */
const MIN_SPAN_M = 100;
const DEFAULT_FOV_RAD = Math.PI / 3;
/**
* Describe the view a lookup answers for.
* @param {{latitude: number, longitude: number, rangeM: number, fovRad?: number, aspectRatio?: number, tier?: string}} view
* `rangeM` is the camera's distance to the view center. `fovRad` and
* `aspectRatio` are the Cesium perspective frustum's. `tier` names which
* lookups the camera height calls for; a view in another tier is new.
* @returns {{latitude: number, longitude: number, rangeM: number, spanM: number, tier: string}}
*/
export function placeLookupView({
latitude,
longitude,
rangeM,
fovRad,
aspectRatio,
tier = '',
}) {
const fov =
Number.isFinite(fovRad) && fovRad > 0 && fovRad < Math.PI
? fovRad
: DEFAULT_FOV_RAD;
// Cesium's fov spans the wider screen dimension, so a portrait screen's
// width is narrower than it.
const aspect = Number(aspectRatio);
const horizontalFov =
Number.isFinite(aspect) && aspect > 0 && aspect < 1
? 2 * Math.atan(Math.tan(fov / 2) * aspect)
: fov;
const range = Math.max(1, Number(rangeM) || 0);
return {
latitude,
longitude,
rangeM: range,
spanM: Math.max(MIN_SPAN_M, 2 * range * Math.tan(horizontalFov / 2)),
tier,
};
}
/**
* Whether `next` shows enough new ground to look its places up again.
* A missing or unusable previous view always does.
*/
export function placeLookupViewChanged(previous, next) {
if (!previous || !next || previous.tier !== next.tier) return true;
const ratio =
Math.max(previous.rangeM, next.rangeM) /
Math.min(previous.rangeM, next.rangeM);
if (!Number.isFinite(ratio) || ratio >= RANGE_RATIO) return true;
const moved = greatCircleMeters(
{ lat: previous.latitude, lon: previous.longitude },
{ lat: next.latitude, lon: next.longitude },
);
return !(moved < MOVE_FRACTION * previous.spanM);
}
/**
* The viewport samples the HUD geocodes besides the view center: one more
* point. Sample 0 is the screen center, so it stands in for the center only
* when the center itself is not geocoded at this height.
*/
export function hudViewportSamples(samples, centerGeocoded) {
return centerGeocoded ? samples.slice(1, 2) : samples.slice(0, 2);
}
+187
View File
@@ -0,0 +1,187 @@
// The HUD's place lookups are paid Google requests made only to feed the AI
// summary, so a server without one must not be asked to make them.
//
// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports
// from, so a module hook swaps it for a stub before the import (the same
// setup as hudPageVisibility.test.mjs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
return next(specifier, context);
},
load(url, context, next) {
if (url === MGRS_STUB_URL) {
return {
format: 'module',
shortCircuit: true,
source:
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
};
}
return next(url, context);
},
});
const { IntelHUD } = await import('./hud.js');
function installPage() {
const elements = new Map(
[
'hud-alt',
'hud-summary',
'hud-mgrs',
'hud-latlon',
'hud-bottom-line',
'hud-gsd',
'hud-coll',
'hud-ona',
'hud-mode',
].map((id) => [id, { textContent: '' }]),
);
const previous = { document: globalThis.document, window: globalThis.window };
globalThis.document = {
hidden: false,
visibilityState: 'visible',
getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
};
globalThis.window ??= {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
};
const viewer = {
camera: {
pitch: -Math.PI / 2,
positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
return {
viewer,
restore() {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
},
};
}
/** A HUD whose place lookup and summary service only count their calls. */
function createHud(t, summaryService) {
t.mock.timers.enable({ apis: ['setInterval'] });
const page = installPage();
const hud = new IntelHUD(page.viewer, { summaryService });
t.after(() => {
hud.destroy();
page.restore();
});
const calls = { context: 0 };
hud._latestMetrics = { altM: 600 };
hud._summaryContext = async () => {
calls.context += 1;
return { placeLabels: ['Austin'], streetLabels: [], nearbyPlaceLabels: [] };
};
return { hud, calls };
}
test('a server that reports no AI summary gets no place lookups', async (t) => {
const service = {
calls: 0,
configured: async () => false,
async summarize() {
this.calls += 1;
return { ok: true, status: 200, data: { summary: 'never' } };
},
};
const { hud, calls } = createHud(t, service);
await hud._updateSummary(false, true);
hud._markSummaryDirty();
await hud._updateSummary(false);
assert.equal(calls.context, 0);
assert.equal(service.calls, 0);
});
test('an unconfigured reply stops the lookups for the rest of the page', async (t) => {
const service = {
calls: 0,
async summarize() {
this.calls += 1;
return {
ok: true,
status: 200,
data: {
configured: false,
code: 'OPENAI_NOT_CONFIGURED',
error: null,
summary: null,
},
};
},
};
const { hud, calls } = createHud(t, service);
await hud._updateSummary(false, true);
assert.equal(calls.context, 1, 'a server without the probe is asked once');
hud._markSummaryDirty();
await hud._updateSummary(false, true);
assert.equal(calls.context, 1);
assert.equal(service.calls, 1);
});
test('a configured or unknown summary keeps looking places up', async (t) => {
for (const configured of [true, null, undefined]) {
await t.test(String(configured), async (st) => {
const service = {
configured:
configured === undefined ? undefined : async () => configured,
summarize: async () => ({
ok: true,
status: 200,
data: { summary: 'Austin capitol district live' },
}),
};
const { hud, calls } = createHud(st, service);
await hud._updateSummary(false, true);
assert.equal(calls.context, 1);
});
}
});
test('an unchanged context restores its accepted reply over the fallback line', async (t) => {
const service = {
calls: 0,
configured: async () => true,
async summarize() {
this.calls += 1;
return {
ok: true,
status: 200,
data: { summary: 'Austin capitol district live' },
};
},
};
const { hud } = createHud(t, service);
const shown = () =>
globalThis.document.getElementById('hud-summary').textContent;
await hud._updateSummary(false, true);
assert.equal(shown(), 'Austin capitol district live');
// A small pan settles: the fallback line is painted and the reused labels
// give the same context, so no new request is made.
hud._setSummaryText('fallback line', false);
hud._markSummaryDirty();
await hud._updateSummary(false);
assert.equal(service.calls, 1);
assert.equal(shown(), 'Austin capitol district live');
});
+1
View File
@@ -864,6 +864,7 @@
"LIVE · Roads: {source} · No flow roads in view": "EN VIVO · Vías: {source} · Sin vías con flujo a la vista",
"LIVE · Roads: {source} · Flow {percent}%": "EN VIVO · Vías: {source} · Flujo {percent}%",
"SIMULATED · Roads: {source} · Flow {percent}%": "SIMULADO · Vías: {source} · Flujo {percent}%",
"STALE · Roads: {source} · Flow: cached TomTom tiles": "DESACTUALIZADO · Vías: {source} · Flujo: teselas de TomTom en caché",
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Sincronizando flujo · Vías: OpenStreetMap · Flujo: TomTom · Sin coincidencia: simulado",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob. · Sin coincidencia: simulado",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob.",
+1
View File
@@ -254,6 +254,7 @@
"LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком",
"LIVE · Roads: {source} · Flow {percent}%": "ОНЛАЙН · Дороги: {source} · Поток {percent}%",
"SIMULATED · Roads: {source} · Flow {percent}%": "СИМУЛЯЦИЯ · Дороги: {source} · Поток {percent}%",
"STALE · Roads: {source} · Flow: cached TomTom tiles": "УСТАРЕЛ · Дороги: {source} · Поток: тайлы TomTom из кэша",
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Синхронизация потока · Дороги: OpenStreetMap · Поток: TomTom · Без совпадений: симуляция",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}% · Без совпадений: симуляция",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}%",
+1
View File
@@ -1059,6 +1059,7 @@
"LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内没有带路况数据的道路",
"LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 路况 {percent}%",
"SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 路况 {percent}%",
"STALE · Roads: {source} · Flow: cached TomTom tiles": "过期 · 道路:{source} · 路况:TomTom 缓存瓦片",
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路况 · 道路:OpenStreetMap · 路况:TomTom · 未匹配:模拟",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}% · 未匹配:模拟",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}%",
+1
View File
@@ -1054,6 +1054,7 @@
"LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路",
"LIVE · Roads: {source} · Flow {percent}%": "實時 · 道路:{source} · 路況 {percent}%",
"SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%",
"STALE · Roads: {source} · Flow: cached TomTom tiles": "過期 · 道路:{source} · 路況:TomTom 緩存瓦片",
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路況 · 道路:OpenStreetMap · 路況:TomTom · 未匹配:模擬",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%",
+81 -1
View File
@@ -281,6 +281,67 @@ export function selectPhotonFeature(
return null;
}
/** Short country names people type that Photon's fields don't carry. */
const COUNTRY_ALIASES = Object.freeze({ US: 'usa', GB: 'uk' });
/**
* OSM keys of settlements and administrative areas. Among candidates that fit
* a "place, region" query, these are what the region names a place in: a cafe
* called "Paris" in Lyon is in France too, but "Paris, France" means the city.
*/
const SETTLEMENT_OSM_KEYS = Object.freeze(new Set(['place', 'boundary']));
/** The region part of a "place, region" query, normalized ('' if none). */
function queryRegion(query) {
const [, ...rest] = String(query ?? '').split(',');
return normalizeToponym(rest.join(' '));
}
/**
* For a "place, region" query, pick the candidate named for the place that
* actually lies in the region. "Paris, Texas" is also the name of a pub in
* Budapest: matched as one name it beat the town, so the region has to be
* checked against where each candidate is, not against its name. When several
* candidates fit, a settlement or administrative area wins over a POI.
* @param {object[]} features - Photon features, in the order returned.
* @param {string} query - The query as typed.
* @returns {?object} null when the query names no region or nothing fits.
*/
export function selectQualifiedFeature(features, query) {
const [headText] = String(query ?? '').split(',');
const region = queryRegion(query);
const head = normalizeToponym(headText);
if (!head || !region) return null;
const words = region.split(' ');
const qualified = (Array.isArray(features) ? features : []).filter(
(feature) => {
const properties = feature?.properties ?? {};
const name = normalizeToponym(properties.name);
// Exactly the place: "Paris Gare de Lyon" is in France too, but it
// is not what "Paris, France" asks for.
if (name !== head) return false;
const where = ` ${normalizeToponym(
[
properties.city,
properties.county,
properties.state,
properties.country,
properties.countrycode,
COUNTRY_ALIASES[String(properties.countrycode ?? '').toUpperCase()],
].join(' '),
)} `;
return words.every((word) => where.includes(` ${word} `));
},
);
return (
qualified.find((feature) =>
SETTLEMENT_OSM_KEYS.has(feature?.properties?.osm_key),
) ??
qualified[0] ??
null
);
}
/** Evict the oldest memo entries until the cache is within its cap. */
function trimPhotonCache(cache) {
while (cache.size > PHOTON_CACHE_MAX) {
@@ -359,12 +420,28 @@ export async function geocodeKeylessWithOutcome(
// to the wrong continent. So a biased answer is only accepted when it leads
// with the name asked for; otherwise the same query runs unbiased, which is
// what a user searching for somewhere off-screen meant in the first place.
// A query naming a region is answered nearby only by a match inside it.
// A nearby place whose own name has a comma ("Bed, Bath & Beyond") and
// equals the whole query is kept as the last choice, after the region
// checks of both passes.
let feature = null;
let nearbyNameMatch = null;
let everyAskAnswered = true;
if (bias) {
const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint }));
if (biased === null) everyAskAnswered = false;
else feature = selectPhotonFeature(biased, normalizeToponym(trimmed));
else if (queryRegion(trimmed)) {
feature = selectQualifiedFeature(biased, trimmed);
// Exactly the whole query: "Springfield IL Auto" only starts with it.
const whole = normalizeToponym(trimmed);
nearbyNameMatch =
biased.find(
(candidate) =>
normalizeToponym(candidate?.properties?.name) === whole,
) ?? null;
} else {
feature = selectPhotonFeature(biased, normalizeToponym(trimmed));
}
}
signal?.throwIfAborted();
@@ -379,9 +456,12 @@ export async function geocodeKeylessWithOutcome(
// the thing being searched for; the tail only says where to look.
const head = normalizeToponym(trimmed.split(',')[0]);
feature =
selectQualifiedFeature(anywhere, trimmed) ||
nearbyNameMatch ||
selectPhotonFeature(anywhere, head, { allowContains: true }) ||
anywhere[0];
}
if (!feature) feature = nearbyNameMatch;
}
signal?.throwIfAborted();
+240 -1
View File
@@ -30,6 +30,7 @@ import {
photonResultTypes,
photonSearchUrl,
selectPhotonFeature,
selectQualifiedFeature,
} from './keylessGeocoder.js';
import { geocodeNavigationMode } from './locations.js';
@@ -288,6 +289,245 @@ test('a "place, region" query is matched on the place, not the region', async ()
assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`);
});
/** Photon's real answers for "Paris, Texas" biased to Denver, then unbiased. */
const PARIS_TX = {
geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] },
properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Paris', state: 'Texas', country: 'United States', countrycode: 'US' },
};
const PARIS_TEXAS_PUB = {
geometry: { type: 'Point', coordinates: [19.0606, 47.4868] },
properties: { osm_key: 'amenity', osm_value: 'pub', type: 'house', name: 'Paris, Texas', city: 'Budapest', country: 'Magyarország', countrycode: 'HU' },
};
const PARIS_TEXAS_SALON = {
geometry: { type: 'Point', coordinates: [153.03, -27.47] },
properties: { osm_key: 'shop', osm_value: 'hairdresser', type: 'house', name: 'Paris Texas', city: 'Brisbane', state: 'Queensland', country: 'Australia', countrycode: 'AU' },
};
test('"Paris, Texas" is the town in Texas, not a pub of that name in Budapest', async () => {
// Measured from Denver against the live service: the biased answer listed the
// town first, but the pub's name equals the whole query and won, flying the
// camera to Budapest at rooftop range.
const fetchImpl = photonPair([PARIS_TX, PARIS_TEXAS_PUB, PARIS_TEXAS_SALON], [PARIS_TX]);
const result = await geocodeKeyless('Paris, Texas', { bias: '39.70,-104.90|39.80,-104.80', fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region is checked against where a candidate is, not its name', () => {
const pubFirst = [PARIS_TEXAS_PUB, PARIS_TEXAS_SALON, PARIS_TX];
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas'), PARIS_TX);
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, United States'), PARIS_TX);
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, US'), PARIS_TX);
// No region, or a region nothing lies in, leaves the choice to the name rules.
assert.equal(selectQualifiedFeature(pubFirst, 'Paris'), null);
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Ontario'), null);
assert.equal(selectQualifiedFeature(undefined, 'Paris, Texas'), null);
});
test('a region nearby fits nothing, so the unbiased answer decides', async () => {
const fetchImpl = photonPair([PARIS_TEXAS_PUB], [PARIS_TX, PARIS_TEXAS_PUB]);
const result = await geocodeKeyless('Paris, Texas', { bias: '47.40,19.00|47.50,19.10', fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 2);
});
test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby answer', async () => {
const pubFirst = [PARIS_TEXAS_PUB, PARIS_TX];
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas, USA'), PARIS_TX);
// "Paris," names no region: the camera-biased pass still answers.
const fetchImpl = photonPair([PARIS_TX], []);
const result = await geocodeKeyless('Paris,', {
bias: '33.60,-95.60|33.70,-95.50',
fetchImpl,
});
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region match takes the place itself, not a place whose name starts with it', () => {
const station = {
geometry: { type: 'Point', coordinates: [4.86, 45.76] },
properties: {
osm_key: 'railway',
name: 'Paris Gare de Lyon',
city: 'Paris',
country: 'France',
countrycode: 'FR',
},
};
const paris = {
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
properties: {
osm_key: 'place',
name: 'Paris',
state: 'Île-de-France',
country: 'France',
countrycode: 'FR',
},
};
assert.equal(selectQualifiedFeature([station, paris], 'Paris, France'), paris);
assert.equal(selectQualifiedFeature([station], 'Paris, France'), null);
});
test('"UK" names the United Kingdom', () => {
const london = {
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
properties: {
osm_key: 'place',
name: 'London',
country: 'United Kingdom',
countrycode: 'GB',
},
};
const ontario = {
geometry: { type: 'Point', coordinates: [-81.25, 42.98] },
properties: {
osm_key: 'place',
name: 'London',
state: 'Ontario',
country: 'Canada',
countrycode: 'CA',
},
};
assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london);
});
test('a nearby place whose own name has a comma still wins when nothing matches as place and region', async () => {
const store = (lon, lat, city) => ({
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
osm_key: 'shop',
osm_value: 'houseware',
name: 'Bed, Bath & Beyond',
city,
countrycode: 'US',
},
});
const nearby = store(-104.94, 39.66, 'Denver');
const fetchImpl = photonPair([nearby], [store(-73.99, 40.74, 'New York')]);
const result = await geocodeKeyless('Bed, Bath & Beyond', {
bias: '39.60,-105.00|39.70,-104.90',
fetchImpl,
});
assert.match(result.label, /Denver/);
// Main answered this from the nearby pass alone. The second request is the
// accepted cost of checking the region unbiased first.
assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first');
});
test('the nearby whole-name fallback takes an exact name, not one that only starts with the query', async () => {
const place = (name, lon, lat, props) => ({
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { osm_key: 'place', name, ...props },
});
const nearby = [
place('Springfield IL Auto', -89.6, 39.8, {
osm_key: 'shop',
city: 'Springfield',
state: 'Illinois',
countrycode: 'US',
}),
];
const anywhere = [
place('Springfield', -89.65, 39.78, { state: 'Illinois', countrycode: 'US' }),
];
const fetchImpl = photonPair(nearby, anywhere);
const result = await geocodeKeyless('Springfield, IL', {
bias: '39.70,-89.70|39.90,-89.50',
fetchImpl,
});
assert.doesNotMatch(result.label, /Auto/);
assert.match(result.label, /^Springfield/);
});
test('a region query without a camera bias still takes the place in the region', async () => {
// Unbiased, Photon can list the pub first; main took it because its name
// starts with "Paris".
const fetchImpl = photonPair([], [PARIS_TEXAS_PUB, PARIS_TX]);
const result = await geocodeKeyless('Paris, Texas', { fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region is matched by whole words, and every word has to fit', () => {
const londonUk = {
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
properties: { osm_key: 'place', name: 'London', state: 'England', country: 'United Kingdom', countrycode: 'GB' },
};
// "on" is inside "london" and "england", but it is not a word of either.
assert.equal(selectQualifiedFeature([londonUk], 'London, ON'), null);
// "Texas" fits, "Ontario" does not, so the town does not qualify.
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Texas, Ontario'), null);
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Tex'), null);
});
test('a region can be the city or the county a candidate lies in', () => {
const street = {
geometry: { type: 'Point', coordinates: [-105.27, 40.02] },
properties: { osm_key: 'highway', name: 'Main Street', city: 'Boulder', countrycode: 'US' },
};
const town = {
geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] },
properties: { osm_key: 'place', name: 'Paris', county: 'Lamar', countrycode: 'US' },
};
assert.equal(selectQualifiedFeature([street], 'Main Street, Boulder'), street);
assert.equal(selectQualifiedFeature([town], 'Paris, Lamar'), town);
});
test('when a POI and the place both fit the region, the place wins', async () => {
// A cafe named "Paris" in Lyon is in France too. Nearby it can rank above the
// city, and it would frame at rooftop range.
const cafe = {
geometry: { type: 'Point', coordinates: [4.84, 45.76] },
properties: { osm_key: 'amenity', osm_value: 'cafe', name: 'Paris', city: 'Lyon', country: 'France', countrycode: 'FR' },
};
const paris = {
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'Paris', state: 'Île-de-France', country: 'France', countrycode: 'FR' },
};
const fetchImpl = photonPair([cafe, paris], [paris]);
const result = await geocodeKeyless('Paris, France', { bias: '45.70,4.80|45.80,4.90', fetchImpl });
assert.equal(Math.round(result.lat), 49, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
// A POI alone still qualifies: "Main Street, Denver" names a street.
assert.equal(selectQualifiedFeature([cafe], 'Paris, France'), cafe);
});
test('if the unbiased request fails, the nearby whole-name match is still the answer', async () => {
const store = {
geometry: { type: 'Point', coordinates: [-104.94, 39.66] },
properties: { osm_key: 'shop', name: 'Bed, Bath & Beyond', city: 'Denver', countrycode: 'US' },
};
const urls = [];
const fetchImpl = (url) => {
urls.push(url);
if (!new URL(url).searchParams.has('lat')) return Promise.resolve({ ok: false, status: 503 });
return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [store] }) });
};
const outcome = await geocodeKeylessWithOutcome('Bed, Bath & Beyond', {
bias: '39.60,-105.00|39.70,-104.90',
fetchImpl,
cache: new Map(),
});
assert.match(outcome.place.label, /Denver/);
assert.equal(outcome.answered, true);
assert.equal(urls.length, 2);
});
test('accents and punctuation do not change what a name compares as', () => {
assert.equal(normalizeToponym('Huế'), 'hue');
assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay');
@@ -453,7 +693,6 @@ function readGeocodingConsumer(file) {
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n');
}
test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => {
for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) {
const params = new URL(photonSearchUrl('island', { bias })).searchParams;
+15
View File
@@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) {
liveMode: layerState._liveMode,
fetching: loading,
flowError: layerState._flowError,
stale: layerState._flowStale,
coveragePct: flowCoveragePct,
statusUnavailable: layerState._flowStatusUnavailable,
roadSource: layerState._roadSource,
@@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) {
lastUpdate: layerState._lastUpdate,
loading,
mode: feed.mode,
// Cached proxy tiles (TomTom budget spent or upstream failing): the
// manager reads this as STALE. Any error (flow, roads, detail) reads
// DEGRADED instead.
stale: Boolean(
layerState._liveMode &&
layerState._flowStale &&
!(
layerState._roadError ||
layerState._roadWarning ||
layerState._detailError ||
feed.error
),
),
oldestTileAt: layerState._flowOldestTileAt ?? null,
error:
layerState._roadError ||
layerState._roadWarning ||
+11 -2
View File
@@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) {
return layerState._warmFlow;
layerState._warmFlowGeneration = generation;
const signal = layerState._activeFetchAbort?.signal;
layerState._warmFlow = ensureFlowStatus(signal).then(() => {
layerState._warmFlow = ensureFlowStatus(signal).then(async () => {
if (!layerState._liveMode || generation !== layerState._loadGeneration)
return [];
return fetchFlowForBounds(clamped, { signal });
if (typeof source.fetchFlowDetail !== 'function')
return fetchFlowForBounds(clamped, { signal });
// The proxy serves cached tiles once the TomTom budget is spent or the
// upstream fails; carry that mark so getStats() reads STALE, not LIVE.
const detail = await source.fetchFlowDetail(clamped, { signal });
if (generation === layerState._loadGeneration) {
layerState._flowStale = detail.stale === true;
layerState._flowOldestTileAt = detail.oldestTileAt ?? null;
}
return detail.segments;
});
layerState._warmFlow.catch(() => {});
return layerState._warmFlow;
+4
View File
@@ -42,6 +42,8 @@ export function createLifecycle({
layerState._lastViewCenter = null;
layerState._flowRoads = null;
layerState._flowError = null;
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
if (TRAFFIC_TIMING_ENABLED) {
layerState._trafficTimingCurrentAnchor = null;
layerState._trafficTimingSequence = 0;
@@ -159,6 +161,8 @@ export function createLifecycle({
// A stale outage from the last session would misreport a fresh enable —
// the next load re-derives feed health from real evidence.
layerState._flowError = null;
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
layerState._surfaceFrameRemover?.();
layerState._surfaceFrameRemover = null;
+12
View File
@@ -229,6 +229,7 @@ export function createModel({ state: layerState, services, parts, source }) {
* @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key.
* @param {boolean} [input.fetching] - A viewport load is in flight.
* @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any.
* @param {boolean} [input.stale] - The flow came from cached proxy tiles.
* @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100.
* @param {boolean} [input.statusUnavailable] - The status probe itself failed.
* @param {string} [input.roadSource] - Name of the geometry being drawn.
@@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) {
liveMode = false,
fetching = false,
flowError = null,
stale = false,
coveragePct = 0,
statusUnavailable = false,
roadSource = 'OpenStreetMap',
@@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) {
});
return { mode, error: degraded, loadingLabel: degraded };
}
// Cached proxy tiles (TomTom budget spent or upstream failing) are not live.
if (liveMode && stale && !fetching) {
return {
mode,
error: null,
loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', {
source: roadSource,
}),
};
}
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
if (liveMode && roadSource === 'TomTom') {
return {
+58
View File
@@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated'
assert.equal(state._flowPending, 0);
});
test('stale proxy flow tiles reach the layer stats and never read LIVE', async () => {
const { createFlow } = await import('./flow.js');
const { createModel } = await import('./model.js');
const { createControls } = await import('./controls.js');
const { createState } = await import('./state.js');
const { layerFeedState } = await import('../../data/feedState.js');
let detail = {
segments: [flow],
partial: false,
stale: true,
oldestTileAt: 1000,
};
const source = {
fetchFlowDetail: async () => detail,
fetchFlowForBounds: async () => detail.segments,
getFlowSessionStats: () => ({ tilesFetched: 1 }),
};
const state = createState({ services: {} });
Object.assign(state, {
_enabled: true,
_liveMode: true,
_loadGeneration: 1,
_flowStatusPromise: Promise.resolve(),
_bucketCounts: { free: 3, slow: 0, jam: 0, sim: 1 },
});
const parts = {};
const context = { state, services: { credits: {} }, parts, source };
parts.model = createModel(context);
parts.flow = createFlow(context);
const controls = createControls(context);
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 1);
const stale = controls.methods.getStats();
assert.equal(stale.mode, 'live');
assert.equal(stale.stale, true);
assert.equal(stale.oldestTileAt, 1000);
assert.doesNotMatch(stale.loadingLabel, /LIVE/);
assert.equal(layerFeedState(stale), 'stale');
// A road failure outranks cached flow: the row reads the failure.
state._roadError = 'Road geometry unavailable';
assert.equal(controls.methods.getStats().stale, false);
state._roadError = null;
detail = {
segments: [flow],
partial: false,
stale: false,
oldestTileAt: 2000,
};
state._loadGeneration = 2;
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 2);
const live = controls.methods.getStats();
assert.equal(live.stale, false);
assert.match(live.loadingLabel, /^LIVE · Roads: OpenStreetMap/);
assert.equal(layerFeedState(live), 'nominal');
});
test('status names the drawn road source for each mode', async () => {
const { createModel } = await import('./model.js');
const { trafficFeedPresentation: present } = createModel({
+10
View File
@@ -108,6 +108,16 @@ export function createState({ services }) {
layerState._flowError = null;
/**
* True while the drawn flow came from cached proxy tiles (TomTom budget
* spent or upstream failing); `_flowOldestTileAt` is the oldest tile's
* fetch time in ms, or null.
* @type {boolean}
*/
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
/**
* True when `/api/tomtom/status` itself could not be reached, so the layer is
* simulating because it could not ask — not because the server said "no key".
+3
View File
@@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
layerState._fetching = false;
layerState._flowPending = 0;
layerState._roadError = null;
// Nothing is drawn up here, cached or not.
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
parts.animation.clearDots();
layerState._lastBounds = null;
layerState._lastViewCenter = null;
+114 -17
View File
@@ -153,6 +153,8 @@ export class SceneDirector {
this._runAbort = null;
/** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */
this._loadAbort = null;
/** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */
this._seekAbort = null;
this._clock = createPlaybackClock({
isRunning: () => this._running,
timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot),
@@ -234,7 +236,7 @@ export class SceneDirector {
destroy() {
if (this._destroyPromise) return this._destroyPromise;
this._destroyed = true;
this._sceneSeekGeneration++;
this._bumpSceneSeekGeneration();
this._visibilityUnsubscribe?.();
this._cameraHandoffUnsubscribe?.();
this._removeCameraInput?.();
@@ -1071,7 +1073,7 @@ export class SceneDirector {
loadShot(sceneId, shotId, options) {
if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++;
this._bumpSceneSeekGeneration();
this._interactionTransitions = 0;
const previousGeneration = this._loadGeneration;
const work = this._loadShot(sceneId, shotId, options);
@@ -1516,9 +1518,30 @@ export class SceneDirector {
return new Promise((resolve) => this._runIdleResolvers.add(resolve));
}
/** Apply a new time inside the already-loaded shot without rebuilding its stack. */
_seekLoadedShot(scene, seekState) {
if (this._destroyed) return false;
/**
* Supersede any pending seek. Only a seek's own layer reconcile is aborted:
* a request that is later rejected must not cancel an in-flight LOAD.
*/
_bumpSceneSeekGeneration() {
if (this._seekAbort) {
this._seekAbort.abort();
if (this._loadAbort === this._seekAbort) this._loadAbort = null;
this._seekAbort = null;
}
return ++this._sceneSeekGeneration;
}
/**
* Apply a new time inside the already-loaded shot without rebuilding its stack.
* @returns {Promise<boolean|null>} Null when the shortcut does not apply and
* LOAD must rebuild the shot; otherwise whether the seek settled.
*/
async _seekLoadedShot(
scene,
seekState,
seekGeneration = this._sceneSeekGeneration,
) {
if (this._destroyed) return null;
const shot = seekState?.shot;
if (
!scene ||
@@ -1528,17 +1551,77 @@ export class SceneDirector {
this._loadedSceneId !== scene.id ||
this._selectedShotId !== shot.id
)
return null;
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
return false;
if (!this._claimCameraOwnership()) return false;
this._cancelActiveSceneTravel();
this._loadAbort?.abort();
this._loadAbort = null;
this._loadGeneration += 1;
const generation = ++this._loadGeneration;
this._clock.stopShot();
const states = this._layerStatesForShot(scene, shot, {
cameraSettled: seekState.cameraProgress >= 1,
sceneSeek: seekState,
});
// A manual toggle, or a superseded LOAD transition whose rollback is still
// settling, leaves declared layers off the shot's stack. Reconcile only
// those through the cancellable admission path.
const staleStates = Object.fromEntries(
Object.entries(states)
.filter(([id, state]) => {
const wantEnabled = !!state?.enabled;
const lifecycle = this.dataManager.getLayerLifecycleState(id);
return (
lifecycle &&
(lifecycle.enabled !== wantEnabled ||
lifecycle.lifecycleState === 'enabling' ||
lifecycle.lifecycleState === 'disabling' ||
lifecycle.uncertain ||
this.dataManager.isEffectivelyEnabled(id) !== wantEnabled)
);
})
.map(([id, state]) => [id, { enabled: !!state?.enabled }]),
);
if (Object.keys(staleStates).length) {
const previousEnabled = new Map(
Object.keys(staleStates).map((id) => [
id,
this.dataManager.isEffectivelyEnabled(id),
]),
);
const controller = new AbortController();
this._loadAbort = controller;
this._seekAbort = controller;
const layerResult = await this._applyLayerStates(
staleStates,
this._loadToken(generation, controller.signal, seekGeneration),
{ stopOnRefusal: true, exitContextMode: false },
);
if (this._loadAbort === controller) this._loadAbort = null;
if (this._seekAbort === controller) this._seekAbort = null;
const superseded = () =>
this._destroyed ||
this._loadAbort ||
this._running ||
seekGeneration !== this._sceneSeekGeneration;
if (layerResult.cancelled || superseded()) return false;
if (layerResult.refused.length) {
// Only a refusal rolls back. A cancelled reconcile leaves layer state
// to whatever superseded it, as a cancelled LOAD does.
for (const id of [...layerResult.applied].reverse()) {
if (superseded()) break;
await this.dataManager.setEnabled(
id,
previousEnabled.get(id) ?? false,
{ origin: 'scene' },
);
}
return false;
}
}
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
return false;
let applied = true;
for (const [layerId, state] of Object.entries(states)) {
if (!state?.enabled || !state.params) continue;
@@ -1550,7 +1633,9 @@ export class SceneDirector {
applied = false;
}
}
if (!applied) return false;
if (!applied) return null;
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
return false;
this._setCameraView(
seekState.camera || resolveCameraPose(scene, shot.camera),
);
@@ -1572,7 +1657,7 @@ export class SceneDirector {
seekScene(sceneId, progress) {
if (this._destroyed) return Promise.resolve(false);
this.stopScene(msg('Seeking scene clock'));
const generation = ++this._sceneSeekGeneration;
const generation = this._bumpSceneSeekGeneration();
return this._trackWork(this._seekScene(sceneId, progress, generation));
}
@@ -1586,7 +1671,10 @@ export class SceneDirector {
return false;
const seekState = this._sceneSeekState(scene, progress);
if (!seekState) return false;
if (this._seekLoadedShot(scene, seekState)) return true;
const seeked = await this._seekLoadedShot(scene, seekState, generation);
if (this._destroyed || generation !== this._sceneSeekGeneration)
return false;
if (seeked !== null) return seeked;
const result = await this._loadShot(scene.id, seekState.shot.id, {
sceneSeek: seekState,
});
@@ -1606,15 +1694,17 @@ export class SceneDirector {
* @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls
* @returns {{ cancelled: boolean, signal: AbortSignal|undefined }}
*/
_loadToken(generation, signal = undefined) {
_loadToken(generation, signal = undefined, seekGeneration = null) {
const director = this;
return {
signal,
get cancelled() {
return (
director._destroyed ||
signal?.aborted ||
director._loadGeneration !== generation
Boolean(signal?.aborted) ||
director._loadGeneration !== generation ||
(seekGeneration !== null &&
director._sceneSeekGeneration !== seekGeneration)
);
},
};
@@ -1753,7 +1843,7 @@ export class SceneDirector {
startScene(sceneId, options) {
if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++;
this._bumpSceneSeekGeneration();
return this._trackWork(this._startScene(sceneId, options));
}
@@ -1919,7 +2009,7 @@ export class SceneDirector {
this._setSceneMediaPlayback();
this._interactions?.clear();
this._dataPacks?.clear();
this._sceneSeekGeneration++;
this._bumpSceneSeekGeneration();
this._loadAbort?.abort();
this._loadAbort = null;
this._loadGeneration++;
@@ -2165,7 +2255,11 @@ export class SceneDirector {
* the transition in flight.
* @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>}
*/
async _applyLayerStates(targetStates, token = null) {
async _applyLayerStates(
targetStates,
token = null,
{ stopOnRefusal = false, exitContextMode = true } = {},
) {
const applied = [];
const refused = [];
const abort = () => ({ applied, refused, cancelled: true });
@@ -2175,8 +2269,10 @@ export class SceneDirector {
// the operator's pre-mode state, which is exactly where a stopped scene
// should come to rest. Tearing that transaction in half would strand
// Context, so it completes and cancellation is honoured immediately after.
await this._exitIsolatingContextMode();
if (token?.cancelled) return abort();
if (exitContextMode) {
await this._exitIsolatingContextMode();
if (token?.cancelled) return abort();
}
const signal = token?.signal;
const registered = new Set(
@@ -2206,6 +2302,7 @@ export class SceneDirector {
console.warn(
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
);
if (stopOnRefusal) break;
continue;
}
if (params && typeof this.dataManager.restoreLayerState !== 'function') {
+362
View File
@@ -14,6 +14,7 @@ import { readShellSource } from '../testSupport/readShellSource.mjs';
import assert from 'node:assert/strict';
import test from 'node:test';
import { LayerLifecycle } from '../data/lifecycle.js';
import { SceneDirector } from './director.js';
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
@@ -174,6 +175,348 @@ test('scene clock seek resolves the exact shot phase and camera in both directio
}
});
test('same-shot seeking restores declared layer visibility after manual changes', async () => {
const { director, dataManager, styleManager, restore } = makeDirector();
try {
const shot = director._project.scenes[0].shots[0];
shot.layers = { flights: { enabled: true }, traffic: { enabled: false } };
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('flights', false);
await dataManager.setEnabled('traffic', true);
await dataManager.setEnabled('satellites', true);
dataManager.setEnabledCalls.length = 0;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), true);
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer');
assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer');
assert.equal(enabled.get('satellites'), true, 'preserve undeclared operator layers');
assert.ok(dataManager.setEnabledCalls.every(({ id }) => id !== 'satellites'));
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => {
let refuse = false;
const { director, dataManager, styleManager, restore } = makeDirector({
data: { refuse: () => refuse },
});
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false);
refuse = true;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false);
const timing = director.getPlaybackTimingState();
assert.equal(timing.activeTimers, 0);
assert.ok(Math.abs(timing.snapshot.sceneProgress - 0.1) < 1e-9, 'keep the last settled seek position');
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking restores visibility a manual toggle is still changing', async () => {
const { director, dataManager, restore } = makeDirector();
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
const disabling = dataManager.setEnabled('flights', false);
assert.equal(await director.seekScene('scene-1', 0.25), true);
await disabling;
await settle();
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => {
const { director, dataManager, styleManager, viewer, restore } = makeDirector();
const lifecycle = new LayerLifecycle(viewer);
for (const id of ['flights', 'traffic', 'satellites']) {
lifecycle.register({
id,
name: id,
init() {},
enable() {},
disable() {},
update() {},
});
}
const realDirector = new SceneDirector(viewer, styleManager, lifecycle);
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('flights', false);
dataManager.setEnabledCalls.length = 0;
const reload = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (let i = 0; i < 20 && !dataManager.setEnabledCalls.length; i++) await Promise.resolve();
assert.equal(dataManager.getLayerLifecycleState('flights').lifecycleState, 'enabling');
assert.equal(await director.seekScene('scene-1', 0.25), true);
await reload;
await settle();
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
await realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await lifecycle.setEnabled('flights', false);
const queuedReload = realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (let i = 0; i < 20 && !lifecycle.isEffectivelyEnabled('flights'); i++) {
await Promise.resolve();
}
assert.deepEqual(lifecycle.getLayerLifecycleState('flights'), {
enabled: false,
lifecycleState: 'disabled',
uncertain: false,
});
assert.equal(await realDirector.seekScene('scene-1', 0.25), true);
await queuedReload;
await settle();
assert.equal(lifecycle.isEnabled('flights'), true);
lifecycle.layers.get('flights').lifecycleUncertain = true;
assert.equal(await realDirector.seekScene('scene-1', 0.3), true);
assert.equal(lifecycle.getLayerLifecycleState('flights').uncertain, false);
let navClaims = 0;
styleManager.runImmediateNavigation = () => {
navClaims += 1;
return false;
};
assert.equal(await realDirector.seekScene('scene-1', 0.4), false);
assert.equal(navClaims, 1, 'camera refusal fails the seek without falling back to LOAD');
} finally {
await realDirector.destroy();
await director.destroy();
restore();
}
});
test('same-shot seeking rolls back earlier layers and preserves Context, clock and camera when a later layer is refused', async () => {
let refuse = () => false;
const { director, dataManager, styleManager, viewer, restore } = makeDirector({
data: { refuse: (id, on) => refuse(id, on) },
});
try {
const shot = director._project.scenes[0].shots[0];
shot.layers = {
flights: { enabled: true },
traffic: { enabled: true },
satellites: { enabled: true },
};
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false);
await dataManager.setEnabled('traffic', false);
await dataManager.setEnabled('satellites', false);
styleManager.contextMode = 'space-missions';
styleManager.contextExits.length = 0;
refuse = (id) => id === 'traffic';
dataManager.setEnabledCalls.length = 0;
const viewsBefore = viewer.views.length;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(
dataManager.setEnabledCalls.some(({ id }) => id === 'satellites'),
false,
'stop the plan before later ids are written',
);
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
assert.equal(enabled.get('flights'), false, 'first stale layer stays disabled');
assert.equal(enabled.get('traffic'), false, 'refused second layer stays disabled');
assert.equal(enabled.get('satellites'), false, 'later stale layer is never written');
assert.equal(styleManager.contextMode, 'space-missions');
assert.deepEqual(styleManager.contextExits, []);
assert.equal(viewer.views.length, viewsBefore, 'camera stays at the pre-seek pose');
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
assert.ok(
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
'clock stays at the last settled seek position',
);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking aborts in-flight admission when superseded by an early-return load or start', async () => {
const { styleManager, viewer, restore } = makeDirector();
let holdEnable = false;
let releaseEnable;
const enableGate = new Promise((resolve) => {
releaseEnable = resolve;
});
const lifecycle = new LayerLifecycle(viewer);
lifecycle.register({
id: 'flights',
name: 'flights',
init() {},
async enable() {
if (holdEnable) await enableGate;
},
disable() {},
update() {},
});
const director = new SceneDirector(viewer, styleManager, lifecycle);
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await lifecycle.setEnabled('flights', false);
holdEnable = true;
const viewsBefore = viewer.views.length;
const seek = director.seekScene('scene-1', 0.25);
for (
let i = 0;
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
i++
) {
await Promise.resolve();
}
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
const admissionSignal = director._loadAbort?.signal;
assert.ok(admissionSignal, 'same-shot seek installs an admission controller');
const missingLoad = director.loadShot('missing', 'nope');
assert.equal(
admissionSignal.aborted,
true,
'missing load aborts the admission controller before returning',
);
assert.equal((await missingLoad).reason, 'shot-not-found');
assert.equal(viewer.views.length, viewsBefore, 'shortcut has not committed before abort');
releaseEnable();
assert.equal(await seek, false);
await settle();
assert.equal(lifecycle.isEnabled('flights'), false, 'layer is not restored');
assert.equal(viewer.views.length, viewsBefore, 'setView is not called');
assert.ok(
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
'sceneProgress stays at the last settled value',
);
let loadFallbackCalls = 0;
const origLoadShot = director._loadShot.bind(director);
director._loadShot = (...args) => {
loadFallbackCalls += 1;
return origLoadShot(...args);
};
const origSeekLoadedShot = director._seekLoadedShot.bind(director);
director._seekLoadedShot = async () => {
await director.startScene('missing');
return null;
};
assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(loadFallbackCalls, 0, 'stale seek returning null must not start _loadShot');
director._seekLoadedShot = origSeekLoadedShot;
} finally {
releaseEnable?.();
await director.destroy();
restore();
}
});
test('a rejected loadShot does not cancel an in-flight LOAD', async () => {
const { styleManager, viewer, restore } = makeDirector();
let holdEnable = true;
let releaseEnable;
const enableGate = new Promise((resolve) => {
releaseEnable = resolve;
});
const lifecycle = new LayerLifecycle(viewer);
lifecycle.register({
id: 'flights',
name: 'flights',
init() {},
async enable() {
if (holdEnable) await enableGate;
},
disable() {},
update() {},
});
const director = new SceneDirector(viewer, styleManager, lifecycle);
try {
const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (
let i = 0;
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
i++
) {
await Promise.resolve();
}
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found');
holdEnable = false;
releaseEnable();
assert.equal((await load)?.started, true, 'the valid LOAD still lands');
assert.equal(lifecycle.isEnabled('flights'), true);
} finally {
releaseEnable?.();
await director.destroy();
restore();
}
});
test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => {
let refuse = () => false;
const { director, dataManager, restore } = makeDirector({
data: { refuse: (id, on) => refuse(id, on) },
});
try {
const shot = director._project.scenes[0].shots[0];
shot.layers = { flights: { enabled: true }, traffic: { enabled: true } };
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false);
await dataManager.setEnabled('traffic', false);
refuse = (id) => id === 'traffic';
const manualEnable = dataManager.setEnabled('flights', true);
assert.equal(await director.seekScene('scene-1', 0.25), false);
await manualEnable;
await settle();
assert.equal(
dataManager.getAll().find(({ id }) => id === 'flights').enabled,
true,
'rollback restores the operator\'s intended state, not the stale committed one',
);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => {
const { director, dataManager, styleManager, restore } = makeDirector();
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('satellites', true);
dataManager.setEnabledCalls.length = 0;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), true);
assert.deepEqual(dataManager.setEnabledCalls, []);
assert.equal(styleManager.visualCalls.length, visualCalls);
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
} finally {
await director.destroy();
restore();
}
});
test('scene clock subscribers receive authoritative forward playback snapshots', () => {
const { director, restore } = makeDirector();
const snapshots = [];
@@ -647,6 +990,7 @@ function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
*/
function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) {
const enabled = new Map();
const pending = new Map();
const setEnabledCalls = [];
const setParamsCalls = [];
const committed = [];
@@ -659,12 +1003,25 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
setParamsCalls,
committed,
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
getLayerLifecycleState: (id) => {
if (!registered.includes(id)) return null;
const on = !!enabled.get(id);
const target = pending.get(id);
return {
enabled: on,
lifecycleState:
target === undefined ? (on ? 'enabled' : 'disabled') : target ? 'enabling' : 'disabling',
};
},
isEffectivelyEnabled: (id) => pending.get(id) ?? !!enabled.get(id),
getLayerParams: () => null,
async setEnabled(id, shouldEnable, { signal } = {}) {
setEnabledCalls.push({ id, enabled: shouldEnable, signal });
if (refuse(id, shouldEnable)) return false;
// Yield once so a stop landing during the transition is observable.
pending.set(id, shouldEnable);
await Promise.resolve();
if (pending.get(id) === shouldEnable) pending.delete(id);
if (signal?.aborted) return false;
enabled.set(id, shouldEnable);
committed.push({ id, enabled: shouldEnable });
@@ -707,15 +1064,20 @@ function fakeStyleManager({ contextMode = null, exitFails = false } = {}) {
/** Cesium viewer double whose flights complete on the next microtask turn. */
function fakeViewer() {
const flights = [];
const views = [];
let cancelled = 0;
return {
flights,
views,
get cancelledFlights() { return cancelled; },
camera: {
flyTo(options) {
flights.push(options);
Promise.resolve().then(() => options.complete?.());
},
setView(options) {
views.push(options);
},
cancelFlight() { cancelled++; },
},
};
+18
View File
@@ -71,6 +71,7 @@ export function createApplicationRequestServices({
// Discovery is bounded; failed, malformed or timed-out probes are forgotten and not
// retried for a short backoff, during which queries go to the server as
// they would against an older server without the probe.
let summaryCapability = null;
let boundaryCapability = null;
let boundaryProbeRetryAt = 0;
function boundariesConfigured() {
@@ -175,6 +176,23 @@ export function createApplicationRequestServices({
},
},
summary: {
// The capability probe, asked once per page: true or false from a
// server that answers it, null (unknown) from one that does not.
configured() {
summaryCapability ??= request(`${urls.summary}/status`, {
signal: AbortSignal.timeout(3000),
}).then(
(response) =>
response.ok && typeof response.data?.configured === 'boolean'
? response.data.configured
: null,
(error) => {
if (lifetime?.aborted) throw error;
return null;
},
);
return summaryCapability;
},
async summarize(context, { signal } = {}) {
return request(urls.summary, {
method: 'POST',
+21
View File
@@ -94,3 +94,24 @@ test('a failed probe is retried after its backoff, not on every query', async ()
'GET /api/overpass/status',
]);
});
test('the summary capability probe is asked once and unknown when unanswered', async () => {
for (const [answer, expected] of [
[() => Response.json({ configured: false }), false],
[() => Response.json({ configured: true }), true],
[() => new Response('missing', { status: 404 }), null],
[() => Response.json({ configured: 'yes' }), null],
[() => { throw new TypeError('offline'); }, null],
]) {
const seen = [];
const services = createApplicationRequestServices({
fetchImpl: async (url, init) => {
seen.push(`${init.method || 'GET'} ${url}`);
return answer();
},
});
assert.equal(await services.summary.configured(), expected);
assert.equal(await services.summary.configured(), expected);
assert.deepEqual(seen, ['GET /api/openai/hud-summary/status']);
}
});
+153
View File
@@ -411,3 +411,156 @@ test('terrain middleware retains successful chunks around a failure and retries
);
assert.equal(requests.length, 4);
});
test('after a 429 the terrain proxy leaves the upstream alone and logs once', async (t) => {
isolate(t);
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('', { status: 429 });
});
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
const request = install(terrainHeightsProxy());
// The retries' backoff runs on mocked time.
let settled = false;
const first = request('/?points=1,2').then(() => {
settled = true;
});
for (let step = 0; step < 60 && !settled; step += 1) {
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(1_000);
}
assert.ok(settled, 'the first request settles within its retry budget');
await first;
const afterFirst = calls;
assert.ok(afterFirst >= 1);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /HTTP 429; pausing upstream for 60 s/);
// New points during the pause: no upstream call, no new log line.
const paused = await request('/?points=3,4');
assert.equal(calls, afterFirst);
assert.equal(paused.status, 502);
assert.equal(warnings.length, 1);
});
test('a far-off Retry-After pauses the terrain upstream for at most 10 minutes', async (t) => {
isolate(t);
t.mock.method(console, 'warn', () => {});
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let limited = true;
t.mock.method(globalThis, 'fetch', async () =>
limited
? new Response('', { status: 429, headers: { 'retry-after': '86400' } })
: Response.json({ results: [{ ellipsoid: 100 }] }),
);
const request = install(terrainHeightsProxy());
// The first request spends its retry budget on 429s, then the pause begins.
let settled = false;
const first = request('/?points=1,2').then((res) => {
settled = true;
return res;
});
// Bounded: the retry budget is 10 s, so 60 simulated seconds is plenty.
for (let step = 0; step < 60 && !settled; step += 1) {
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(1_000);
}
assert.ok(settled, 'the first request settles within its retry budget');
assert.equal((await first).status, 502);
limited = false;
const pausedAt = Date.now();
t.mock.timers.setTime(pausedAt + 9 * 60_000);
assert.equal((await request('/?points=3,4')).status, 502, 'still paused');
t.mock.timers.setTime(pausedAt + 10 * 60_000 + 1);
assert.equal((await request('/?points=3,4')).status, 200, 'pause over');
});
/** Drive mocked timers until `promise` settles; the retry budget is 10 s. */
async function settleOnMockedTime(t, promise) {
let settled = false;
const tracked = promise.finally(() => {
settled = true;
});
for (let step = 0; step < 60 && !settled; step += 1) {
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(1_000);
}
assert.ok(settled, 'the request settles within its retry budget');
return tracked;
}
test('during a terrain 429 pause fully cached points, even stale ones, are still served', async (t) => {
isolate(t);
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let calls = 0;
let limited = false;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return limited
? new Response('', { status: 429, headers: { 'retry-after': '300' } })
: Response.json({ results: [{ ellipsoid: 100 }] });
});
const request = install(terrainHeightsProxy());
assert.equal((await request('/?points=5,6')).status, 200);
// Past the 30-day TTL, so 5,6 is stale and would normally be refreshed.
t.mock.timers.setTime(Date.now() + 31 * 24 * 3600_000);
limited = true;
const first = await settleOnMockedTime(t, request('/?points=1,2'));
assert.equal(first.status, 502);
assert.equal(calls, 2, 'a Retry-After past the budget is not retried');
const stale = await request('/?points=5,6');
assert.equal(stale.status, 200);
assert.deepEqual(json(stale).results, [{ ellipsoid: 100 }]);
assert.equal((await request('/?points=5,6;7,8')).status, 502);
assert.equal(calls, 2, 'no upstream call during the pause');
});
test('a terrain upstream failure other than 429 does not pause later requests', async (t) => {
isolate(t);
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('', { status: 503 });
});
const request = install(terrainHeightsProxy());
const first = await settleOnMockedTime(t, request('/?points=1,2'));
assert.equal(first.status, 502);
const afterFirst = calls;
const second = await settleOnMockedTime(t, request('/?points=3,4'));
assert.equal(second.status, 502);
assert.ok(calls > afterFirst, 'new points still go upstream after a 503');
});
test('a shorter concurrent Retry-After never cuts a terrain 429 pause short', async (t) => {
isolate(t);
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let calls = 0;
t.mock.method(globalThis, 'fetch', async (raw) => {
calls += 1;
const points = new URL(raw).searchParams.get('points');
if (points.startsWith('0.'))
return Response.json({ results: [{ ellipsoid: 1 }] });
const retryAfter = points.startsWith('1.') ? '300' : '30';
return new Response('', {
status: 429,
headers: { 'retry-after': retryAfter },
});
});
const request = install(terrainHeightsProxy());
// Load the disk cache first so both requests below reach the upstream in
// order: the 300 s answer lands first, the 30 s answer after it.
assert.equal((await request('/?points=0,0')).status, 200);
const [long, short] = await settleOnMockedTime(
t,
Promise.all([request('/?points=1,2'), request('/?points=3,4')]),
);
assert.equal(long.status, 502);
assert.equal(short.status, 502);
const afterBoth = calls;
t.mock.timers.setTime(Date.now() + 120_000);
assert.equal((await request('/?points=5,6')).status, 502);
assert.equal(calls, afterBoth, 'still paused by the 300 s Retry-After');
});
+23
View File
@@ -729,6 +729,29 @@ test('Realtime service configuration selects compatible endpoint/model without f
assert.doesNotMatch(response.body, /server-fixture|voice\.example/);
});
test('the HUD summary status says whether a summary is configured, without calling OpenAI', async (t) => {
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('the status probe must not reach OpenAI');
});
const handler = install(openAiRealtimeProxy()).get('/api/openai/hud-summary');
for (const [key, configured] of [
[undefined, false],
[' ', false],
['fixture-key', true],
]) {
env(t, 'OPENAI_API_KEY', key);
const response = await request(handler, { url: '/status' });
assert.equal(response.status, 200);
assert.deepEqual(response.json(), { configured });
assert.equal(response.headers['cache-control'], 'no-store');
}
assert.equal(
(await request(handler, { url: '/' })).status,
405,
'only the status path answers GET',
);
});
test('OpenAI routes answer generically when the upstream or the request fails', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
+8 -2
View File
@@ -714,18 +714,24 @@ export class LayerPanel {
});
}
if (feedState === 'stale') {
// A layer drawing cached upstream data (Traffic's proxy tiles) dates the
// row by the oldest of it, not by the load that re-served it.
const dataAgo =
Number(stats.oldestTileAt) > 0
? this._timeAgo(stats.oldestTileAt)
: ago;
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return t('{state} · {source} · {ago} · retrying in {seconds}s', {
state: stateLabel,
source,
ago,
ago: dataAgo,
seconds: stats.retryInSec,
});
}
return t('{state} · {source} · {ago}', {
state: stateLabel,
source,
ago,
ago: dataAgo,
});
}
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
+21
View File
@@ -93,6 +93,27 @@ test('partial feed controls distinguish incomplete records from stale data and o
assert.equal(button.textContent, 'OFF');
});
test('a stale row dates cached data by its oldest tile, not the last load', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const panel = LayerPanel.prototype;
const layer = {
id: 'traffic',
name: 'Street Traffic',
source: 'TomTom',
enabled: true,
stats: {
mode: 'live',
stale: true,
count: 4,
lastUpdate: Date.now(),
oldestTileAt: Date.now() - 3 * 3600_000,
},
};
assert.match(panel._buildMetaText(layer), /^STALE · TomTom · 3h ago$/);
delete layer.stats.oldestTileAt;
assert.match(panel._buildMetaText(layer), /^STALE · TomTom · just now$/);
});
test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const { railFixture } = await import('./railTestFixture.mjs');
+81 -4
View File
@@ -12,6 +12,11 @@ import { msg } from '../i18n/index.js';
import * as Cesium from 'cesium';
import { CITY_POIS } from '../cityPois.js';
import { greatCircleKm } from '../geoDistance.js';
import {
hudViewportSamples,
placeLookupView,
placeLookupViewChanged,
} from '../hudPlaceLookup.js';
import {
findPoiByName,
flyToGlobeView,
@@ -2692,13 +2697,20 @@ function runManagedVoiceNavigation(
};
}
/**
* Place labels for the HUD's passive summary. It geocodes the view center
* and one more point. While the view still shows about the same area (see
* hudPlaceLookup.js) it answers for the points the last lookup asked about
* instead, so a settled camera does not buy a new set of Google requests for
* every small pan; those points are cache hits, and only what the last
* lookup did not get (a timeout, a failure, an empty answer) is asked again.
* `cachedOnly` reads the shared caches and makes no request.
*/
export async function getBasemapLabelContext(
viewer,
service = defaultGeospatial,
{ cachedOnly = false } = {},
{ cachedOnly = false, previous = null } = {},
) {
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
const samples = sampleViewportCartographics(viewer);
const cameraHeightM = viewer.camera.positionCartographic.height;
const target = getViewTargetCartographic(viewer);
if (!target) {
@@ -2706,11 +2718,77 @@ export async function getBasemapLabelContext(
placeLabels: [],
streetLabels: [],
nearbyPlaceLabels: [],
view: null,
lookup: null,
};
}
const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6));
const longitude = Number(Cesium.Math.toDegrees(target.longitude).toFixed(6));
const view = placeLookupView({
latitude,
longitude,
rangeM: viewTargetRangeM(viewer, target, cameraHeightM),
fovRad: viewer.camera.frustum?.fov,
aspectRatio: viewer.camera.frustum?.aspectRatio,
tier: basemapLookupTier(cameraHeightM),
});
if (
!cachedOnly &&
previous?.lookup &&
!placeLookupViewChanged(previous.view, view)
) {
// The same points again: what the last lookup got is a cache hit, and
// only what it did not (a timeout, a failure, an empty answer) is asked.
const labels = await basemapLabelsAt(previous.lookup, service, {
cachedOnly: false,
});
return { ...labels, view: previous.view, lookup: previous.lookup };
}
const samples = sampleViewportCartographics(viewer);
const lookup = {
latitude,
longitude,
cameraHeightM,
// Cache reads are free, so a cached-only host keeps every sample.
samples: cachedOnly
? samples
: hudViewportSamples(samples, shouldReverseGeocode(cameraHeightM)),
};
return {
...(await basemapLabelsAt(lookup, service, { cachedOnly })),
view,
lookup,
};
}
/** Which of the height-gated lookups a camera height calls for. */
function basemapLookupTier(cameraHeightM) {
return [
shouldReverseGeocode(cameraHeightM),
shouldReverseGeocodeViewport(cameraHeightM) && cameraHeightM > 10000,
shouldFetchNearbyPlaces(cameraHeightM) &&
nearbyPlacesRadiusM(cameraHeightM),
].join(':');
}
/** Camera distance to the view target, or the camera height without one. */
function viewTargetRangeM(viewer, target, cameraHeightM) {
const position = viewer.camera.positionWC;
if (!position) return cameraHeightM;
const range = Cesium.Cartesian3.distance(
position,
Cesium.Cartographic.toCartesian(target),
);
return Number.isFinite(range) ? range : cameraHeightM;
}
async function basemapLabelsAt(
{ latitude, longitude, cameraHeightM, samples },
service,
{ cachedOnly },
) {
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
const cachedViewportPlaces = viewportPlacesFromCache(
samples,
cameraHeightM,
@@ -2748,7 +2826,6 @@ export async function getBasemapLabelContext(
resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, null),
resolveWithin(nearbyPromise, BASEMAP_CONTEXT_WAIT_MS, []),
]);
return {
placeLabels: uniqueStrings([
place?.formattedAddress,
+359
View File
@@ -0,0 +1,359 @@
// The HUD's passive place lookup spends paid Google requests on its own, so
// it must look up the view center and one more point, and only again once the
// view shows a different area.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { getBasemapLabelContext } from './gevActions.js';
import {
hudViewportSamples,
placeLookupView,
placeLookupViewChanged,
} from '../hudPlaceLookup.js';
globalThis.window = globalThis.window || { setTimeout, clearTimeout };
/**
* A looking-down camera `heightM` above the ellipsoid. Ellipsoid picks map
* screen pixels linearly to degrees; the view target sits on terrain
* `groundM` high under the camera.
*/
function createViewer({ latitude, longitude, heightM, groundM = 0 }) {
const state = { latitude, longitude, heightM, groundM };
const canvas = { clientWidth: 1200, clientHeight: 800 };
const degreesPerPixel = () => state.heightM / 111_000 / canvas.clientHeight;
const camera = {
heading: 0,
pitch: -Math.PI / 2,
frustum: { fov: Math.PI / 3, aspectRatio: 1.5 },
pickEllipsoid(point) {
return Cesium.Cartesian3.fromDegrees(
state.longitude +
(point.x - canvas.clientWidth / 2) * degreesPerPixel(),
state.latitude -
(point.y - canvas.clientHeight / 2) * degreesPerPixel(),
);
},
};
Object.defineProperties(camera, {
positionCartographic: {
get: () =>
Cesium.Cartographic.fromDegrees(
state.longitude,
state.latitude,
state.heightM,
),
},
positionWC: {
get: () =>
Cesium.Cartesian3.fromDegrees(
state.longitude,
state.latitude,
state.heightM,
),
},
});
const scene = {
canvas,
pickPositionSupported: true,
pickPosition: () =>
Cesium.Cartesian3.fromDegrees(
state.longitude,
state.latitude,
state.groundM,
),
};
return { viewer: { camera, scene }, state, degreesPerPixel };
}
/** Answers every lookup with a label naming the point it was asked about. */
function createService() {
const calls = { reverse: 0, nearby: 0 };
return {
calls,
async reverseGeocode(latitude, longitude) {
calls.reverse += 1;
const point = `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
return { formattedAddress: `Place ${point}`, labels: [`Area ${point}`] };
},
async nearby() {
calls.nearby += 1;
return [{ name: `Nearby ${calls.nearby}` }];
},
};
}
test('a view counts as new after a quarter-width move or a doubled distance', () => {
const view = placeLookupView({ latitude: 30, longitude: -97, rangeM: 1000 });
// A 60° field of view at 1 km shows about 1155 m across.
assert.ok(Math.abs(view.spanM - 1154.7) < 1);
const near = placeLookupView({
latitude: 30.002,
longitude: -97,
rangeM: 1100,
});
assert.equal(
placeLookupViewChanged(view, near),
false,
'~220 m is under a quarter width',
);
const far = placeLookupView({
latitude: 30.003,
longitude: -97,
rangeM: 1000,
});
assert.equal(
placeLookupViewChanged(view, far),
true,
'~330 m is over a quarter width',
);
const closer = placeLookupView({ latitude: 30, longitude: -97, rangeM: 500 });
assert.equal(placeLookupViewChanged(view, closer), true);
const farther = placeLookupView({
latitude: 30,
longitude: -97,
rangeM: 2000,
});
assert.equal(placeLookupViewChanged(view, farther), true);
assert.equal(placeLookupViewChanged(null, view), true);
// The antimeridian is a short hop, not a world away.
const west = placeLookupView({
latitude: 0,
longitude: 179.9999,
rangeM: 1000,
});
const east = placeLookupView({
latitude: 0,
longitude: -179.9999,
rangeM: 1000,
});
assert.equal(placeLookupViewChanged(west, east), false);
});
test('a portrait screen measures its narrower width', () => {
const landscape = placeLookupView({
latitude: 0,
longitude: 0,
rangeM: 1000,
aspectRatio: 1.5,
});
const portrait = placeLookupView({
latitude: 0,
longitude: 0,
rangeM: 1000,
aspectRatio: 0.5,
});
assert.ok(Math.abs(landscape.spanM - 1154.7) < 1);
assert.ok(Math.abs(portrait.spanM - 577.4) < 1);
});
test('a view in another height tier is new however close it is', () => {
const a = placeLookupView({
latitude: 0,
longitude: 0,
rangeM: 1000,
tier: 'x',
});
const b = placeLookupView({
latitude: 0,
longitude: 0,
rangeM: 1000,
tier: 'y',
});
assert.equal(placeLookupViewChanged(a, b), true);
});
test('the HUD geocodes one viewport point besides the center', () => {
const samples = [{ i: 0 }, { i: 1 }, { i: 2 }];
assert.deepEqual(hudViewportSamples(samples, true), [{ i: 1 }]);
assert.deepEqual(hudViewportSamples(samples, false), [{ i: 0 }, { i: 1 }]);
assert.deepEqual(hudViewportSamples([], true), []);
});
test('a regional view spends two reverse geocodes and a nearby search', async () => {
const { viewer } = createViewer({
latitude: 30.26,
longitude: -97.74,
heightM: 20_000,
});
const service = createService();
const labels = await getBasemapLabelContext(viewer, service);
// Before: the center plus three viewport samples (the first of them the
// center again, which shares its cache key in this stub).
assert.deepEqual(service.calls, { reverse: 2, nearby: 1 });
assert.ok(labels.placeLabels.length > 0);
});
test('a small pan reuses the last lookup; a real move looks up again', async () => {
const { viewer, state } = createViewer({
latitude: 30.26,
longitude: -97.74,
heightM: 600,
});
const service = createService();
const first = await getBasemapLabelContext(viewer, service);
assert.deepEqual(service.calls, { reverse: 1, nearby: 1 });
state.latitude += 0.0005; // ~55 m at 600 m height
const panned = await getBasemapLabelContext(viewer, service, {
previous: first,
});
assert.deepEqual(
panned.placeLabels,
first.placeLabels,
'the same area keeps its labels',
);
assert.deepEqual(service.calls, { reverse: 1, nearby: 1 });
state.latitude += 0.002; // ~280 m from the first lookup
const moved = await getBasemapLabelContext(viewer, service, {
previous: panned,
});
assert.notDeepEqual(moved.placeLabels, first.placeLabels);
assert.deepEqual(service.calls, { reverse: 2, nearby: 2 });
});
test('over high terrain the visible width is measured from the ground', async () => {
// 100 m above a 3000 m plateau shows ~115 m across, not the ~3.6 km a
// 3100 m ellipsoid height would suggest.
const { viewer, state } = createViewer({
latitude: 30.26,
longitude: -97.74,
heightM: 3100,
groundM: 3000,
});
const service = createService();
const first = await getBasemapLabelContext(viewer, service);
state.latitude += 0.0045; // ~500 m
await getBasemapLabelContext(viewer, service, { previous: first });
assert.equal(service.calls.reverse, 2);
});
test('crossing into the Nearby Places height looks up again', async () => {
const { viewer, state } = createViewer({
latitude: 30.26,
longitude: -97.74,
heightM: 30_000,
});
const service = createService();
const high = await getBasemapLabelContext(viewer, service);
assert.equal(service.calls.nearby, 0);
state.heightM = 20_000; // a 1.5× zoom, under the distance rule
const low = await getBasemapLabelContext(viewer, service, { previous: high });
assert.equal(service.calls.nearby, 1);
assert.ok(low.nearbyPlaceLabels.length > 0);
});
test('a lookup that timed out is completed from the cache, not frozen', async () => {
const { viewer, state } = createViewer({
latitude: 30.26,
longitude: -97.74,
heightM: 600,
});
const service = createService();
let answer;
service.reverseGeocode = () => {
service.calls.reverse += 1;
return new Promise((resolve) => {
answer = resolve;
});
};
// Expire the 1.5 s wait at once.
const { setTimeout: wait } = globalThis.window;
globalThis.window.setTimeout = (fn) => wait(fn, 0);
let first;
try {
first = await getBasemapLabelContext(viewer, service);
} finally {
globalThis.window.setTimeout = wait;
}
assert.deepEqual(first.placeLabels, []);
assert.ok(
first.nearbyPlaceLabels.length > 0,
'the nearby answer arrived in time',
);
answer({ formattedAddress: 'Late place', labels: [] });
await new Promise((resolve) => setImmediate(resolve));
state.latitude += 0.0005;
const next = await getBasemapLabelContext(viewer, service, {
previous: first,
});
assert.deepEqual(next.placeLabels, ['Late place']);
assert.deepEqual(
service.calls,
{ reverse: 1, nearby: 1 },
'the late answer is a cache hit',
);
});
test('an empty answer is asked again rather than held', async () => {
const { viewer, state } = createViewer({
latitude: 30.26,
longitude: -97.74,
heightM: 600,
});
const service = createService();
service.reverseGeocode = async () => {
service.calls.reverse += 1;
return null;
};
const empty = await getBasemapLabelContext(viewer, service);
state.latitude += 0.0005;
await getBasemapLabelContext(viewer, service, { previous: empty });
assert.deepEqual(
service.calls,
{ reverse: 2, nearby: 1 },
'the cached nearby answer is not asked again',
);
});
test('a failed lookup is asked again once the provider recovers', async () => {
const { viewer, state } = createViewer({
latitude: 30.26,
longitude: -97.74,
heightM: 600,
});
const service = createService();
const working = service.reverseGeocode;
service.reverseGeocode = async () => {
service.calls.reverse += 1;
throw new Error('503');
};
const failed = await getBasemapLabelContext(viewer, service);
assert.deepEqual(failed.placeLabels, []);
service.reverseGeocode = working;
state.latitude += 0.0005;
const recovered = await getBasemapLabelContext(viewer, service, {
previous: failed,
});
assert.ok(recovered.placeLabels.length > 0);
assert.deepEqual(
service.calls,
{ reverse: 2, nearby: 1 },
'only the failed geocode is asked again',
);
});
test('cached-only lookups read every cached viewport sample and never request', async () => {
const { viewer, state, degreesPerPixel } = createViewer({
latitude: 30.26,
longitude: -97.74,
heightM: 20_000,
});
const service = createService();
// Geocode view A's upper-left sample (screen x = 300).
const a = await getBasemapLabelContext(viewer, service);
const cachedLabel = a.placeLabels.find(
(label) => label.startsWith('Area') && label !== 'Area 30.2600,-97.7400',
);
assert.ok(cachedLabel, 'the upper-left sample was geocoded');
// View B puts that same point at its upper-right sample (screen x = 900),
// which only a read of every sample finds.
state.longitude -= 600 * degreesPerPixel();
const before = { ...service.calls };
const b = await getBasemapLabelContext(viewer, service, { cachedOnly: true });
assert.ok(b.placeLabels.includes(cachedLabel));
assert.deepEqual(service.calls, before);
});