mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Cut the Google requests the HUD summary makes on its own
The HUD builds place context for its AI summary whenever the camera settles, and each build paid for up to four reverse geocodes plus a Nearby Places search, even when no OpenAI key was configured to use them. - Ask GET /api/openai/hud-summary/status once; with no summary configured, or after an unconfigured reply, make no place lookups for the page. - Reverse-geocode at most two points: the view center and one viewport point. - Reuse a lookup until the view center moves a quarter of the visible width, the camera's distance to it doubles or halves, or the camera crosses a lookup height. The reused points are asked again, so only a timeout, failure or empty answer spends. - Repaint the last AI reply when a small pan leaves the context unchanged. Cached-only hosts and voice context lookups are unchanged.
This commit is contained in:
1 parent
e608fee793
commit
c1f60cb7e7
13 files changed
+855
-15
No files matched your search
+81
-4
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user