From 7981a179ea28d079d5b0585a8c5afb3f565ed60b Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:53:30 -0700 Subject: [PATCH 1/6] fix(traffic): the traffic layer reads STALE, not LIVE, on cached flow tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the TomTom tile budget is spent, or TomTom fails, the proxy serves cached tiles marked STALE-BUDGET / STALE-ERROR. flowSource works out `stale`, but the layer fetched flow through fetchFlowForBounds, which returns only the segments, so getStats() and the sync chip still read "LIVE · Roads: …". #1239 fixed the get_traffic_flow tool the same way; the layer now uses fetchFlowDetail too, reports stale and oldestTileAt in its stats, and the chip reads "STALE · Roads: · Flow: cached TomTom tiles". A source without fetchFlowDetail keeps the previous behaviour. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 ++- src/i18n/locales/es.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/layers/traffic/controls.js | 15 +++++++ src/layers/traffic/flow.js | 13 +++++- src/layers/traffic/lifecycle.js | 4 ++ src/layers/traffic/model.js | 12 ++++++ src/layers/traffic/roadModes.test.mjs | 58 +++++++++++++++++++++++++++ src/layers/traffic/state.js | 10 +++++ src/layers/traffic/viewport.js | 3 ++ 11 files changed, 120 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3c53c93a2..777880ce1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5651,7 +5651,10 @@ 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, and the sync chip reads + `STALE · Roads: · 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 diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 098c82f55..901b023f8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -857,6 +857,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.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8d12ba43e..21f0c5c98 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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}%", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e727b8516..baa8c4d9d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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}%", diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index d33e5a076..01af66633 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -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 || diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 7135fd8c6..9b37178ba 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -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; diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index fc13e205d..1c53e3ebe 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -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; diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js index 291c06c90..ff1c89ddb 100644 --- a/src/layers/traffic/model.js +++ b/src/layers/traffic/model.js @@ -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 { diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index a7556e432..1d1cd4986 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -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({ diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 595bc2a0d..ecd34c9ee 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -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". diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js index f147f5288..077199855 100644 --- a/src/layers/traffic/viewport.js +++ b/src/layers/traffic/viewport.js @@ -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; From ae4b382262624c30d8f6b917eca6ade6d0ec7cb7 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:06:10 -0700 Subject: [PATCH 2/6] docs(changelog): note #1405 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1405-traffic-layer-stale.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1405-traffic-layer-stale.md diff --git a/changelog.d/1405-traffic-layer-stale.md b/changelog.d/1405-traffic-layer-stale.md new file mode 100644 index 000000000..b933e7c9b --- /dev/null +++ b/changelog.d/1405-traffic-layer-stale.md @@ -0,0 +1,2 @@ +- The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves + cached flow tiles (budget spent or TomTom failing) (quanticsoul4772, #1405). From 4c1ebfb87733359aa344c1a0e92f8a1086a5b56f Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:42:49 -0700 Subject: [PATCH 3/6] fix(traffic): pass fetchFlowDetail through the layer's source slot Co-Authored-By: Claude Opus 5.5 --- src/data/traffic.js | 9 +++ src/data/traffic.test.mjs | 124 ++++++++++++++++++++++++++++++++------ 2 files changed, 114 insertions(+), 19 deletions(-) diff --git a/src/data/traffic.js b/src/data/traffic.js index dbab8cb8f..ad77d92a9 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -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, }); diff --git a/src/data/traffic.test.mjs b/src/data/traffic.test.mjs index 04739e200..23924448a 100644 --- a/src/data/traffic.test.mjs +++ b/src/data/traffic.test.mjs @@ -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(); + } +}); From 69c5b32598232885c0fab751ed0912d707deb601 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:10:18 +0000 Subject: [PATCH 4/6] fix(traffic): date the STALE row by the oldest cached tile; add zh-Hant string --- changelog.d/1405-traffic-layer-stale.md | 3 ++- docs/CURRENT-STATE.md | 3 ++- src/i18n/locales/zh-Hant.json | 1 + src/ui/layerPanel.js | 10 ++++++++-- src/ui/layerPanel.test.mjs | 21 +++++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/changelog.d/1405-traffic-layer-stale.md b/changelog.d/1405-traffic-layer-stale.md index b933e7c9b..dc564ac76 100644 --- a/changelog.d/1405-traffic-layer-stale.md +++ b/changelog.d/1405-traffic-layer-stale.md @@ -1,2 +1,3 @@ - The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves - cached flow tiles (budget spent or TomTom failing) (quanticsoul4772, #1405). + cached flow tiles (budget spent or TomTom failing), and the row dates them by + the oldest cached tile (quanticsoul4772, #1405). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a5a1886c8..cfb5b85b7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5760,7 +5760,8 @@ easier to meet (detection is now on more often), but does not create it. shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`, 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, and the sync chip reads + and `oldestTileAt`, the row reads STALE with the oldest tile's age, and the + sync chip reads `STALE · Roads: · 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: diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index d95a18e39..f286da19e 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -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}%", diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 18b71dabf..24bcad6e6 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -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()) { diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index b4b771e1a..bd90f0777 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -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'); From c1f60cb7e74026e9b87a410b17a72c65a27fcddb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 28 Oct 2026 14:59:33 -0700 Subject: [PATCH 5/6] Cut the Google requests the HUD summary makes on its own The HUD builds place context for its AI summary whenever the camera settles, and each build paid for up to four reverse geocodes plus a Nearby Places search, even when no OpenAI key was configured to use them. - Ask GET /api/openai/hud-summary/status once; with no summary configured, or after an unconfigured reply, make no place lookups for the page. - Reverse-geocode at most two points: the view center and one viewport point. - Reuse a lookup until the view center moves a quarter of the visible width, the camera's distance to it doubles or halves, or the camera crosses a lookup height. The reused points are asked again, so only a timeout, failure or empty answer spends. - Repaint the last AI reply when a small pan leaves the context unchanged. Cached-only hosts and voice context lookups are unchanged. --- changelog.d/0000-hud-place-lookups.md | 5 + docs/CURRENT-STATE.md | 18 ++ scripts/format-scope.json | 2 + scripts/package-boundaries.json | 1 + server/providers/openai/hud-summary.js | 13 + src/hud.js | 58 +++- src/hudPlaceLookup.js | 80 ++++++ src/hudSummaryGate.test.mjs | 187 +++++++++++++ src/services/requests.js | 18 ++ src/services/requests.test.mjs | 21 ++ src/tooling/localServices.test.mjs | 23 ++ src/voice/gevActions.js | 85 +++++- src/voice/hudPlaceLabels.test.mjs | 359 +++++++++++++++++++++++++ 13 files changed, 855 insertions(+), 15 deletions(-) create mode 100644 changelog.d/0000-hud-place-lookups.md create mode 100644 src/hudPlaceLookup.js create mode 100644 src/hudSummaryGate.test.mjs create mode 100644 src/voice/hudPlaceLabels.test.mjs diff --git a/changelog.d/0000-hud-place-lookups.md b/changelog.d/0000-hud-place-lookups.md new file mode 100644 index 000000000..b0ff71f09 --- /dev/null +++ b/changelog.d/0000-hud-place-lookups.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3e7f333a1..553bb218e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ee23b8b6a..5947f8811 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..4b63663ce 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index bdb94ac58..9033c7cb3 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -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'); diff --git a/src/hud.js b/src/hud.js index 792fd30d9..011b53691 100644 --- a/src/hud.js +++ b/src/hud.js @@ -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?.() diff --git a/src/hudPlaceLookup.js b/src/hudPlaceLookup.js new file mode 100644 index 000000000..8890b76d1 --- /dev/null +++ b/src/hudPlaceLookup.js @@ -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); +} diff --git a/src/hudSummaryGate.test.mjs b/src/hudSummaryGate.test.mjs new file mode 100644 index 000000000..b56141505 --- /dev/null +++ b/src/hudSummaryGate.test.mjs @@ -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'); +}); diff --git a/src/services/requests.js b/src/services/requests.js index 3e3a819eb..f264eccc8 100644 --- a/src/services/requests.js +++ b/src/services/requests.js @@ -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', diff --git a/src/services/requests.test.mjs b/src/services/requests.test.mjs index 2cbc464cd..88900e126 100644 --- a/src/services/requests.test.mjs +++ b/src/services/requests.test.mjs @@ -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']); + } +}); diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 075f8c837..f0b4dd5c6 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -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); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 8e5466b7b..0a43f02f1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -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, diff --git a/src/voice/hudPlaceLabels.test.mjs b/src/voice/hudPlaceLabels.test.mjs new file mode 100644 index 000000000..df2e011ab --- /dev/null +++ b/src/voice/hudPlaceLabels.test.mjs @@ -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); +}); From d1e0ff4e06652dd941d69258d4d734315444023f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 28 Oct 2026 17:09:50 -0700 Subject: [PATCH 6/6] Name the HUD lookups changelog entry after its pull request --- .../{0000-hud-place-lookups.md => 1459-hud-place-lookups.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-hud-place-lookups.md => 1459-hud-place-lookups.md} (100%) diff --git a/changelog.d/0000-hud-place-lookups.md b/changelog.d/1459-hud-place-lookups.md similarity index 100% rename from changelog.d/0000-hud-place-lookups.md rename to changelog.d/1459-hud-place-lookups.md