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.
This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-25 09:18:22 -07:00
1 parent 5b6c52437e
commit 496d024431
4 files changed
+382 -13

No files matched your search

+4
View File
@@ -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).
+1
View File
@@ -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). - 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. - 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. - 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 ### Place-search providers
+42 -5
View File
@@ -224,6 +224,20 @@ export class IntelHUD {
// session, so this flips once — and both altitude readouts have to move // session, so this flips once — and both altitude readouts have to move
// together when it does (see the repaint in _updateCameraData). // together when it does (see the repaint in _updateCameraData).
this._geoidCorrectionApplied = false; 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._onCameraMoveEnd = () => {
this._markSummaryDirty(); this._markSummaryDirty();
// The 250 ms telemetry timer and 15 s semantic-summary timer must not // 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._passNum = 100 + Math.floor(Math.random() * 200);
this._buildDOM(); this._buildDOM();
this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart);
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
this._startTimers(); this._startTimers();
} }
@@ -766,6 +781,12 @@ export class IntelHUD {
return provenance ? `${line} | ${provenance}` : line; 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 * Animate the summary text into the DOM using a typewriter effect
* (2 characters every 24ms). * (2 characters every 24ms).
@@ -816,8 +837,10 @@ export class IntelHUD {
} catch (error) { } catch (error) {
console.warn('[HUD] summary context unavailable:', error); console.warn('[HUD] summary context unavailable:', error);
// Left dirty on purpose: the next periodic tick retries instead of // Left dirty on purpose: the next periodic tick retries instead of
// sticking on the fallback line for the rest of the session. // sticking on the fallback line for the rest of the session. The
this._setSummaryText(fallbackText, animate); // fallback was composed for this view; a moved camera keeps its own.
if (revision === this._summaryRevision)
this._setSummaryText(fallbackText, animate);
return; return;
} }
if (revision !== this._summaryRevision) return; if (revision !== this._summaryRevision) return;
@@ -832,7 +855,11 @@ export class IntelHUD {
this._summaryDirty = false; this._summaryDirty = false;
this._lastSummarySignature = signature; this._lastSummarySignature = signature;
const controller = new AbortController(); 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; this._summaryRequest = controller;
try { try {
this.summaryPolicy.onRequest?.(); this.summaryPolicy.onRequest?.();
@@ -855,17 +882,24 @@ export class IntelHUD {
animate, animate,
); );
} catch (error) { } 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); console.warn('[HUD] AI summary unavailable:', error);
// Invalidate the committed signature so the next periodic tick // Invalidate the committed signature so the next periodic tick
// retries instead of sticking on the fallback line forever. // retries instead of sticking on the fallback line forever.
this._lastSummarySignature = null; this._lastSummarySignature = null;
this._summaryDirty = true; this._summaryDirty = true;
} }
this._setSummaryText(fallbackText, animate); if (!moved) this._setSummaryText(fallbackText, animate);
} finally { } finally {
window.clearTimeout(timeout); window.clearTimeout(timeout);
if (this._summaryRequest === controller) this._summaryRequest = null; 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); this._typeSummary(text);
return; return;
} }
// A plain paint replaces any reply still typing.
this._stopSummaryTyping();
const el = document.getElementById('hud-summary'); const el = document.getElementById('hud-summary');
if (el) el.textContent = text; if (el) el.textContent = text;
} }
@@ -1044,6 +1080,7 @@ export class IntelHUD {
clearInterval(this._timestampInterval); clearInterval(this._timestampInterval);
clearInterval(this._summaryInterval); clearInterval(this._summaryInterval);
clearInterval(this._summaryTypingInterval); clearInterval(this._summaryTypingInterval);
this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart);
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd); this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe?.();
this._summaryRequest?.abort(); this._summaryRequest?.abort();
+335 -8
View File
@@ -16,6 +16,9 @@
// globals exist: Cesium's widget bundle probes for a real `document` at module // 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 → // scope and a partial stub sends it down the browser path. Hence hook →
// import → install DOM, in that order. // 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 { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -33,7 +36,8 @@ registerHooks({
return { return {
format: 'module', format: 'module',
shortCircuit: true, 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); return next(url, context);
@@ -59,10 +63,20 @@ const SFO_ELLIPSOIDAL_M = -15;
*/ */
function installHudEnvironment() { function installHudEnvironment() {
const elements = new Map( 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 previousDocument = globalThis.document;
const cameraListeners = {};
globalThis.document = { globalThis.document = {
getElementById: (id) => elements.get(id) ?? null, getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [], querySelectorAll: () => [],
@@ -78,12 +92,24 @@ function installHudEnvironment() {
height: SFO_ELLIPSOIDAL_M, height: SFO_ELLIPSOIDAL_M,
}, },
computeViewRectangle: () => undefined, computeViewRectangle: () => undefined,
moveEnd: { addEventListener() {}, removeEventListener() {} }, moveStart: {
addEventListener(listener) {
cameraListeners.moveStart = listener;
},
removeEventListener() {},
},
moveEnd: {
addEventListener(listener) {
cameraListeners.moveEnd = listener;
},
removeEventListener() {},
},
}, },
}; };
return { return {
elements, elements,
viewer, viewer,
cameraListeners,
restore() { restore() {
if (previousDocument === undefined) delete globalThis.document; if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument; else globalThis.document = previousDocument;
@@ -93,7 +119,9 @@ function installHudEnvironment() {
test('hud.js corrects the camera height to MSL through the geoid module', () => { test('hud.js corrects the camera height to MSL through the geoid module', () => {
assert.equal( assert.equal(
has(/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s), has(
/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s,
),
true, true,
'hud.js must take the datum correction from ./data/geoid.js, not re-derive one', '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', 'the ~2.7 MB grid must be requested once, on demand — not at HUD construction',
); );
assert.equal( 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, true,
'a failed geoid load must leave the readout uncorrected, not unhandled', '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 // 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. // "ALT: 17m" in the other has found a bug, not a distinction.
assert.equal( 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, true,
'the summary altitude tag must prefer the MSL datum and fall back to the raw height', '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. // Tick 1 — cold. This is also the tick that requests the grid.
hud._updateCameraData(); hud._updateCameraData();
assert.match(alt(), /^ALT: -15m/, `cold corner readout, got ${alt()}`); 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 // The HUD registered its own continuation on this same shared promise
// during tick 1, and it registered first, so awaiting here means its // 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(); 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();
}
});