diff --git a/changelog.d/1459-hud-place-lookups.md b/changelog.d/1459-hud-place-lookups.md new file mode 100644 index 000000000..b0ff71f09 --- /dev/null +++ b/changelog.d/1459-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 6546ea023..09ae87f1a 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); +});