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).
- 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
+42 -5
View File
@@ -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();
+335 -8
View File
@@ -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();
}
});