Merge branch 'main' into upstream/terrain-partial-heights

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
quanticsoul4772andClaude Opus 5.5 committed 2026-10-28 19:58:22 -07:00
commit 692e99729c
29 files changed
+1123 -39

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
- The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves
cached flow tiles (budget spent or TomTom failing), and the row dates them by
the oldest cached tile (quanticsoul4772, #1405).
+5
View File
@@ -0,0 +1,5 @@
- Cut the Google requests the HUD's AI summary makes on its own. It no longer
looks up place names when no OpenAI key is configured, reverse-geocodes at
most two points instead of up to four, and looks up again only once the view
shows a different area, not after every small pan, so a small pan no longer
asks OpenAI for a new summary either (samehkhamis, #0000).
+23 -1
View File
@@ -4,6 +4,24 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so
containers can read it on enforcing SELinux hosts. The separate image-owned
`node_modules` volume and loopback-only port publishing remain unchanged.
The HUD's AI summary line looks up place names for the view only when the
summary can use them. The page asks `GET /api/openai/hud-summary/status` once;
a server without an OpenAI key answers `{ configured: false }`, and the HUD then
makes no reverse-geocode or Nearby Places request until the page reloads. A
server without the route is asked once, and an unconfigured summary reply
stops the lookups the same way. Each lookup reverse-geocodes at most two
points: the view center alone below 10 km, the center and one viewport point
up to 750 km, and two viewport points up to 3000 km; Nearby Places is still
added below 25 km. A lookup is reused until the view center moves a quarter of
the visible width, the camera's distance to the center doubles or halves, or
the camera crosses one of those heights. The width is measured from the
camera's distance to the view center, so terrain and tilt scale it. A reused
lookup asks again only for what it did not get (a timeout, a failure, an empty
answer); everything else is a cache hit. A small pan over a reused lookup
repaints the last AI reply instead of asking for a new one. A host passing
`cachedOnly` keeps reading every cached sample. Voice context lookups are
unchanged.
The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to
hundredths of a second, and then splits it. A value within 0.005" of the next
whole minute carries into the minutes and degrees, so `-122.35` reads
@@ -5788,7 +5806,11 @@ easier to meet (detection is now on more often), but does not create it.
coverage number. `stats.loading` covers outstanding flow work as well as the
road fetch, so a flow failure landing after the first paint still ends the
shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`,
never on `mode === 'live'` alone.
never on `mode === 'live'` alone. When the drawn flow came from the proxy's
cached STALE-BUDGET / STALE-ERROR tiles, `getStats()` reports `stale: true`
and `oldestTileAt`, the row reads STALE with the oldest tile's age, and the
sync chip reads
`STALE · Roads: <source> · Flow: cached TomTom tiles` instead of LIVE.
- Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY`
is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode:
TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy
+2
View File
@@ -1102,6 +1102,8 @@
"src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs",
"src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
+1
View File
@@ -1953,6 +1953,7 @@
"src/hud.js",
"src/hudLocality.js",
"src/hudLocalityFormat.js",
"src/hudPlaceLookup.js",
"src/hudSummaryResponse.js",
"src/keySetup.js",
"src/keySetupCore.mjs",
+13
View File
@@ -29,6 +29,19 @@ function toFiveWordHudSummary(value) {
}
async function handleHudSummary(req, res) {
// Capability probe: lets the HUD skip its paid place lookups when there is
// no summary to feed them to.
if (req.method === 'GET' && req.url?.split('?')[0] === '/status') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(
JSON.stringify({
configured: !keylessHudSummaryResponse(process.env.OPENAI_API_KEY),
}),
);
return;
}
if (req.method !== 'POST') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json');
+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();
}
});
+47 -11
View File
@@ -203,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..."
@@ -829,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
@@ -851,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;
@@ -858,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(() => {
@@ -873,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.
@@ -918,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?.()
+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}%",
+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;
+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() {
@@ -188,6 +189,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
@@ -111,3 +111,24 @@ test('a failed terrain request carries the real heights the server had', async (
return true;
});
});
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']);
}
});
+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);
});