From 496d024431b95bb5bbddd5dac17b00fd01ad1409 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:18:22 -0700 Subject: [PATCH] fix(hud): drop a summary reply once the camera starts moving (#1384) Discard stale HUD summaries when camera movement starts and allow cancelled requests to retry for unchanged place context. Contribution by @quanticsoul4772. --- changelog.d/1384-hud-summary-moving.md | 4 + docs/CURRENT-STATE.md | 1 + src/hud.js | 47 +++- src/hudAltitudeDatum.test.mjs | 343 ++++++++++++++++++++++++- 4 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 changelog.d/1384-hud-summary-moving.md diff --git a/changelog.d/1384-hud-summary-moving.md b/changelog.d/1384-hud-summary-moving.md new file mode 100644 index 000000000..21965af96 --- /dev/null +++ b/changelog.d/1384-hud-summary-moving.md @@ -0,0 +1,4 @@ +- The HUD's AI place summary drops a reply that arrives after the camera has + started moving, so a summary asked for before a flight no longer names the + old place mid-flight. Cancelled or partially typed summaries retry even if the place + context stays the same (quanticsoul4772, #1384). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eceb4c239..9afb99c45 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5107,6 +5107,7 @@ and unreachable upstream (502/504) separately from road geometry. - HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning). - Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. - Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable. ### Place-search providers diff --git a/src/hud.js b/src/hud.js index 8190f4b1b..cf7983da7 100644 --- a/src/hud.js +++ b/src/hud.js @@ -224,6 +224,20 @@ export class IntelHUD { // session, so this flips once — and both altitude readouts have to move // together when it does (see the repaint in _updateCameraData). this._geoidCorrectionApplied = false; + // A summary built for one view must not land on the next: a request in + // flight when the camera starts moving is aborted and nothing it ends + // with paints (the revision moves), a reply still typing stops, and the + // summary is marked dirty so the next periodic tick asks about the view + // the camera has moved to. + this._onCameraMoveStart = () => { + this._markSummaryDirty(); + if (this._summaryRequest || this._summaryTypingInterval) { + // Cancelled requests and partially typed replies are not reusable answers. + this._lastSummarySignature = null; + } + this._summaryRequest?.abort(); + this._stopSummaryTyping(); + }; this._onCameraMoveEnd = () => { this._markSummaryDirty(); // The 250 ms telemetry timer and 15 s semantic-summary timer must not @@ -249,6 +263,7 @@ export class IntelHUD { this._passNum = 100 + Math.floor(Math.random() * 200); this._buildDOM(); + this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart); this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); this._startTimers(); } @@ -766,6 +781,12 @@ export class IntelHUD { return provenance ? `${line} | ${provenance}` : line; } + /** Stop a summary still typing out, leaving what it has typed so far. */ + _stopSummaryTyping() { + clearInterval(this._summaryTypingInterval); + this._summaryTypingInterval = null; + } + /** * Animate the summary text into the DOM using a typewriter effect * (2 characters every 24ms). @@ -816,8 +837,10 @@ export class IntelHUD { } catch (error) { console.warn('[HUD] summary context unavailable:', error); // Left dirty on purpose: the next periodic tick retries instead of - // sticking on the fallback line for the rest of the session. - this._setSummaryText(fallbackText, animate); + // sticking on the fallback line for the rest of the session. The + // fallback was composed for this view; a moved camera keeps its own. + if (revision === this._summaryRevision) + this._setSummaryText(fallbackText, animate); return; } if (revision !== this._summaryRevision) return; @@ -832,7 +855,11 @@ export class IntelHUD { this._summaryDirty = false; this._lastSummarySignature = signature; const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), 5000); + let timedOut = false; + const timeout = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, 5000); this._summaryRequest = controller; try { this.summaryPolicy.onRequest?.(); @@ -855,17 +882,24 @@ export class IntelHUD { animate, ); } catch (error) { - if (error?.name !== 'AbortError') { + // The 5 s timeout is a failure like any other; a move's abort (or + // destroy's) is not. + const moved = revision !== this._summaryRevision; + if (error?.name !== 'AbortError' || timedOut) { console.warn('[HUD] AI summary unavailable:', error); // Invalidate the committed signature so the next periodic tick // retries instead of sticking on the fallback line forever. this._lastSummarySignature = null; this._summaryDirty = true; } - this._setSummaryText(fallbackText, animate); + if (!moved) this._setSummaryText(fallbackText, animate); } finally { window.clearTimeout(timeout); if (this._summaryRequest === controller) this._summaryRequest = null; + // A request whose answer was dropped (aborted by a move, or a reply + // that came back after one) committed a signature it never painted; + // forget it, or the next tick over the same place would skip asking. + if (revision !== this._summaryRevision) this._lastSummarySignature = null; } } @@ -874,6 +908,8 @@ export class IntelHUD { this._typeSummary(text); return; } + // A plain paint replaces any reply still typing. + this._stopSummaryTyping(); const el = document.getElementById('hud-summary'); if (el) el.textContent = text; } @@ -1044,6 +1080,7 @@ export class IntelHUD { clearInterval(this._timestampInterval); clearInterval(this._summaryInterval); clearInterval(this._summaryTypingInterval); + this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart); this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd); this._dataManagerUnsubscribe?.(); this._summaryRequest?.abort(); diff --git a/src/hudAltitudeDatum.test.mjs b/src/hudAltitudeDatum.test.mjs index eeb407da4..ca14569e5 100644 --- a/src/hudAltitudeDatum.test.mjs +++ b/src/hudAltitudeDatum.test.mjs @@ -16,6 +16,9 @@ // globals exist: Cesium's widget bundle probes for a real `document` at module // scope and a partial stub sends it down the browser path. Hence hook → // import → install DOM, in that order. +// +// The same hook is why the live AI-summary test lives here too: this is the +// one file that can construct a real IntelHUD. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -33,7 +36,8 @@ registerHooks({ return { format: 'module', shortCircuit: true, - source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', }; } return next(url, context); @@ -59,10 +63,20 @@ const SFO_ELLIPSOIDAL_M = -15; */ function installHudEnvironment() { 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: '' }]), + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), ); const previousDocument = globalThis.document; + const cameraListeners = {}; globalThis.document = { getElementById: (id) => elements.get(id) ?? null, querySelectorAll: () => [], @@ -78,12 +92,24 @@ function installHudEnvironment() { height: SFO_ELLIPSOIDAL_M, }, computeViewRectangle: () => undefined, - moveEnd: { addEventListener() {}, removeEventListener() {} }, + moveStart: { + addEventListener(listener) { + cameraListeners.moveStart = listener; + }, + removeEventListener() {}, + }, + moveEnd: { + addEventListener(listener) { + cameraListeners.moveEnd = listener; + }, + removeEventListener() {}, + }, }, }; return { elements, viewer, + cameraListeners, restore() { if (previousDocument === undefined) delete globalThis.document; else globalThis.document = previousDocument; @@ -93,7 +119,9 @@ function installHudEnvironment() { test('hud.js corrects the camera height to MSL through the geoid module', () => { assert.equal( - has(/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s), + has( + /import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s, + ), true, 'hud.js must take the datum correction from ./data/geoid.js, not re-derive one', ); @@ -108,7 +136,9 @@ test('hud.js corrects the camera height to MSL through the geoid module', () => 'the ~2.7 MB grid must be requested once, on demand — not at HUD construction', ); assert.equal( - has(/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/), + has( + /\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/, + ), true, 'a failed geoid load must leave the readout uncorrected, not unhandled', ); @@ -141,7 +171,9 @@ test('the summary ALT tag agrees with the corner readout', () => { // Both are on screen together; a viewer reading "ALT -15M" in one corner and // "ALT: 17m" in the other has found a bug, not a distinction. assert.equal( - has(/const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/), + has( + /const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/, + ), true, 'the summary altitude tag must prefer the MSL datum and fall back to the raw height', ); @@ -188,7 +220,11 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in // Tick 1 — cold. This is also the tick that requests the grid. hud._updateCameraData(); assert.match(alt(), /^ALT: -15m/, `cold corner readout, got ${alt()}`); - assert.match(summary(), /\| ALT -15M \|/, `cold summary tag, got ${summary()}`); + assert.match( + summary(), + /\| ALT -15M \|/, + `cold summary tag, got ${summary()}`, + ); // The HUD registered its own continuation on this same shared promise // during tick 1, and it registered first, so awaiting here means its @@ -248,3 +284,294 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig env.restore(); } }); + +test('a summary asked for one view is dropped once the camera moves', async () => { + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + const summaryService = { + summarize: (context) => + new Promise((resolve) => { + pending.push({ + context, + reply: (summary) => + resolve({ ok: true, status: 200, data: { summary, error: null } }), + }); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + let place = 'Yellowstone'; + hud._summaryContext = async () => ({ place }); + const summary = () => env.elements.get('hud-summary').textContent; + const settle = () => new Promise((resolve) => setImmediate(resolve)); + hud._updateCameraData(); + + // The periodic tick's call (force=false): it asks only for a dirty + // summary. Asked over Yellowstone; the camera leaves before the reply + // lands. + const first = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 1); + env.cameraListeners.moveStart(); + pending[0].reply('Canyon Junction Wyoming'); + await first; + assert.doesNotMatch( + summary(), + /Canyon Junction/, + 'a reply for the old view is dropped', + ); + + // The next tick, with the camera still moving (a follow camera or an + // orbit emits no moveEnd), asks about where it is now and paints it. + place = 'Atlantic'; + const moving = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 2, 'the move marked the summary dirty'); + assert.deepEqual(pending[1].context, { place: 'Atlantic' }); + pending[1].reply('North Atlantic Ocean'); + await moving; + assert.equal(summary(), 'North Atlantic Ocean'); + } finally { + for (const request of pending) request.reply(''); + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('a request the camera leaves paints nothing: aborted, failed, or its context build throws', async () => { + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + // Like fetch: an abort rejects with an AbortError, unless `ignoreAbort`. + let ignoreAbort = false; + const summaryService = { + summarize: (context, { signal }) => + new Promise((resolve, reject) => { + pending.push({ signal, reject }); + if (!ignoreAbort) + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + hud._summaryContext = async () => ({ place: 'Yellowstone' }); + const element = env.elements.get('hud-summary'); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + hud._updateCameraData(); + + // The move aborts the request in flight, and the abort paints nothing. + let update = hud._updateSummary(false, true); + await settle(); + element.textContent = 'shown before the move'; + env.cameraListeners.moveStart(); + assert.equal(pending[0].signal.aborted, true, 'the request is aborted'); + await update; + assert.equal(element.textContent, 'shown before the move'); + + // A request that fails after the move (an error, the 5 s timeout) does + // not paint the old view's fallback line either. + ignoreAbort = true; + update = hud._updateSummary(false, true); + await settle(); + element.textContent = 'shown before the move'; + env.cameraListeners.moveStart(); + pending[1].reject(new Error('network')); + await update; + assert.equal(element.textContent, 'shown before the move'); + + // Nor does a context build that throws once the camera has moved. + hud._summaryContext = async () => { + env.cameraListeners.moveStart(); + throw new Error('view target unavailable'); + }; + element.textContent = 'shown before the move'; + await hud._updateSummary(false, true); + assert.equal(element.textContent, 'shown before the move'); + } finally { + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('a cancelled summary retries the same context but completed answers stay reusable', async () => { + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + const summaryService = { + summarize: (context, { signal }) => + new Promise((resolve, reject) => { + pending.push({ + context, + reply: (summary) => + resolve({ ok: true, status: 200, data: { summary } }), + }); + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._updateCameraData(); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + const first = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 1); + env.cameraListeners.moveStart(); + await first; + + // An orbit can leave all semantic place/layer labels unchanged. The next + // periodic tick must retry even though its context equals the aborted one. + const retry = hud._updateSummary(false); + await settle(); + assert.equal( + pending.length, + 2, + 'the cancelled signature cannot suppress a retry', + ); + assert.deepEqual(pending[1].context, pending[0].context); + pending[1].reply('San Francisco Bay Urban Landscape'); + await retry; + assert.equal( + env.elements.get('hud-summary').textContent, + 'San Francisco Bay Urban Landscape', + ); + + // Movement without an active request must not discard a completed answer. + env.cameraListeners.moveStart(); + await hud._updateSummary(false); + assert.equal(pending.length, 2, 'completed context still deduplicates'); + } finally { + for (const request of pending) request.reply(''); + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('after a dropped or timed-out request the next tick asks again, and a move stops typing', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + const summaryService = { + summarize: (context, { signal }) => + new Promise((resolve, reject) => { + pending.push({ resolve }); + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + // The same place before and after: a slow follow camera, a small pan. + hud._summaryContext = async () => ({ place: 'Lisboa' }); + const element = env.elements.get('hud-summary'); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + hud._updateCameraData(); + + // A move aborts the request; the next tick over the same place asks again. + let update = hud._updateSummary(false); + await settle(); + env.cameraListeners.moveStart(); + await update; + update = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 2, 'asked again after the abort'); + + // That one times out at 5 s: retried like any failure. + t.mock.timers.tick(5_000); + await update; + update = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 3, 'asked again after the timeout'); + pending[2].resolve({ + ok: true, + status: 200, + data: { summary: 'Lisboa Rua Augusta', error: null }, + }); + await update; + + // A reply still typing stops when the camera starts moving. + hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park'); + t.mock.timers.tick(48); + const typed = element.textContent; + env.cameraListeners.moveStart(); + t.mock.timers.tick(2_000); + assert.equal( + element.textContent, + typed, + 'no more characters after the move', + ); + } finally { + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('a summary interrupted while typing can retry unchanged context', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + let calls = 0; + const text = 'San Francisco Bay Urban Landscape'; + const summaryService = { + summarize: async () => { + calls++; + return { ok: true, status: 200, data: { summary: text } }; + }, + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._updateCameraData(); + await hud._updateSummary(true); + t.mock.timers.tick(48); + const element = env.elements.get('hud-summary'); + assert.notEqual(element.textContent, text, 'the answer is still typing'); + assert.equal( + hud._summaryRequest, + null, + 'the network request already finished', + ); + env.cameraListeners.moveStart(); + assert.equal(hud._summaryTypingInterval, null); + await hud._updateSummary(false); + assert.equal( + calls, + 2, + 'a partially displayed answer must not suppress retry', + ); + assert.equal(element.textContent, text); + env.cameraListeners.moveStart(); + await hud._updateSummary(false); + assert.equal(calls, 2, 'the fully displayed answer remains reusable'); + } finally { + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +});