mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge branch 'main' into upstream/terrain-partial-heights
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
692e99729c
29 files changed
+1123
-39
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
- The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves
|
||||||
|
cached flow tiles (budget spent or TomTom failing), and the row dates them by
|
||||||
|
the oldest cached tile (quanticsoul4772, #1405).
|
||||||
@@ -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).
|
||||||
+23
-1
@@ -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
|
containers can read it on enforcing SELinux hosts. The separate image-owned
|
||||||
`node_modules` volume and loopback-only port publishing remain unchanged.
|
`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
|
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
|
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
|
whole minute carries into the minutes and degrees, so `-122.35` reads
|
||||||
@@ -5788,7 +5806,11 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
coverage number. `stats.loading` covers outstanding flow work as well as the
|
coverage number. `stats.loading` covers outstanding flow work as well as the
|
||||||
road fetch, so a flow failure landing after the first paint still ends the
|
road fetch, so a flow failure landing after the first paint still ends the
|
||||||
shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`,
|
shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`,
|
||||||
never on `mode === 'live'` alone.
|
never on `mode === 'live'` alone. When the drawn flow came from the proxy's
|
||||||
|
cached STALE-BUDGET / STALE-ERROR tiles, `getStats()` reports `stale: true`
|
||||||
|
and `oldestTileAt`, the row reads STALE with the oldest tile's age, and the
|
||||||
|
sync chip reads
|
||||||
|
`STALE · Roads: <source> · Flow: cached TomTom tiles` instead of LIVE.
|
||||||
- Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY`
|
- Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY`
|
||||||
is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode:
|
is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode:
|
||||||
TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy
|
TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy
|
||||||
|
|||||||
@@ -1102,6 +1102,8 @@
|
|||||||
"src/cameraGlobeRotation.test.mjs",
|
"src/cameraGlobeRotation.test.mjs",
|
||||||
"src/voice/globeResetConfirmation.test.mjs",
|
"src/voice/globeResetConfirmation.test.mjs",
|
||||||
"src/hudDms.test.mjs",
|
"src/hudDms.test.mjs",
|
||||||
|
"src/hudSummaryGate.test.mjs",
|
||||||
|
"src/voice/hudPlaceLabels.test.mjs",
|
||||||
"src/voice/panel/preferences.test.mjs",
|
"src/voice/panel/preferences.test.mjs",
|
||||||
"src/voice/panel/host.test.mjs",
|
"src/voice/panel/host.test.mjs",
|
||||||
"src/voice/panel/manualPanel.test.mjs",
|
"src/voice/panel/manualPanel.test.mjs",
|
||||||
|
|||||||
@@ -1953,6 +1953,7 @@
|
|||||||
"src/hud.js",
|
"src/hud.js",
|
||||||
"src/hudLocality.js",
|
"src/hudLocality.js",
|
||||||
"src/hudLocalityFormat.js",
|
"src/hudLocalityFormat.js",
|
||||||
|
"src/hudPlaceLookup.js",
|
||||||
"src/hudSummaryResponse.js",
|
"src/hudSummaryResponse.js",
|
||||||
"src/keySetup.js",
|
"src/keySetup.js",
|
||||||
"src/keySetupCore.mjs",
|
"src/keySetupCore.mjs",
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ function toFiveWordHudSummary(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleHudSummary(req, res) {
|
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') {
|
if (req.method !== 'POST') {
|
||||||
res.statusCode = 405;
|
res.statusCode = 405;
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
|||||||
@@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot(
|
|||||||
{
|
{
|
||||||
getFlowSessionStats: () => ({ tilesFetched: 0 }),
|
getFlowSessionStats: () => ({ tilesFetched: 0 }),
|
||||||
resetFlowTileCache: () => {},
|
resetFlowTileCache: () => {},
|
||||||
|
// The layer reads stale cached tiles through this; a source without it
|
||||||
|
// answers with fresh segments only.
|
||||||
|
fetchFlowDetail: async (...args) => ({
|
||||||
|
segments: await sourceSlot.source.fetchFlowForBounds(...args),
|
||||||
|
stale: false,
|
||||||
|
oldestTileAt: null,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
export const configureTrafficSource = sourceSlot.configure;
|
export const configureTrafficSource = sourceSlot.configure;
|
||||||
|
/** The source the traffic layer reads, as configured. */
|
||||||
|
export const trafficSource = sourceSlot.source;
|
||||||
const layer = createApplicationTraffic({
|
const layer = createApplicationTraffic({
|
||||||
source: sourceSlot.source,
|
source: sourceSlot.source,
|
||||||
});
|
});
|
||||||
|
|||||||
+105
-19
@@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js';
|
|||||||
const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/;
|
const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/;
|
||||||
|
|
||||||
test('a superseded flow fetch is not an outage', () => {
|
test('a superseded flow fetch is not an outage', () => {
|
||||||
assert.equal(deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), null);
|
assert.equal(
|
||||||
|
deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
assert.equal(deriveTrafficFlowError(null), null);
|
assert.equal(deriveTrafficFlowError(null), null);
|
||||||
assert.equal(deriveTrafficFlowError(undefined), null);
|
assert.equal(deriveTrafficFlowError(undefined), null);
|
||||||
});
|
});
|
||||||
@@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => {
|
|||||||
test('flow failures map onto short, specific reasons', () => {
|
test('flow failures map onto short, specific reasons', () => {
|
||||||
const reason = (message) => deriveTrafficFlowError(new Error(message));
|
const reason = (message) => deriveTrafficFlowError(new Error(message));
|
||||||
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
|
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
|
||||||
assert.equal(reason('flow tile 12/1/1: HTTP 429'), 'TomTom daily budget reached');
|
assert.equal(
|
||||||
assert.equal(reason('flow tile 12/1/1: HTTP 502'), 'TomTom upstream unreachable');
|
reason('flow tile 12/1/1: HTTP 429'),
|
||||||
assert.equal(reason('flow tile 12/1/1: HTTP 504'), 'TomTom upstream unreachable');
|
'TomTom daily budget reached',
|
||||||
assert.equal(reason('flow tile 12/1/1: HTTP 418'), 'TomTom flow error (HTTP 418)');
|
);
|
||||||
|
assert.equal(
|
||||||
|
reason('flow tile 12/1/1: HTTP 502'),
|
||||||
|
'TomTom upstream unreachable',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
reason('flow tile 12/1/1: HTTP 504'),
|
||||||
|
'TomTom upstream unreachable',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
reason('flow tile 12/1/1: HTTP 418'),
|
||||||
|
'TomTom flow error (HTTP 418)',
|
||||||
|
);
|
||||||
assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable');
|
assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => {
|
|||||||
trafficFeedPresentation({ liveMode: false, fetching: false }),
|
trafficFeedPresentation({ liveMode: false, fetching: false }),
|
||||||
trafficFeedPresentation({ liveMode: false, fetching: true }),
|
trafficFeedPresentation({ liveMode: false, fetching: true }),
|
||||||
trafficFeedPresentation({ statusUnavailable: true }),
|
trafficFeedPresentation({ statusUnavailable: true }),
|
||||||
trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
|
trafficFeedPresentation({
|
||||||
trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }),
|
liveMode: true,
|
||||||
|
flowError: 'TomTom flow unavailable',
|
||||||
|
}),
|
||||||
|
trafficFeedPresentation({
|
||||||
|
liveMode: true,
|
||||||
|
fetching: true,
|
||||||
|
flowError: 'TomTom flow unavailable',
|
||||||
|
}),
|
||||||
].map((feed) => feed.loadingLabel);
|
].map((feed) => feed.loadingLabel);
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`);
|
assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`);
|
||||||
assert.ok(label.startsWith('SIMULATED'), `fallback label must lead with the mode: ${label}`);
|
assert.ok(
|
||||||
|
label.startsWith('SIMULATED'),
|
||||||
|
`fallback label must lead with the mode: ${label}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('simulating because the status probe failed reads differently from keyless by design', () => {
|
test('simulating because the status probe failed reads differently from keyless by design', () => {
|
||||||
const probeDown = trafficFeedPresentation({ statusUnavailable: true });
|
const probeDown = trafficFeedPresentation({ statusUnavailable: true });
|
||||||
assert.equal(probeDown.mode, 'sim');
|
assert.equal(probeDown.mode, 'sim');
|
||||||
assert.equal(probeDown.loadingLabel, 'SIMULATED — traffic service unreachable');
|
assert.equal(
|
||||||
|
probeDown.loadingLabel,
|
||||||
|
'SIMULATED — traffic service unreachable',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a healthy keyed layer reports live flow with its real coverage', () => {
|
test('a healthy keyed layer reports live flow with its real coverage', () => {
|
||||||
@@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => {
|
|||||||
assert.deepEqual(idle, {
|
assert.deepEqual(idle, {
|
||||||
mode: 'live',
|
mode: 'live',
|
||||||
error: null,
|
error: null,
|
||||||
loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
loadingLabel:
|
||||||
|
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel,
|
trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel,
|
||||||
@@ -100,7 +129,11 @@ test('a mid-session flow outage degrades instead of reporting stale live coverag
|
|||||||
fetching: true,
|
fetching: true,
|
||||||
flowError: 'TomTom daily budget reached',
|
flowError: 'TomTom daily budget reached',
|
||||||
});
|
});
|
||||||
assert.deepEqual(busy, down, 'the degraded state reads the same whether or not a load is in flight');
|
assert.deepEqual(
|
||||||
|
busy,
|
||||||
|
down,
|
||||||
|
'the degraded state reads the same whether or not a load is in flight',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||||
@@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
mgr._buildMetaText({
|
mgr._buildMetaText({
|
||||||
source: 'OpenStreetMap',
|
source: 'OpenStreetMap',
|
||||||
stats: stats(trafficFeedPresentation({
|
stats: stats(
|
||||||
liveMode: true,
|
trafficFeedPresentation({
|
||||||
flowError: 'TomTom daily budget reached',
|
liveMode: true,
|
||||||
})),
|
flowError: 'TomTom daily budget reached',
|
||||||
|
}),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached',
|
'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached',
|
||||||
);
|
);
|
||||||
@@ -128,17 +163,26 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
|||||||
test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => {
|
test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => {
|
||||||
const settled = { count: 4200, lastUpdate: Date.now() };
|
const settled = { count: 4200, lastUpdate: Date.now() };
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }),
|
layerFeedState({
|
||||||
|
...settled,
|
||||||
|
...trafficFeedPresentation({ liveMode: false }),
|
||||||
|
}),
|
||||||
'fallback',
|
'fallback',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }),
|
layerFeedState({
|
||||||
|
...settled,
|
||||||
|
...trafficFeedPresentation({ liveMode: true }),
|
||||||
|
}),
|
||||||
'nominal',
|
'nominal',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layerFeedState({
|
layerFeedState({
|
||||||
...settled,
|
...settled,
|
||||||
...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
|
...trafficFeedPresentation({
|
||||||
|
liveMode: true,
|
||||||
|
flowError: 'TomTom flow unavailable',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
'degraded',
|
'degraded',
|
||||||
);
|
);
|
||||||
@@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => {
|
|||||||
const stats = trafficLayer.getStats();
|
const stats = trafficLayer.getStats();
|
||||||
assert.equal(stats.mode, 'sim');
|
assert.equal(stats.mode, 'sim');
|
||||||
assert.equal(stats.error, null);
|
assert.equal(stats.error, null);
|
||||||
assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`);
|
assert.ok(
|
||||||
|
!LIVE_CLAIM.test(stats.loadingLabel),
|
||||||
|
`boot label implies live data: ${stats.loadingLabel}`,
|
||||||
|
);
|
||||||
assert.equal(layerFeedState(stats), 'fallback');
|
assert.equal(layerFeedState(stats), 'fallback');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => {
|
|||||||
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
|
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
|
||||||
assert.ok(!LIVE_CLAIM.test(feed.loadingLabel));
|
assert.ok(!LIVE_CLAIM.test(feed.loadingLabel));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the traffic layer source passes fetchFlowDetail through, so stale tiles reach the layer', async () => {
|
||||||
|
const { trafficSource, configureTrafficSource } =
|
||||||
|
await import('./traffic.js');
|
||||||
|
assert.equal(typeof trafficSource.fetchFlowDetail, 'function');
|
||||||
|
const base = {
|
||||||
|
requestRoads: async () => ({}),
|
||||||
|
getStatus: async () => ({}),
|
||||||
|
fetchFlowForBounds: async () => ['segment'],
|
||||||
|
};
|
||||||
|
let release = configureTrafficSource({
|
||||||
|
...base,
|
||||||
|
fetchFlowDetail: async () => ({
|
||||||
|
segments: ['segment'],
|
||||||
|
stale: true,
|
||||||
|
oldestTileAt: 1000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
|
||||||
|
segments: ['segment'],
|
||||||
|
stale: true,
|
||||||
|
oldestTileAt: 1000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
// A source without fetchFlowDetail answers with fresh segments.
|
||||||
|
release = configureTrafficSource(base);
|
||||||
|
try {
|
||||||
|
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
|
||||||
|
segments: ['segment'],
|
||||||
|
stale: false,
|
||||||
|
oldestTileAt: null,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
});
|
||||||
+47
-11
@@ -203,7 +203,13 @@ export class IntelHUD {
|
|||||||
this._dataManagerUnsubscribe = null;
|
this._dataManagerUnsubscribe = null;
|
||||||
this._summaryDirty = true;
|
this._summaryDirty = true;
|
||||||
this._summaryRequest = null;
|
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 = '';
|
this._lastSummarySignature = '';
|
||||||
|
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
|
||||||
|
this._lastSummaryText = null;
|
||||||
this._summaryRevision = 0;
|
this._summaryRevision = 0;
|
||||||
// One-shot guards so the very first summary lands immediately instead of
|
// One-shot guards so the very first summary lands immediately instead of
|
||||||
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
|
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
|
||||||
@@ -829,6 +835,15 @@ export class IntelHUD {
|
|||||||
if (this.summaryPolicy.canRequest?.() === false) return;
|
if (this.summaryPolicy.canRequest?.() === false) return;
|
||||||
|
|
||||||
const revision = this._summaryRevision;
|
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
|
// Every caller invokes this as `void this._updateSummary(...)`, so nothing
|
||||||
// owns the returned promise — a rejection escaping from here lands as an
|
// owns the returned promise — a rejection escaping from here lands as an
|
||||||
// unhandled rejection in the console. Building the context walks the live
|
// unhandled rejection in the console. Building the context walks the live
|
||||||
@@ -851,6 +866,9 @@ export class IntelHUD {
|
|||||||
const signature = JSON.stringify(context);
|
const signature = JSON.stringify(context);
|
||||||
if (!force && signature === this._lastSummarySignature) {
|
if (!force && signature === this._lastSummarySignature) {
|
||||||
this._summaryDirty = false;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (this._summaryRequest) return;
|
if (this._summaryRequest) return;
|
||||||
@@ -858,6 +876,7 @@ export class IntelHUD {
|
|||||||
if (force) this._setSummaryText(fallbackText, false);
|
if (force) this._setSummaryText(fallbackText, false);
|
||||||
this._summaryDirty = false;
|
this._summaryDirty = false;
|
||||||
this._lastSummarySignature = signature;
|
this._lastSummarySignature = signature;
|
||||||
|
this._lastSummaryText = null;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
@@ -873,18 +892,16 @@ export class IntelHUD {
|
|||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (revision !== this._summaryRevision) return;
|
if (revision !== this._summaryRevision) return;
|
||||||
if (isHudSummaryUnconfigured(response.status, data)) {
|
if (isHudSummaryUnconfigured(response.status, data)) {
|
||||||
|
this._summaryUnconfigured = true;
|
||||||
this._setSummaryText(fallbackText, animate);
|
this._setSummaryText(fallbackText, animate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok || !data?.summary) {
|
if (!response.ok || !data?.summary) {
|
||||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
this._setSummaryText(
|
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
|
||||||
hudSummaryMatchesProvenance(data.summary, context.feedProvenance)
|
this._lastSummaryText = data.summary;
|
||||||
? data.summary
|
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
||||||
: fallbackText,
|
|
||||||
animate,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The 5 s timeout is a failure like any other; a move's abort (or
|
// The 5 s timeout is a failure like any other; a move's abort (or
|
||||||
// destroy's) is not.
|
// destroy's) is not.
|
||||||
@@ -918,12 +935,31 @@ export class IntelHUD {
|
|||||||
if (el) el.textContent = text;
|
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() {
|
async _summaryContext() {
|
||||||
const labels = await getBasemapLabelContext(
|
const revision = this._summaryRevision;
|
||||||
this.viewer,
|
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, {
|
||||||
this.placeSearch,
|
...this.basemapContext,
|
||||||
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 =
|
const enabledLayers =
|
||||||
this._dataManager
|
this._dataManager
|
||||||
?.getAll?.()
|
?.getAll?.()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -864,6 +864,7 @@
|
|||||||
"LIVE · Roads: {source} · No flow roads in view": "EN VIVO · Vías: {source} · Sin vías con flujo a la vista",
|
"LIVE · Roads: {source} · No flow roads in view": "EN VIVO · Vías: {source} · Sin vías con flujo a la vista",
|
||||||
"LIVE · Roads: {source} · Flow {percent}%": "EN VIVO · Vías: {source} · Flujo {percent}%",
|
"LIVE · Roads: {source} · Flow {percent}%": "EN VIVO · Vías: {source} · Flujo {percent}%",
|
||||||
"SIMULATED · Roads: {source} · Flow {percent}%": "SIMULADO · Vías: {source} · Flujo {percent}%",
|
"SIMULATED · Roads: {source} · Flow {percent}%": "SIMULADO · Vías: {source} · Flujo {percent}%",
|
||||||
|
"STALE · Roads: {source} · Flow: cached TomTom tiles": "DESACTUALIZADO · Vías: {source} · Flujo: teselas de TomTom en caché",
|
||||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Sincronizando flujo · Vías: OpenStreetMap · Flujo: TomTom · Sin coincidencia: simulado",
|
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Sincronizando flujo · Vías: OpenStreetMap · Flujo: TomTom · Sin coincidencia: simulado",
|
||||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob. · Sin coincidencia: simulado",
|
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob. · Sin coincidencia: simulado",
|
||||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob.",
|
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob.",
|
||||||
|
|||||||
@@ -254,6 +254,7 @@
|
|||||||
"LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком",
|
"LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком",
|
||||||
"LIVE · Roads: {source} · Flow {percent}%": "ОНЛАЙН · Дороги: {source} · Поток {percent}%",
|
"LIVE · Roads: {source} · Flow {percent}%": "ОНЛАЙН · Дороги: {source} · Поток {percent}%",
|
||||||
"SIMULATED · Roads: {source} · Flow {percent}%": "СИМУЛЯЦИЯ · Дороги: {source} · Поток {percent}%",
|
"SIMULATED · Roads: {source} · Flow {percent}%": "СИМУЛЯЦИЯ · Дороги: {source} · Поток {percent}%",
|
||||||
|
"STALE · Roads: {source} · Flow: cached TomTom tiles": "УСТАРЕЛ · Дороги: {source} · Поток: тайлы TomTom из кэша",
|
||||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Синхронизация потока · Дороги: OpenStreetMap · Поток: TomTom · Без совпадений: симуляция",
|
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Синхронизация потока · Дороги: OpenStreetMap · Поток: TomTom · Без совпадений: симуляция",
|
||||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}% · Без совпадений: симуляция",
|
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}% · Без совпадений: симуляция",
|
||||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}%",
|
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}%",
|
||||||
|
|||||||
@@ -1059,6 +1059,7 @@
|
|||||||
"LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内没有带路况数据的道路",
|
"LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内没有带路况数据的道路",
|
||||||
"LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 路况 {percent}%",
|
"LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 路况 {percent}%",
|
||||||
"SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 路况 {percent}%",
|
"SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 路况 {percent}%",
|
||||||
|
"STALE · Roads: {source} · Flow: cached TomTom tiles": "过期 · 道路:{source} · 路况:TomTom 缓存瓦片",
|
||||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路况 · 道路:OpenStreetMap · 路况:TomTom · 未匹配:模拟",
|
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路况 · 道路:OpenStreetMap · 路况:TomTom · 未匹配:模拟",
|
||||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}% · 未匹配:模拟",
|
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}% · 未匹配:模拟",
|
||||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}%",
|
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}%",
|
||||||
|
|||||||
@@ -1054,6 +1054,7 @@
|
|||||||
"LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路",
|
"LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路",
|
||||||
"LIVE · Roads: {source} · Flow {percent}%": "實時 · 道路:{source} · 路況 {percent}%",
|
"LIVE · Roads: {source} · Flow {percent}%": "實時 · 道路:{source} · 路況 {percent}%",
|
||||||
"SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%",
|
"SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%",
|
||||||
|
"STALE · Roads: {source} · Flow: cached TomTom tiles": "過期 · 道路:{source} · 路況:TomTom 緩存瓦片",
|
||||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路況 · 道路:OpenStreetMap · 路況:TomTom · 未匹配:模擬",
|
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路況 · 道路:OpenStreetMap · 路況:TomTom · 未匹配:模擬",
|
||||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬",
|
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬",
|
||||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%",
|
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%",
|
||||||
|
|||||||
Vendored
+15
@@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
liveMode: layerState._liveMode,
|
liveMode: layerState._liveMode,
|
||||||
fetching: loading,
|
fetching: loading,
|
||||||
flowError: layerState._flowError,
|
flowError: layerState._flowError,
|
||||||
|
stale: layerState._flowStale,
|
||||||
coveragePct: flowCoveragePct,
|
coveragePct: flowCoveragePct,
|
||||||
statusUnavailable: layerState._flowStatusUnavailable,
|
statusUnavailable: layerState._flowStatusUnavailable,
|
||||||
roadSource: layerState._roadSource,
|
roadSource: layerState._roadSource,
|
||||||
@@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
lastUpdate: layerState._lastUpdate,
|
lastUpdate: layerState._lastUpdate,
|
||||||
loading,
|
loading,
|
||||||
mode: feed.mode,
|
mode: feed.mode,
|
||||||
|
// Cached proxy tiles (TomTom budget spent or upstream failing): the
|
||||||
|
// manager reads this as STALE. Any error (flow, roads, detail) reads
|
||||||
|
// DEGRADED instead.
|
||||||
|
stale: Boolean(
|
||||||
|
layerState._liveMode &&
|
||||||
|
layerState._flowStale &&
|
||||||
|
!(
|
||||||
|
layerState._roadError ||
|
||||||
|
layerState._roadWarning ||
|
||||||
|
layerState._detailError ||
|
||||||
|
feed.error
|
||||||
|
),
|
||||||
|
),
|
||||||
|
oldestTileAt: layerState._flowOldestTileAt ?? null,
|
||||||
error:
|
error:
|
||||||
layerState._roadError ||
|
layerState._roadError ||
|
||||||
layerState._roadWarning ||
|
layerState._roadWarning ||
|
||||||
|
|||||||
@@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
|||||||
return layerState._warmFlow;
|
return layerState._warmFlow;
|
||||||
layerState._warmFlowGeneration = generation;
|
layerState._warmFlowGeneration = generation;
|
||||||
const signal = layerState._activeFetchAbort?.signal;
|
const signal = layerState._activeFetchAbort?.signal;
|
||||||
layerState._warmFlow = ensureFlowStatus(signal).then(() => {
|
layerState._warmFlow = ensureFlowStatus(signal).then(async () => {
|
||||||
if (!layerState._liveMode || generation !== layerState._loadGeneration)
|
if (!layerState._liveMode || generation !== layerState._loadGeneration)
|
||||||
return [];
|
return [];
|
||||||
return fetchFlowForBounds(clamped, { signal });
|
if (typeof source.fetchFlowDetail !== 'function')
|
||||||
|
return fetchFlowForBounds(clamped, { signal });
|
||||||
|
// The proxy serves cached tiles once the TomTom budget is spent or the
|
||||||
|
// upstream fails; carry that mark so getStats() reads STALE, not LIVE.
|
||||||
|
const detail = await source.fetchFlowDetail(clamped, { signal });
|
||||||
|
if (generation === layerState._loadGeneration) {
|
||||||
|
layerState._flowStale = detail.stale === true;
|
||||||
|
layerState._flowOldestTileAt = detail.oldestTileAt ?? null;
|
||||||
|
}
|
||||||
|
return detail.segments;
|
||||||
});
|
});
|
||||||
layerState._warmFlow.catch(() => {});
|
layerState._warmFlow.catch(() => {});
|
||||||
return layerState._warmFlow;
|
return layerState._warmFlow;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function createLifecycle({
|
|||||||
layerState._lastViewCenter = null;
|
layerState._lastViewCenter = null;
|
||||||
layerState._flowRoads = null;
|
layerState._flowRoads = null;
|
||||||
layerState._flowError = null;
|
layerState._flowError = null;
|
||||||
|
layerState._flowStale = false;
|
||||||
|
layerState._flowOldestTileAt = null;
|
||||||
if (TRAFFIC_TIMING_ENABLED) {
|
if (TRAFFIC_TIMING_ENABLED) {
|
||||||
layerState._trafficTimingCurrentAnchor = null;
|
layerState._trafficTimingCurrentAnchor = null;
|
||||||
layerState._trafficTimingSequence = 0;
|
layerState._trafficTimingSequence = 0;
|
||||||
@@ -159,6 +161,8 @@ export function createLifecycle({
|
|||||||
// A stale outage from the last session would misreport a fresh enable —
|
// A stale outage from the last session would misreport a fresh enable —
|
||||||
// the next load re-derives feed health from real evidence.
|
// the next load re-derives feed health from real evidence.
|
||||||
layerState._flowError = null;
|
layerState._flowError = null;
|
||||||
|
layerState._flowStale = false;
|
||||||
|
layerState._flowOldestTileAt = null;
|
||||||
|
|
||||||
layerState._surfaceFrameRemover?.();
|
layerState._surfaceFrameRemover?.();
|
||||||
layerState._surfaceFrameRemover = null;
|
layerState._surfaceFrameRemover = null;
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
* @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key.
|
* @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key.
|
||||||
* @param {boolean} [input.fetching] - A viewport load is in flight.
|
* @param {boolean} [input.fetching] - A viewport load is in flight.
|
||||||
* @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any.
|
* @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any.
|
||||||
|
* @param {boolean} [input.stale] - The flow came from cached proxy tiles.
|
||||||
* @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100.
|
* @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100.
|
||||||
* @param {boolean} [input.statusUnavailable] - The status probe itself failed.
|
* @param {boolean} [input.statusUnavailable] - The status probe itself failed.
|
||||||
* @param {string} [input.roadSource] - Name of the geometry being drawn.
|
* @param {string} [input.roadSource] - Name of the geometry being drawn.
|
||||||
@@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
liveMode = false,
|
liveMode = false,
|
||||||
fetching = false,
|
fetching = false,
|
||||||
flowError = null,
|
flowError = null,
|
||||||
|
stale = false,
|
||||||
coveragePct = 0,
|
coveragePct = 0,
|
||||||
statusUnavailable = false,
|
statusUnavailable = false,
|
||||||
roadSource = 'OpenStreetMap',
|
roadSource = 'OpenStreetMap',
|
||||||
@@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
});
|
});
|
||||||
return { mode, error: degraded, loadingLabel: degraded };
|
return { mode, error: degraded, loadingLabel: degraded };
|
||||||
}
|
}
|
||||||
|
// Cached proxy tiles (TomTom budget spent or upstream failing) are not live.
|
||||||
|
if (liveMode && stale && !fetching) {
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
error: null,
|
||||||
|
loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', {
|
||||||
|
source: roadSource,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
|
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
|
||||||
if (liveMode && roadSource === 'TomTom') {
|
if (liveMode && roadSource === 'TomTom') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated'
|
|||||||
assert.equal(state._flowPending, 0);
|
assert.equal(state._flowPending, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stale proxy flow tiles reach the layer stats and never read LIVE', async () => {
|
||||||
|
const { createFlow } = await import('./flow.js');
|
||||||
|
const { createModel } = await import('./model.js');
|
||||||
|
const { createControls } = await import('./controls.js');
|
||||||
|
const { createState } = await import('./state.js');
|
||||||
|
const { layerFeedState } = await import('../../data/feedState.js');
|
||||||
|
let detail = {
|
||||||
|
segments: [flow],
|
||||||
|
partial: false,
|
||||||
|
stale: true,
|
||||||
|
oldestTileAt: 1000,
|
||||||
|
};
|
||||||
|
const source = {
|
||||||
|
fetchFlowDetail: async () => detail,
|
||||||
|
fetchFlowForBounds: async () => detail.segments,
|
||||||
|
getFlowSessionStats: () => ({ tilesFetched: 1 }),
|
||||||
|
};
|
||||||
|
const state = createState({ services: {} });
|
||||||
|
Object.assign(state, {
|
||||||
|
_enabled: true,
|
||||||
|
_liveMode: true,
|
||||||
|
_loadGeneration: 1,
|
||||||
|
_flowStatusPromise: Promise.resolve(),
|
||||||
|
_bucketCounts: { free: 3, slow: 0, jam: 0, sim: 1 },
|
||||||
|
});
|
||||||
|
const parts = {};
|
||||||
|
const context = { state, services: { credits: {} }, parts, source };
|
||||||
|
parts.model = createModel(context);
|
||||||
|
parts.flow = createFlow(context);
|
||||||
|
const controls = createControls(context);
|
||||||
|
|
||||||
|
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 1);
|
||||||
|
const stale = controls.methods.getStats();
|
||||||
|
assert.equal(stale.mode, 'live');
|
||||||
|
assert.equal(stale.stale, true);
|
||||||
|
assert.equal(stale.oldestTileAt, 1000);
|
||||||
|
assert.doesNotMatch(stale.loadingLabel, /LIVE/);
|
||||||
|
assert.equal(layerFeedState(stale), 'stale');
|
||||||
|
|
||||||
|
// A road failure outranks cached flow: the row reads the failure.
|
||||||
|
state._roadError = 'Road geometry unavailable';
|
||||||
|
assert.equal(controls.methods.getStats().stale, false);
|
||||||
|
state._roadError = null;
|
||||||
|
|
||||||
|
detail = {
|
||||||
|
segments: [flow],
|
||||||
|
partial: false,
|
||||||
|
stale: false,
|
||||||
|
oldestTileAt: 2000,
|
||||||
|
};
|
||||||
|
state._loadGeneration = 2;
|
||||||
|
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 2);
|
||||||
|
const live = controls.methods.getStats();
|
||||||
|
assert.equal(live.stale, false);
|
||||||
|
assert.match(live.loadingLabel, /^LIVE · Roads: OpenStreetMap/);
|
||||||
|
assert.equal(layerFeedState(live), 'nominal');
|
||||||
|
});
|
||||||
|
|
||||||
test('status names the drawn road source for each mode', async () => {
|
test('status names the drawn road source for each mode', async () => {
|
||||||
const { createModel } = await import('./model.js');
|
const { createModel } = await import('./model.js');
|
||||||
const { trafficFeedPresentation: present } = createModel({
|
const { trafficFeedPresentation: present } = createModel({
|
||||||
|
|||||||
@@ -108,6 +108,16 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
layerState._flowError = null;
|
layerState._flowError = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the drawn flow came from cached proxy tiles (TomTom budget
|
||||||
|
* spent or upstream failing); `_flowOldestTileAt` is the oldest tile's
|
||||||
|
* fetch time in ms, or null.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
|
||||||
|
layerState._flowStale = false;
|
||||||
|
layerState._flowOldestTileAt = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when `/api/tomtom/status` itself could not be reached, so the layer is
|
* True when `/api/tomtom/status` itself could not be reached, so the layer is
|
||||||
* simulating because it could not ask — not because the server said "no key".
|
* simulating because it could not ask — not because the server said "no key".
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
|||||||
layerState._fetching = false;
|
layerState._fetching = false;
|
||||||
layerState._flowPending = 0;
|
layerState._flowPending = 0;
|
||||||
layerState._roadError = null;
|
layerState._roadError = null;
|
||||||
|
// Nothing is drawn up here, cached or not.
|
||||||
|
layerState._flowStale = false;
|
||||||
|
layerState._flowOldestTileAt = null;
|
||||||
parts.animation.clearDots();
|
parts.animation.clearDots();
|
||||||
layerState._lastBounds = null;
|
layerState._lastBounds = null;
|
||||||
layerState._lastViewCenter = null;
|
layerState._lastViewCenter = null;
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export function createApplicationRequestServices({
|
|||||||
// Discovery is bounded; failed, malformed or timed-out probes are forgotten and not
|
// 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
|
// retried for a short backoff, during which queries go to the server as
|
||||||
// they would against an older server without the probe.
|
// they would against an older server without the probe.
|
||||||
|
let summaryCapability = null;
|
||||||
let boundaryCapability = null;
|
let boundaryCapability = null;
|
||||||
let boundaryProbeRetryAt = 0;
|
let boundaryProbeRetryAt = 0;
|
||||||
function boundariesConfigured() {
|
function boundariesConfigured() {
|
||||||
@@ -188,6 +189,23 @@ export function createApplicationRequestServices({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
summary: {
|
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 } = {}) {
|
async summarize(context, { signal } = {}) {
|
||||||
return request(urls.summary, {
|
return request(urls.summary, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -111,3 +111,24 @@ test('a failed terrain request carries the real heights the server had', async (
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -729,6 +729,29 @@ test('Realtime service configuration selects compatible endpoint/model without f
|
|||||||
assert.doesNotMatch(response.body, /server-fixture|voice\.example/);
|
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) => {
|
test('OpenAI routes answer generically when the upstream or the request fails', async (t) => {
|
||||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||||
|
|||||||
@@ -714,18 +714,24 @@ export class LayerPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (feedState === 'stale') {
|
if (feedState === 'stale') {
|
||||||
|
// A layer drawing cached upstream data (Traffic's proxy tiles) dates the
|
||||||
|
// row by the oldest of it, not by the load that re-served it.
|
||||||
|
const dataAgo =
|
||||||
|
Number(stats.oldestTileAt) > 0
|
||||||
|
? this._timeAgo(stats.oldestTileAt)
|
||||||
|
: ago;
|
||||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||||
return t('{state} · {source} · {ago} · retrying in {seconds}s', {
|
return t('{state} · {source} · {ago} · retrying in {seconds}s', {
|
||||||
state: stateLabel,
|
state: stateLabel,
|
||||||
source,
|
source,
|
||||||
ago,
|
ago: dataAgo,
|
||||||
seconds: stats.retryInSec,
|
seconds: stats.retryInSec,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return t('{state} · {source} · {ago}', {
|
return t('{state} · {source} · {ago}', {
|
||||||
state: stateLabel,
|
state: stateLabel,
|
||||||
source,
|
source,
|
||||||
ago,
|
ago: dataAgo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
||||||
|
|||||||
@@ -93,6 +93,27 @@ test('partial feed controls distinguish incomplete records from stale data and o
|
|||||||
assert.equal(button.textContent, 'OFF');
|
assert.equal(button.textContent, 'OFF');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a stale row dates cached data by its oldest tile, not the last load', async () => {
|
||||||
|
const { LayerPanel } = await import('./layerPanel.js');
|
||||||
|
const panel = LayerPanel.prototype;
|
||||||
|
const layer = {
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Street Traffic',
|
||||||
|
source: 'TomTom',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
mode: 'live',
|
||||||
|
stale: true,
|
||||||
|
count: 4,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
oldestTileAt: Date.now() - 3 * 3600_000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.match(panel._buildMetaText(layer), /^STALE · TomTom · 3h ago$/);
|
||||||
|
delete layer.stats.oldestTileAt;
|
||||||
|
assert.match(panel._buildMetaText(layer), /^STALE · TomTom · just now$/);
|
||||||
|
});
|
||||||
|
|
||||||
test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => {
|
test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => {
|
||||||
const { LayerPanel } = await import('./layerPanel.js');
|
const { LayerPanel } = await import('./layerPanel.js');
|
||||||
const { railFixture } = await import('./railTestFixture.mjs');
|
const { railFixture } = await import('./railTestFixture.mjs');
|
||||||
|
|||||||
+81
-4
@@ -12,6 +12,11 @@ import { msg } from '../i18n/index.js';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { CITY_POIS } from '../cityPois.js';
|
import { CITY_POIS } from '../cityPois.js';
|
||||||
import { greatCircleKm } from '../geoDistance.js';
|
import { greatCircleKm } from '../geoDistance.js';
|
||||||
|
import {
|
||||||
|
hudViewportSamples,
|
||||||
|
placeLookupView,
|
||||||
|
placeLookupViewChanged,
|
||||||
|
} from '../hudPlaceLookup.js';
|
||||||
import {
|
import {
|
||||||
findPoiByName,
|
findPoiByName,
|
||||||
flyToGlobeView,
|
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(
|
export async function getBasemapLabelContext(
|
||||||
viewer,
|
viewer,
|
||||||
service = defaultGeospatial,
|
service = defaultGeospatial,
|
||||||
{ cachedOnly = false } = {},
|
{ cachedOnly = false, previous = null } = {},
|
||||||
) {
|
) {
|
||||||
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
|
|
||||||
const samples = sampleViewportCartographics(viewer);
|
|
||||||
const cameraHeightM = viewer.camera.positionCartographic.height;
|
const cameraHeightM = viewer.camera.positionCartographic.height;
|
||||||
const target = getViewTargetCartographic(viewer);
|
const target = getViewTargetCartographic(viewer);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -2706,11 +2718,77 @@ export async function getBasemapLabelContext(
|
|||||||
placeLabels: [],
|
placeLabels: [],
|
||||||
streetLabels: [],
|
streetLabels: [],
|
||||||
nearbyPlaceLabels: [],
|
nearbyPlaceLabels: [],
|
||||||
|
view: null,
|
||||||
|
lookup: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6));
|
const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6));
|
||||||
const longitude = Number(Cesium.Math.toDegrees(target.longitude).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(
|
const cachedViewportPlaces = viewportPlacesFromCache(
|
||||||
samples,
|
samples,
|
||||||
cameraHeightM,
|
cameraHeightM,
|
||||||
@@ -2748,7 +2826,6 @@ export async function getBasemapLabelContext(
|
|||||||
resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, null),
|
resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, null),
|
||||||
resolveWithin(nearbyPromise, BASEMAP_CONTEXT_WAIT_MS, []),
|
resolveWithin(nearbyPromise, BASEMAP_CONTEXT_WAIT_MS, []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
placeLabels: uniqueStrings([
|
placeLabels: uniqueStrings([
|
||||||
place?.formattedAddress,
|
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