Merge remote-tracking branch 'origin/main' into german/hal-644-ar

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-29 14:24:27 +04:00
commit 8f8e6776bc
78 files changed
+3326 -303

No files matched your search

@@ -0,0 +1,4 @@
- Director clock listeners, interaction observers and scene release failures
no longer leave the director inconsistent: each scene is released once, and
a phase error is kept (with the release failure logged) when cleanup also
fails (deepeshbind, #1014).
@@ -0,0 +1,5 @@
- Keep Directions route errors, the Local ADS-B receiver error and Global
Context cohort reasons English in layer data; the Layers panel, Radio card
and awareness panel still show them in the interface language. The route
proxy's "routing busy" and "route proxy error" refusals are now translated
too (daikaginza, #1209).
@@ -0,0 +1,4 @@
- Pause the HUD's camera readout while the page is hidden, as polled layers
already do. An app embedded inline in a panel keeps both updating, because the
panel's host can report the page as hidden while it is on screen (nu-stats,
#1344).
@@ -0,0 +1,4 @@
- Restore a shot's declared layer visibility when seeking within it after manual
layer changes, including toggles still in flight. Only changed layers are
reconciled; refused restoration reports a failed seek and keeps the last
position, while undeclared operator layers are preserved (@oneill9, #1374).
+2
View File
@@ -0,0 +1,2 @@
- A "place, region" search such as "Paris, Texas" now lands on the place in
that region instead of a namesake elsewhere (quanticsoul4772, #1401).
+5
View File
@@ -0,0 +1,5 @@
- The terrain proxy stops asking the upstream for a while after a 429
(its Retry-After, at most 10 minutes, or 60 s) instead of retrying on every
page request, and a Retry-After longer than its 10 s retry budget now ends
the retries at once instead of sleeping the budget out (quanticsoul4772,
#1402).
+3
View File
@@ -0,0 +1,3 @@
- Fire Perimeters and `get_fire_perimeters` say when WFIGS is unreachable and
the last snapshot is being shown (STALE, its age, "data may be stale"), and
that snapshot is served for at most 24 hours (quanticsoul4772, #1404).
+3
View File
@@ -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).
+5
View File
@@ -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).
+4
View File
@@ -0,0 +1,4 @@
- Update the development tools to Vite 6.4.4 and Puppeteer 25.16.0, which
clears the open npm advisories for the dev server and the browser test
tooling, and remove the unused local screenshot helper `scripts/shot-sink.mjs`
(samehkhamis, #1463).
+58 -4
View File
@@ -4,6 +4,24 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so
containers can read it on enforcing SELinux hosts. The separate image-owned
`node_modules` volume and loopback-only port publishing remain unchanged.
The HUD's AI summary line looks up place names for the view only when the
summary can use them. The page asks `GET /api/openai/hud-summary/status` once;
a server without an OpenAI key answers `{ configured: false }`, and the HUD then
makes no reverse-geocode or Nearby Places request until the page reloads. A
server without the route is asked once, and an unconfigured summary reply
stops the lookups the same way. Each lookup reverse-geocodes at most two
points: the view center alone below 10 km, the center and one viewport point
up to 750 km, and two viewport points up to 3000 km; Nearby Places is still
added below 25 km. A lookup is reused until the view center moves a quarter of
the visible width, the camera's distance to the center doubles or halves, or
the camera crosses one of those heights. The width is measured from the
camera's distance to the view center, so terrain and tilt scale it. A reused
lookup asks again only for what it did not get (a timeout, a failure, an empty
answer); everything else is a cache hit. A small pan over a reused lookup
repaints the last AI reply instead of asking for a new one. A host passing
`cachedOnly` keeps reading every cached sample. Voice context lookups are
unchanged.
The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to
hundredths of a second, and then splits it. A value within 0.005" of the next
whole minute carries into the minutes and degrees, so `-122.35` reads
@@ -560,6 +578,11 @@ dateline while preserving both authored endpoint longitudes, and turns heading
and roll the short way for any authored angles.
A seek to a shot's start, as a shot action makes, selects that shot rather than
the end of the previous one.
Same-shot seeking restores declared layer visibility after a manual toggle,
including one still in flight, by reconciling only the changed layers through
the cancellable layer admission path. Refused restoration reports a failed
seek and keeps the last settled position. Undeclared operator layers remain
untouched, and the shot is not rebuilt.
See [camera directions](DIRECTOR-CAMERA.md).
Realtime voice composes separate connection, response/tool, Radio handoff, input
@@ -793,6 +816,11 @@ rendered detection set. The legacy manager retains panel helper compatibility;
registration sealing, restoration order and failed-transition rollback remain
unchanged.
Polled layer refreshes and the HUD's camera readout pause while the page is
hidden. An app embedded inline in a panel keeps both running, because the
panel's host can report the page as hidden while it is on screen. This is the
same rule the render loop follows (`keepPanelRendering` in `app/embed.js`).
Application scene setup owns request services, terrain/floor caches and annotation
lookup state. Controls, layers and voice share those owners, and cancellation
clears caches and removes map-stack listeners. Standalone setup supplies the
@@ -1144,6 +1172,10 @@ Shot selection updates the highlight without replacing the row, preserving
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
late file and failed-action completions cannot update removed presentation.
Director clock listeners, interaction observers and scene release failures no
longer leave the director inconsistent, and a phase error is kept when cleanup
also fails.
## Cockpit component ownership
Cockpit presentation is separated from its camera/controller behavior. Existing
@@ -3078,7 +3110,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> usable per point, while an uncached absent height still returns 502 rather
> than becoming a fabricated ground value. A position the upstream answers
> with a null ellipsoid is reported as an absent height rather than a refresh
> failure, and is left uncached so a later poll re-asks it. Client geoid
> failure, and is left uncached so a later poll re-asks it. A `Retry-After`
> longer than the 10 s retry budget ends the retries at once. After a 429 that
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
> (60 seconds when none, at most 10 minutes). A request whose points are all
> cached, fresh or stale, is still served; a request with any uncached point
> gets 502 and the page uses its geoid fallback for that chunk. Each request
> that was waiting on the upstream when the 429 came back logs one warning;
> requests refused during the pause do not log. Client geoid
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
> first later successful fetch. Smaller chunks reduce timeout risk; complete
> camera batches still wait for their sequential requests, and unresolved
@@ -3859,7 +3898,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
@@ -5275,7 +5314,13 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
deadline, with Photon requests capped at six seconds each. Caller/application
cancellation stops retries and late cache writes. Replacing a location search
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers
candidates, and an unbiased retry for name mismatches. A "place, region" query
prefers a candidate named exactly for the place whose city, county, state,
country or country code (or USA/UK) contains every word of the region, and a
settlement or administrative area over a POI when both fit. Nearby results that
fail that check are retried unbiased. After the region checks of both passes, a
nearby place whose whole name is the query (one with a comma in it) comes next,
then the unbiased name match. Invalid or wrapped bounds returned by providers
are omitted rather than framing the wrong part of the globe.
A location search that finds nothing, or fails, leaves the field open with the
@@ -5766,7 +5811,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
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`,
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`
is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode:
TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy
@@ -6069,6 +6118,11 @@ reception does not interrupt internet radio.
Changes apply to the open receiver without reconnecting.
- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard,
aircraft with a fresh position and the IQ level.
- **Error text.** A receiver error sets two fields (`errorState()` in
`src/sdr/controller.js`): `message` is the UI language for the Radio card,
`errorText` is the English text the Local ADS-B layer reports as its stats
`error` (`localAdsbStatus()`, falling back to `message` when a receiver sets
none); the Layers panel translates it where it shows it.
- **Devices.** An already-authorized receiver opens without the WebUSB
picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the
1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels.
+67 -67
View File
@@ -23,9 +23,9 @@
},
"devDependencies": {
"prettier": "3.9.6",
"puppeteer": "^25.10.0",
"puppeteer": "^25.16.0",
"sharp": "^0.35.4",
"vite": "^6.0.0",
"vite": "^6.4.4",
"vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0"
},
@@ -1116,13 +1116,13 @@
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
"integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz",
"integrity": "sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"modern-tar": "^0.8.4",
"modern-tar": "^0.8.6",
"yargs": "^18.0.0"
},
"bin": {
@@ -1620,9 +1620,9 @@
}
},
"node_modules/ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1707,14 +1707,14 @@
}
},
"node_modules/chromium-bidi": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
"version": "157.0.8090-0",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
"integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
"zod": "^4.6.5"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
@@ -1723,16 +1723,6 @@
"devtools-protocol": "*"
}
},
"node_modules/chromium-bidi/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
@@ -1821,9 +1811,9 @@
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1666840",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz",
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==",
"version": "0.0.1704951",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1704951.tgz",
"integrity": "sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg==",
"dev": true,
"license": "BSD-3-Clause"
},
@@ -2054,9 +2044,9 @@
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2361,9 +2351,9 @@
"license": "MIT"
},
"node_modules/modern-tar": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
"version": "0.8.6",
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz",
"integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2557,19 +2547,19 @@
"license": "MIT"
},
"node_modules/puppeteer": {
"version": "25.10.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz",
"integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==",
"version": "25.16.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.16.0.tgz",
"integrity": "sha512-LOr3reYnxAYYfQkpopz1fQnL0LdvqhpR5U2Fu+QnwzTX2gpVTHsn91UETo5NNC1md2/oC54Zu1ChBXgZDNSP1A==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.2.2",
"chromium-bidi": "17.0.2",
"devtools-protocol": "0.0.1666840",
"@puppeteer/browsers": "3.2.6",
"chromium-bidi": "157.0.8090-0",
"devtools-protocol": "0.0.1704951",
"lilconfig": "^3.1.3",
"puppeteer-core": "25.10.0",
"typed-query-selector": "^2.12.2"
"puppeteer-core": "25.16.0",
"typed-query-selector": "^2.12.3"
},
"bin": {
"puppeteer": "lib/puppeteer/node/cli.js"
@@ -2579,18 +2569,18 @@
}
},
"node_modules/puppeteer-core": {
"version": "25.10.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz",
"integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==",
"version": "25.16.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.16.0.tgz",
"integrity": "sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.2.2",
"chromium-bidi": "17.0.2",
"devtools-protocol": "0.0.1666840",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.3",
"ws": "^8.21.3"
"@puppeteer/browsers": "3.2.6",
"chromium-bidi": "157.0.8090-0",
"devtools-protocol": "0.0.1704951",
"typed-query-selector": "^2.12.3",
"webdriver-bidi-protocol": "1.0.1",
"ws": "^8.22.0"
},
"engines": {
"node": ">=22.12.0"
@@ -2871,9 +2861,9 @@
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
},
"node_modules/string-width": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2963,9 +2953,9 @@
"license": "0BSD"
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"version": "2.12.3",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
"integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
"dev": true,
"license": "MIT"
},
@@ -3002,9 +2992,9 @@
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
"integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3132,9 +3122,9 @@
"license": "Apache-2.0"
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
"integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
"dev": true,
"license": "Apache-2.0"
},
@@ -3175,9 +3165,9 @@
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3226,9 +3216,9 @@
}
},
"node_modules/yargs": {
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3252,6 +3242,16 @@
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/zod": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+2 -2
View File
@@ -70,9 +70,9 @@
},
"devDependencies": {
"prettier": "3.9.6",
"puppeteer": "^25.10.0",
"puppeteer": "^25.16.0",
"sharp": "^0.35.4",
"vite": "^6.0.0",
"vite": "^6.4.4",
"vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0"
},
+2
View File
@@ -1102,6 +1102,8 @@
"src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs",
"src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
+1
View File
@@ -1982,6 +1982,7 @@
"src/hud.js",
"src/hudLocality.js",
"src/hudLocalityFormat.js",
"src/hudPlaceLookup.js",
"src/hudSummaryResponse.js",
"src/keySetup.js",
"src/keySetupCore.mjs",
+64
View File
@@ -88,6 +88,70 @@ try {
};
});
for (const [name, passed] of Object.entries(result)) check(name, passed);
const visibilityRestored = await page.evaluate(async () => {
// QA layer registration exists only in Vite dev builds.
if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped';
const { sceneDirector: director, dataManager } = window.__godsEyeView;
const layerId = 'director-seek-visibility-qa';
window.__gevQaRegisterLayer(dataManager, {
id: layerId,
name: 'Director seek visibility QA',
updateInterval: -1,
init() {},
enable() {},
disable() {},
update() {},
destroy() {},
getStats: () => ({ count: 0 }),
});
const scene = {
id: 'visibility-qa',
title: 'Visibility QA',
shots: [
{
id: 'visibility-shot',
title: 'Visibility shot',
durationSec: 0.4,
holdSec: 0,
camera: director.styleManager.getCameraState(),
visual: director.styleManager.getVisualState(),
layers: { [layerId]: { enabled: true } },
},
],
};
director._project.scenes.push(scene);
try {
const loaded = await director.loadShot(scene.id, scene.shots[0].id, {
flyDuration: 0.2,
});
const disabled =
(await dataManager.setEnabled(layerId, false, { origin: 'user' })) &&
!dataManager.layers.get(layerId).enabled;
const sought = await director.seekScene(scene.id, 0.5);
return (
loaded?.started &&
disabled &&
sought &&
dataManager.layers.get(layerId).enabled
);
} finally {
director.stopScene();
director._project.scenes = director._project.scenes.filter(
({ id }) => id !== scene.id,
);
await window.__gevQaUnregisterLayer(dataManager, layerId);
}
});
if (visibilityRestored === 'skipped') {
console.log(
'[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)',
);
} else {
check(
'same-shot seek restores manually changed layer visibility',
visibilityRestored,
);
}
const content = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes.find(({ title }) =>
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env node
// Minimal screenshot sink: the browser POSTs a data:image/... URL (grabbed from
// the real-GPU Cesium canvas) and this writes it to qa-shots/height-datum/<name>.
// Keeps large image payloads OUT of the agent's context — browser → sink → disk.
// Usage: node scripts/shot-sink.mjs (listens on :4399)
import http from 'node:http';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'qa-shots', 'height-datum');
mkdirSync(OUT, { recursive: true });
const PORT = 4399;
const cors = (res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
};
http.createServer((req, res) => {
cors(res);
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const url = new URL(req.url, 'http://localhost');
if (req.method === 'POST' && url.pathname === '/save') {
const name = (url.searchParams.get('name') || 'shot').replace(/[^a-zA-Z0-9._-]/g, '_');
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
try {
const m = /^data:image\/(png|jpeg);base64,(.+)$/s.exec(body.trim());
if (!m) { res.writeHead(400); res.end('bad dataurl'); return; }
const ext = m[1] === 'jpeg' ? 'jpg' : 'png';
const file = path.join(OUT, `${name}.${ext}`);
writeFileSync(file, Buffer.from(m[2], 'base64'));
res.writeHead(200); res.end(file);
console.log(`saved ${file} (${Math.round(m[2].length / 1024)} KB b64)`);
} catch (e) { res.writeHead(500); res.end(String(e)); }
});
return;
}
res.writeHead(404); res.end('nope');
}).listen(PORT, () => console.log(`shot-sink on http://localhost:${PORT} → ${OUT}`));
+10 -1
View File
@@ -39,6 +39,8 @@ const API_URL =
// The service caps a single response at its maxRecordCount (2000); a peak
// season can exceed that, so follow exceededTransferLimit with offset pages.
const MAX_PAGES = 5;
/** Longest a failed refresh keeps serving the last perimeter snapshot, marked stale. */
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
const exceededTransferLimit = (payload) =>
payload?.exceededTransferLimit === true ||
@@ -165,7 +167,14 @@ export function firePerimetersProxy({
});
return { value: await promise, stale: false };
} catch (error) {
if (previous) return { value: previous.value, stale: true };
// A failed refresh serves the last answer, marked stale. A perimeter
// snapshot only for a day: older than that, it is no picture of
// current fires. (InciWeb entries keep their previous behaviour.)
if (
previous &&
(key !== 'perimeters' || now() - previous.savedAt <= STALE_MAX_MS)
)
return { value: previous.value, stale: true };
throw error;
}
}
+13
View File
@@ -29,6 +29,19 @@ function toFiveWordHudSummary(value) {
}
async function handleHudSummary(req, res) {
// Capability probe: lets the HUD skip its paid place lookups when there is
// no summary to feed them to.
if (req.method === 'GET' && req.url?.split('?')[0] === '/status') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(
JSON.stringify({
configured: !keylessHudSummaryResponse(process.env.OPENAI_API_KEY),
}),
);
return;
}
if (req.method !== 'POST') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json');
+47 -6
View File
@@ -6,6 +6,7 @@ import {
parseTerrainPoints,
resolveTerrainHeightRequest,
terrainPointKey,
terrainRetryAfterMs,
validTerrainResult,
} from '../../src/data/terrainHeightsProxy.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
@@ -55,6 +56,15 @@ export function terrainHeightsProxy(options = {}) {
// 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of
// that range.
const UPSTREAM_CHUNK = 64;
// After a 429 the upstream is left alone for its Retry-After (at most
// RATE_LIMIT_PAUSE_MAX_MS, so a far-off date can't stop terrain until a
// restart), or RATE_LIMIT_PAUSE_MS when it gives none. Every page poll for
// new points (about once a second while the camera moves) otherwise asks
// again with its own retries, which keeps the limit in force and logs each
// time.
const RATE_LIMIT_PAUSE_MS = 60_000;
const RATE_LIMIT_PAUSE_MAX_MS = 10 * 60_000;
let rateLimitedUntil = 0;
const MAX_POINTS = UPSTREAM_CHUNK;
/**
* Entry ceiling for the point cache, and with it the file written from it.
@@ -199,7 +209,8 @@ export function terrainHeightsProxy(options = {}) {
*
* The first try keeps its empirically required 30s timeout; network errors,
* 429, and 5xx receive up to three jittered retries sharing a 10s added-time
* budget, with Retry-After honored within that bound.
* budget, with Retry-After honored within that bound; a longer Retry-After
* ends the retries at once so the 429 pause below starts without delay.
*
* A chunk that still fails yields nulls for its own positions rather than
* rejecting: with several chunks per request, throwing would discard every
@@ -211,15 +222,37 @@ export function terrainHeightsProxy(options = {}) {
* @returns {Promise<Array<object>>}
*/
async function fetchUpstreamAll(points) {
if (Date.now() < rateLimitedUntil) {
// Rate-limited: asking again only extends the limit. Cached points are
// still served; the rest resolve on the page through its geoid.
const error = new Error('HTTP 429');
error.status = 429;
error.coolingDown = true;
throw error;
}
const results = [];
let firstError = null;
for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) {
const chunk = points.slice(i, i + UPSTREAM_CHUNK);
let chunkResults = [];
try {
chunkResults = await fetchTerrainChunkWithRetry(chunk);
} catch (error) {
firstError = firstError || error;
// Re-checked per chunk for when a request spans several chunks again;
// today requests are capped at one chunk (MAX_POINTS).
if (Date.now() >= rateLimitedUntil) {
try {
chunkResults = await fetchTerrainChunkWithRetry(chunk);
} catch (error) {
firstError = firstError || error;
// Concurrent 429s extend the pause, never cut it short.
if (error?.status === 429)
rateLimitedUntil = Math.max(
rateLimitedUntil,
Date.now() +
Math.min(
terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS,
RATE_LIMIT_PAUSE_MAX_MS,
),
);
}
}
// Keep later chunks aligned even if a malformed upstream response omits
// trailing positions. The resolver will reject each null individually.
@@ -292,9 +325,17 @@ export function terrainHeightsProxy(options = {}) {
evictOverflow();
diskDirty = true;
}
if (outcome.upstreamError) {
if (outcome.upstreamError?.coolingDown) {
// Logged once, when the pause began.
} else if (outcome.upstreamError) {
const pause =
outcome.upstreamError.status === 429 &&
Date.now() < rateLimitedUntil
? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)`
: '';
console.warn(
'[terrain-heights-proxy] refresh incomplete' +
pause +
' — serving stale points when available',
);
} else if (outcome.absentPoints > 0) {
+29
View File
@@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains
assert.equal(dataManager.layers.size, 0);
});
test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => {
const previous = globalThis.document;
globalThis.document = {
hidden: true,
visibilityState: 'hidden',
getElementById: () => null,
addEventListener() {},
removeEventListener() {},
};
t.after(() => {
delete globalThis.GEV_EMBED_INLINE;
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
});
const releases = [];
const { dataManager } = createApplicationData({
scene: { viewer: {} },
controls: { styleManager: { attachDataManager() {} } },
catalog: createLayerCatalog([], []),
defer: (release) => releases.push(release),
});
const visibility = dataManager._visibilityTarget;
assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel');
globalThis.GEV_EMBED_INLINE = true;
assert.equal(visibility.hidden, false);
assert.equal(visibility.visibilityState, 'visible');
for (const release of releases.reverse()) await release();
});
test('controls bind catalog instances rather than similarly named defaults', () => {
const ids = [
'traffic',
+4 -1
View File
@@ -1,6 +1,7 @@
import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
import { createCyberSonarScene } from '../cyberSonarScene.js';
import { panelAwareVisibility } from './embed.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer, mapStackController },
@@ -13,7 +14,9 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
visibilityTarget: document,
// A panel's host may report it hidden while it is on screen; its layers
// keep polling (see keepPanelRendering in embed.js).
visibilityTarget: panelAwareVisibility(document),
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
+22
View File
@@ -45,6 +45,28 @@ export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true;
}
/**
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
* may report it hidden while it is on screen (see keepPanelRendering), so an
* inline panel always reads as visible.
*/
export function panelAwareVisibility(doc = globalThis.document) {
const hidden = () =>
(doc.hidden === true || doc.visibilityState === 'hidden') &&
!isEmbeddedInline();
return {
get hidden() {
return hidden();
},
get visibilityState() {
return hidden() ? 'hidden' : 'visible';
},
addEventListener: (type, listener) => doc.addEventListener(type, listener),
removeEventListener: (type, listener) =>
doc.removeEventListener(type, listener),
};
}
// A panel keeps drawing at about this rate when its host stops animation
// frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33;
+56
View File
@@ -5,7 +5,9 @@ import {
installViews,
isEmbedded,
keepPanelRendering,
panelAwareVisibility,
} from './embed.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { createView } from '../view/index.js';
const fakeViewer = () => {
@@ -211,6 +213,60 @@ test('an app loaded inline into a panel page talks through its own window', asyn
}
});
test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => {
const listeners = new Set();
const doc = {
hidden: true,
visibilityState: 'hidden',
addEventListener: (_type, fn) => listeners.add(fn),
removeEventListener: (_type, fn) => listeners.delete(fn),
};
const settle = async () => {
for (let i = 0; i < 3; i++) await Promise.resolve();
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
let updates = 0;
const manager = new LayerLifecycle(
{},
{ visibilityTarget: panelAwareVisibility(doc) },
);
manager.register({
id: 'flights',
name: 'flights',
icon: '',
source: 'test',
updateInterval: 1000,
async init() {},
enable() {},
disable() {},
async update() {
updates++;
await Promise.resolve();
},
getStats: () => ({ count: 0, lastUpdate: null }),
});
globalThis.GEV_EMBED_INLINE = true;
try {
await manager.setEnabled('flights', true);
await settle();
const enabled = updates;
t.mock.timers.tick(1000);
await settle();
t.mock.timers.tick(1000);
await settle();
assert.equal(updates, enabled + 2, 'the panel keeps polling');
delete globalThis.GEV_EMBED_INLINE;
t.mock.timers.tick(1000);
await settle();
assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses');
} finally {
delete globalThis.GEV_EMBED_INLINE;
await manager.destroyAll();
}
assert.equal(listeners.size, 0);
});
test('annotations in a link are drawn after it restores, embedded or not', async () => {
const { calls, run } = recorder();
const an = encodeURIComponent(
+2
View File
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_bumpSceneSeekGeneration:
SceneDirector.prototype._bumpSceneSeekGeneration,
_cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
+7 -6
View File
@@ -1,6 +1,6 @@
/** Pure proximity helpers for Global Context. No capability modeling. */
import { t } from '../i18n/index.js';
import { msg, t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
@@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) {
/**
* Summarize sorted nearby results without promoting missing/stale feeds to a
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP.
* claim in this MVP. `reason` is English data; the awareness panel shows it
* in the UI language.
*/
export function summarizeAwarenessCohort(
items,
@@ -95,14 +96,14 @@ export function summarizeAwarenessCohort(
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: t('feed unavailable'),
reason: msg('feed unavailable'),
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: t('feed stale'),
reason: msg('feed stale'),
};
const normalized = (Array.isArray(items) ? items : [])
.filter(
@@ -119,8 +120,8 @@ export function summarizeAwarenessCohort(
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length
? t('observed or mapped nearby context')
: t('no observed or mapped objects in current feeds'),
? msg('observed or mapped nearby context')
: msg('no observed or mapped objects in current feeds'),
};
}
+20
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../i18n/index.js';
import {
AWARENESS_RELATIONSHIP,
findByDoublingRadius,
@@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => {
assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale');
});
test('cohort reasons stay English in data and translate where the panel shows them', (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const reasons = [
summarizeAwarenessCohort([], { available: false }).reason,
summarizeAwarenessCohort([], { stale: true }).reason,
summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason,
summarizeAwarenessCohort([]).reason,
];
assert.deepEqual(reasons, [
'feed unavailable',
'feed stale',
'observed or mapped nearby context',
'no observed or mapped objects in current feeds',
]);
// The awareness panel shows a reason through translateKnown().
for (const reason of reasons) assert.notEqual(translateKnown(reason), reason);
});
test('navigation prioritizes nearby targets from the selected cohort', () => {
const cohorts = [
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
+6 -1
View File
@@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) {
/**
* Fetch one upstream chunk with jittered exponential backoff. Network errors,
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
* Retry sleeps and retry attempts share a 10s budget after attempt one.
* Retry sleeps and retry attempts share a 10s budget after attempt one. A
* Retry-After longer than the budget left ends the retries at once: sleeping
* out the budget cannot reach the time the upstream asked for, and it would
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
*
* Dependencies are injectable for deterministic offline tests.
* @param {Array<[number, number]>} points
@@ -145,6 +148,7 @@ export async function fetchTerrainChunkWithRetry(
const res = await fetchImpl(url, signal ? { signal } : {});
if (!res.ok) {
const error = new Error(`HTTP ${res.status}`);
error.status = res.status;
error.retryable = res.status === 429 || res.status >= 500;
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
throw error;
@@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry(
if (remaining <= 0) break;
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
if (retryAfterMs != null && retryAfterMs > remaining) break;
const backoffMs = Math.round(
350 * 2 ** attempt * (0.75 + random() * 0.5),
);
+30
View File
@@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => {
assert.equal(results[0].id, 'recovered');
});
test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => {
let clock = 3_000_000;
let attempts = 0;
const sleeps = [];
await assert.rejects(
fetchTerrainChunkWithRetry([[1, 1]], {
now: () => clock,
random: () => 0.5,
makeSignal: () => undefined,
sleep: async (ms) => {
sleeps.push(ms);
clock += ms;
},
fetchImpl: async () => {
attempts += 1;
return {
ok: false,
status: 429,
headers: {
get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null),
},
};
},
}),
(error) => error.status === 429 && error.retryAfter === '30',
);
assert.equal(attempts, 1);
assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After');
});
test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => {
let clock = 2_000_000;
let attempts = 0;
+9
View File
@@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot(
{
getFlowSessionStats: () => ({ tilesFetched: 0 }),
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;
/** The source the traffic layer reads, as configured. */
export const trafficSource = sourceSlot.source;
const layer = createApplicationTraffic({
source: sourceSlot.source,
});
+105 -19
View File
@@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js';
const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/;
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(undefined), null);
});
@@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => {
test('flow failures map onto short, specific reasons', () => {
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 429'), 'TomTom daily budget reached');
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 tile 12/1/1: HTTP 429'),
'TomTom daily budget reached',
);
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');
});
@@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => {
trafficFeedPresentation({ liveMode: false, fetching: false }),
trafficFeedPresentation({ liveMode: false, fetching: true }),
trafficFeedPresentation({ statusUnavailable: true }),
trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }),
trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom flow unavailable',
}),
trafficFeedPresentation({
liveMode: true,
fetching: true,
flowError: 'TomTom flow unavailable',
}),
].map((feed) => feed.loadingLabel);
for (const label of labels) {
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', () => {
const probeDown = trafficFeedPresentation({ statusUnavailable: true });
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', () => {
@@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => {
assert.deepEqual(idle, {
mode: 'live',
error: null,
loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
loadingLabel:
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
});
assert.equal(
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,
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', () => {
@@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
assert.equal(
mgr._buildMetaText({
source: 'OpenStreetMap',
stats: stats(trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
})),
stats: stats(
trafficFeedPresentation({
liveMode: true,
flowError: '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', () => {
const settled = { count: 4200, lastUpdate: Date.now() };
assert.equal(
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }),
layerFeedState({
...settled,
...trafficFeedPresentation({ liveMode: false }),
}),
'fallback',
);
assert.equal(
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }),
layerFeedState({
...settled,
...trafficFeedPresentation({ liveMode: true }),
}),
'nominal',
);
assert.equal(
layerFeedState({
...settled,
...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
...trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom flow unavailable',
}),
}),
'degraded',
);
@@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => {
const stats = trafficLayer.getStats();
assert.equal(stats.mode, 'sim');
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');
});
@@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => {
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
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();
}
});
+7 -1
View File
@@ -124,7 +124,13 @@ class PlaybackClock {
subscribe(listener) {
if (this._destroyed || typeof listener !== 'function') return () => {};
this._sceneClockListeners.add(listener);
if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot });
if (this._sceneClockSnapshot) {
try {
listener({ ...this._sceneClockSnapshot });
} catch (error) {
console.warn('[Scenes] Scene clock listener failed:', error);
}
}
return () => this._sceneClockListeners.delete(listener);
}
+29
View File
@@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a
});
assert.equal(f.timers.size, 0);
});
test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => {
const f = fixture();
f.clock.publish(scene, shot, 1);
let calls = 0;
const warnings = [];
const origWarn = console.warn;
console.warn = (...args) => warnings.push(args);
try {
const unsubscribe = f.clock.subscribe(() => {
calls++;
throw new Error('listener failure');
});
assert.equal(typeof unsubscribe, 'function');
assert.equal(calls, 1);
assert.equal(warnings.length, 1);
assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:');
f.clock.publish(scene, shot, 2);
assert.equal(calls, 2);
assert.equal(warnings.length, 2);
unsubscribe();
f.clock.publish(scene, shot, 3);
assert.equal(calls, 2);
assert.equal(warnings.length, 2);
f.clock.destroy();
} finally {
console.warn = origWarn;
}
});
@@ -178,6 +178,92 @@ test('synchronous stop before execution prevents any side effect; rejection unlo
assert.equal(calls, 2);
});
test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => {
let executed = 0;
let throwOnBusy = true;
let throwOnEnd = true;
const warnings = [];
const origWarn = console.warn;
console.warn = (...args) => warnings.push(args);
try {
const session = createInteractionSession({
execute: () => {
executed++;
return true;
},
changed: (state) => {
if (throwOnBusy && state.busy) throw new Error('start failed');
if (throwOnEnd && !state.busy && state.selected === 'a') {
throw new Error('end failed');
}
},
});
session.activate([{ id: 'a' }]);
const result = await session.dispatch('a');
assert.equal(result, true);
assert.equal(executed, 1);
assert.deepEqual(session.getState(), {
active: true,
busy: false,
selected: 'a',
count: 1,
});
assert.equal(warnings.length, 2);
assert.equal(
warnings[0][0],
'[Scenes] Interaction session observer failed:',
);
assert.equal(
warnings[1][0],
'[Scenes] Interaction session observer failed:',
);
throwOnBusy = false;
throwOnEnd = false;
assert.equal(await session.dispatch('a'), true);
assert.equal(executed, 2);
session.clear();
assert.deepEqual(session.getState(), {
active: false,
busy: false,
selected: null,
count: 0,
});
} finally {
console.warn = origWarn;
}
});
test('an observer that always throws cannot stop activate or clear', () => {
const warnings = [];
const origWarn = console.warn;
console.warn = (...args) => warnings.push(args);
try {
const session = createInteractionSession({
execute: () => true,
changed: () => {
throw new Error('observer failed');
},
});
session.activate([{ id: 'a' }, { id: 'b' }]);
assert.deepEqual(session.getState(), {
active: true,
busy: false,
selected: null,
count: 2,
});
session.clear();
assert.deepEqual(session.getState(), {
active: false,
busy: false,
selected: null,
count: 0,
});
assert.equal(warnings.length, 3);
} finally {
console.warn = origWarn;
}
});
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
const { SceneDirector } = await import('../../scenes/director.js');
const shot = { id: 'shot', dataPackIds: ['pack'] };
@@ -188,7 +274,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele
_claimCameraOwnership: () =>
assert.fail('Must reload the released pack before camera seek'),
});
assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false);
assert.equal(await director._seekLoadedShot({ id: 'scene' }, { shot }), null);
});
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
+11 -4
View File
@@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) {
selected = null;
let actions = new Map();
const state = () => ({ active, busy, selected, count: actions.size });
function notify() {
try {
changed(state());
} catch (error) {
console.warn('[Scenes] Interaction session observer failed:', error);
}
}
function clear() {
active = false;
busy = false;
@@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
actions.clear();
controller?.abort();
controller = null;
changed(state());
notify();
}
return {
clear,
@@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
clear();
actions = new Map(items.map((item) => [item.id, item]));
active = !!actions.size;
changed(state());
notify();
},
async dispatch(id) {
const item = actions.get(id);
@@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
controller = current;
busy = true;
selected = id;
changed(state());
notify();
let abort;
try {
const cancelled = new Promise((resolve) => {
@@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
if (controller === current) {
busy = false;
controller = null;
changed(state());
notify();
}
}
},
+20 -2
View File
@@ -35,6 +35,7 @@ export function buildPlaybackQueue(
* releaseScene(scene, token?) leaves a previously loaded scene before playback
* (false refuses that initial handoff). Between scenes and during final cleanup
* it receives no cancelled token, so resource release can finish after Stop.
* The first error wins when the final release also fails.
* An empty/already-cancelled queue acquires and releases no resources.
*
* @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue.
@@ -53,6 +54,7 @@ export async function playSceneQueue(
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
let activeScene = null;
let completedShots = 0;
let runFailed = false;
try {
if (!queue.length || cancelled()) {
return {
@@ -68,8 +70,9 @@ export async function playSceneQueue(
for (let index = 0; index < queue.length && !cancelled(); index++) {
const { scene, shot } = queue[index];
if (activeScene && activeScene.id !== scene.id) {
await adapter.releaseScene(activeScene);
const toRelease = activeScene;
activeScene = null;
await adapter.releaseScene(toRelease);
if (cancelled()) break;
}
activeScene = scene;
@@ -82,8 +85,23 @@ export async function playSceneQueue(
}
if (!cancelled()) await adapter.complete?.();
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
} catch (error) {
runFailed = true;
throw error;
} finally {
if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene);
if (releaseOnFinish && activeScene) {
const toRelease = activeScene;
activeScene = null;
try {
await adapter.releaseScene(toRelease);
} catch (error) {
if (runFailed) {
console.warn('[Scenes] Scene release failed:', error);
} else {
throw error;
}
}
}
}
}
+65
View File
@@ -196,3 +196,68 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy
(error) => error === failure,
);
});
test('a phase failure is preserved when final scene release also throws', async () => {
const phaseError = new Error('travel failed');
const releaseError = new Error('release failed');
const warnings = [];
const origWarn = console.warn;
console.warn = (...args) => warnings.push(args);
try {
const f = fixture({
travel: async () => {
throw phaseError;
},
releaseScene: async () => {
throw releaseError;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
(error) => error === phaseError,
);
assert.equal(warnings.length, 1);
assert.equal(warnings[0][0], '[Scenes] Scene release failed:');
assert.equal(warnings[0][1], releaseError);
} finally {
console.warn = origWarn;
}
});
test('a failing inter-scene release releases the active scene only once', async () => {
const releaseError = new Error('release failed');
const released = [];
const f = fixture({
releaseScene: async (scene) => {
released.push(scene.id);
throw releaseError;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a'), f),
(error) => error === releaseError,
);
assert.deepEqual(released, ['a']);
});
test('a falsy phase failure is still preserved over a release failure', async () => {
const releaseError = new Error('release failed');
const origWarn = console.warn;
console.warn = () => {};
try {
const f = fixture({
travel: async () => {
throw undefined;
},
releaseScene: async () => {
throw releaseError;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
(error) => error === undefined,
);
} finally {
console.warn = origWarn;
}
});
+52 -13
View File
@@ -39,6 +39,7 @@ import {
} from './hudLayouts.js';
import { formatNumber, msg, t } from './i18n/index.js';
import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -202,7 +203,13 @@ export class IntelHUD {
this._dataManagerUnsubscribe = null;
this._summaryDirty = true;
this._summaryRequest = null;
this._summaryUnconfigured = false;
// The last place lookup and the view it answered for (see
// getBasemapLabelContext), reused until the view changes enough.
this._placeLabels = null;
this._lastSummarySignature = '';
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
this._lastSummaryText = null;
this._summaryRevision = 0;
// One-shot guards so the very first summary lands immediately instead of
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
@@ -363,9 +370,11 @@ export class IntelHUD {
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
}, 800);
// Camera-derived data — 4 updates/second (250ms)
// Camera-derived data — 4 updates/second (250ms). Skipped in a hidden
// page, except an inline panel, whose host may report it hidden while it
// is on screen (see keepPanelRendering in app/embed.js).
this._updateInterval = setInterval(() => {
if (!this._visible) return;
if (!this._visible || (document.hidden && !isEmbeddedInline())) return;
this._updateCameraData();
}, 250);
@@ -826,6 +835,15 @@ export class IntelHUD {
if (this.summaryPolicy.canRequest?.() === false) return;
const revision = this._summaryRevision;
// The place lookups below are paid Google requests made only to feed the
// AI summary, so a server without one must not be asked to make them.
if (!(await this._summaryAvailable())) {
if (revision !== this._summaryRevision) return;
this._summaryDirty = false;
this._setSummaryText(fallbackText, animate);
return;
}
if (revision !== this._summaryRevision) return;
// Every caller invokes this as `void this._updateSummary(...)`, so nothing
// owns the returned promise — a rejection escaping from here lands as an
// unhandled rejection in the console. Building the context walks the live
@@ -848,6 +866,9 @@ export class IntelHUD {
const signature = JSON.stringify(context);
if (!force && signature === this._lastSummarySignature) {
this._summaryDirty = false;
// A settle painted the fallback line over the reply this context
// already has; reused place labels make that common, so restore it.
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText);
return;
}
if (this._summaryRequest) return;
@@ -855,6 +876,7 @@ export class IntelHUD {
if (force) this._setSummaryText(fallbackText, false);
this._summaryDirty = false;
this._lastSummarySignature = signature;
this._lastSummaryText = null;
const controller = new AbortController();
let timedOut = false;
const timeout = window.setTimeout(() => {
@@ -870,18 +892,16 @@ export class IntelHUD {
const data = response.data;
if (revision !== this._summaryRevision) return;
if (isHudSummaryUnconfigured(response.status, data)) {
this._summaryUnconfigured = true;
this._setSummaryText(fallbackText, animate);
return;
}
if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
this._setSummaryText(
hudSummaryMatchesProvenance(data.summary, context.feedProvenance)
? data.summary
: fallbackText,
animate,
);
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
this._lastSummaryText = data.summary;
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
} catch (error) {
// The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not.
@@ -915,12 +935,31 @@ export class IntelHUD {
if (el) el.textContent = text;
}
/**
* False once the summary service reports no AI summary: by its capability
* probe, when it has one, or by a deliberate unconfigured reply. Unknown
* stays available, as before.
*/
async _summaryAvailable() {
if (this._summaryUnconfigured) return false;
try {
if ((await this.summaryService?.configured?.()) === false)
this._summaryUnconfigured = true;
} catch {
/* an unanswered probe leaves the summary as it was */
}
return !this._summaryUnconfigured;
}
async _summaryContext() {
const labels = await getBasemapLabelContext(
this.viewer,
this.placeSearch,
this.basemapContext,
);
const revision = this._summaryRevision;
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, {
...this.basemapContext,
previous: this._placeLabels,
});
// A slower build for a view the camera has left must not replace the
// newer view's lookup.
if (revision === this._summaryRevision) this._placeLabels = labels;
const enabledLayers =
this._dataManager
?.getAll?.()
+87
View File
@@ -0,0 +1,87 @@
// The HUD's 250 ms telemetry readout skips ticks in a hidden page, except in
// an inline panel, whose host may report it hidden while it is on screen.
//
// 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 hudAltitudeDatum.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 installHiddenPage() {
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 previousDocument = globalThis.document;
globalThis.document = {
hidden: true,
visibilityState: 'hidden',
getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
};
const viewer = {
camera: {
pitch: -Math.PI / 2,
positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
return {
alt: () => elements.get('hud-alt').textContent,
viewer,
restore() {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
},
};
}
test('the HUD readout pauses in a hidden page but keeps updating in an inline panel', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const page = installHiddenPage();
let hud;
try {
hud = new IntelHUD(page.viewer);
hud._visible = true;
t.mock.timers.tick(250);
assert.equal(page.alt(), '', 'a hidden page skips the telemetry tick');
globalThis.GEV_EMBED_INLINE = true;
t.mock.timers.tick(250);
assert.match(
page.alt(),
/^ALT: 500m/,
'an inline panel reported hidden keeps its readout updating',
);
} finally {
delete globalThis.GEV_EMBED_INLINE;
hud?.destroy();
page.restore();
}
});
+80
View File
@@ -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);
}
+187
View File
@@ -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');
});
+4 -1
View File
@@ -865,6 +865,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} · Flow {percent}%": "EN VIVO · 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",
"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.",
@@ -3002,5 +3003,7 @@
"Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.",
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
"Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz."
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas"
}
+4 -1
View File
@@ -255,6 +255,7 @@
"LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком",
"LIVE · 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 · Без совпадений: симуляция",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}% · Без совпадений: симуляция",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}%",
@@ -3071,5 +3072,7 @@
"Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.",
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
"Preview is off.": "Предпросмотр отключён.",
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения."
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
"route proxy error": "ошибка прокси маршрутов"
}
+4 -1
View File
@@ -1060,6 +1060,7 @@
"LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内没有带路况数据的道路",
"LIVE · 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 · 未匹配:模拟",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}% · 未匹配:模拟",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}%",
@@ -2864,5 +2865,7 @@
"{count} placemarks": {
"other": "{count} 个地标"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。"
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
"route proxy error": "路线代理出错"
}
+4 -1
View File
@@ -1055,6 +1055,7 @@
"LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路",
"LIVE · 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 · 未匹配:模擬",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬",
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%",
@@ -2864,5 +2865,7 @@
"{count} placemarks": {
"other": "{count} 個地標"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。"
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯"
}
+81 -1
View File
@@ -281,6 +281,67 @@ export function selectPhotonFeature(
return null;
}
/** Short country names people type that Photon's fields don't carry. */
const COUNTRY_ALIASES = Object.freeze({ US: 'usa', GB: 'uk' });
/**
* OSM keys of settlements and administrative areas. Among candidates that fit
* a "place, region" query, these are what the region names a place in: a cafe
* called "Paris" in Lyon is in France too, but "Paris, France" means the city.
*/
const SETTLEMENT_OSM_KEYS = Object.freeze(new Set(['place', 'boundary']));
/** The region part of a "place, region" query, normalized ('' if none). */
function queryRegion(query) {
const [, ...rest] = String(query ?? '').split(',');
return normalizeToponym(rest.join(' '));
}
/**
* For a "place, region" query, pick the candidate named for the place that
* actually lies in the region. "Paris, Texas" is also the name of a pub in
* Budapest: matched as one name it beat the town, so the region has to be
* checked against where each candidate is, not against its name. When several
* candidates fit, a settlement or administrative area wins over a POI.
* @param {object[]} features - Photon features, in the order returned.
* @param {string} query - The query as typed.
* @returns {?object} null when the query names no region or nothing fits.
*/
export function selectQualifiedFeature(features, query) {
const [headText] = String(query ?? '').split(',');
const region = queryRegion(query);
const head = normalizeToponym(headText);
if (!head || !region) return null;
const words = region.split(' ');
const qualified = (Array.isArray(features) ? features : []).filter(
(feature) => {
const properties = feature?.properties ?? {};
const name = normalizeToponym(properties.name);
// Exactly the place: "Paris Gare de Lyon" is in France too, but it
// is not what "Paris, France" asks for.
if (name !== head) return false;
const where = ` ${normalizeToponym(
[
properties.city,
properties.county,
properties.state,
properties.country,
properties.countrycode,
COUNTRY_ALIASES[String(properties.countrycode ?? '').toUpperCase()],
].join(' '),
)} `;
return words.every((word) => where.includes(` ${word} `));
},
);
return (
qualified.find((feature) =>
SETTLEMENT_OSM_KEYS.has(feature?.properties?.osm_key),
) ??
qualified[0] ??
null
);
}
/** Evict the oldest memo entries until the cache is within its cap. */
function trimPhotonCache(cache) {
while (cache.size > PHOTON_CACHE_MAX) {
@@ -359,12 +420,28 @@ export async function geocodeKeylessWithOutcome(
// to the wrong continent. So a biased answer is only accepted when it leads
// with the name asked for; otherwise the same query runs unbiased, which is
// what a user searching for somewhere off-screen meant in the first place.
// A query naming a region is answered nearby only by a match inside it.
// A nearby place whose own name has a comma ("Bed, Bath & Beyond") and
// equals the whole query is kept as the last choice, after the region
// checks of both passes.
let feature = null;
let nearbyNameMatch = null;
let everyAskAnswered = true;
if (bias) {
const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint }));
if (biased === null) everyAskAnswered = false;
else feature = selectPhotonFeature(biased, normalizeToponym(trimmed));
else if (queryRegion(trimmed)) {
feature = selectQualifiedFeature(biased, trimmed);
// Exactly the whole query: "Springfield IL Auto" only starts with it.
const whole = normalizeToponym(trimmed);
nearbyNameMatch =
biased.find(
(candidate) =>
normalizeToponym(candidate?.properties?.name) === whole,
) ?? null;
} else {
feature = selectPhotonFeature(biased, normalizeToponym(trimmed));
}
}
signal?.throwIfAborted();
@@ -379,9 +456,12 @@ export async function geocodeKeylessWithOutcome(
// the thing being searched for; the tail only says where to look.
const head = normalizeToponym(trimmed.split(',')[0]);
feature =
selectQualifiedFeature(anywhere, trimmed) ||
nearbyNameMatch ||
selectPhotonFeature(anywhere, head, { allowContains: true }) ||
anywhere[0];
}
if (!feature) feature = nearbyNameMatch;
}
signal?.throwIfAborted();
+240 -1
View File
@@ -30,6 +30,7 @@ import {
photonResultTypes,
photonSearchUrl,
selectPhotonFeature,
selectQualifiedFeature,
} from './keylessGeocoder.js';
import { geocodeNavigationMode } from './locations.js';
@@ -288,6 +289,245 @@ test('a "place, region" query is matched on the place, not the region', async ()
assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`);
});
/** Photon's real answers for "Paris, Texas" biased to Denver, then unbiased. */
const PARIS_TX = {
geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] },
properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Paris', state: 'Texas', country: 'United States', countrycode: 'US' },
};
const PARIS_TEXAS_PUB = {
geometry: { type: 'Point', coordinates: [19.0606, 47.4868] },
properties: { osm_key: 'amenity', osm_value: 'pub', type: 'house', name: 'Paris, Texas', city: 'Budapest', country: 'Magyarország', countrycode: 'HU' },
};
const PARIS_TEXAS_SALON = {
geometry: { type: 'Point', coordinates: [153.03, -27.47] },
properties: { osm_key: 'shop', osm_value: 'hairdresser', type: 'house', name: 'Paris Texas', city: 'Brisbane', state: 'Queensland', country: 'Australia', countrycode: 'AU' },
};
test('"Paris, Texas" is the town in Texas, not a pub of that name in Budapest', async () => {
// Measured from Denver against the live service: the biased answer listed the
// town first, but the pub's name equals the whole query and won, flying the
// camera to Budapest at rooftop range.
const fetchImpl = photonPair([PARIS_TX, PARIS_TEXAS_PUB, PARIS_TEXAS_SALON], [PARIS_TX]);
const result = await geocodeKeyless('Paris, Texas', { bias: '39.70,-104.90|39.80,-104.80', fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region is checked against where a candidate is, not its name', () => {
const pubFirst = [PARIS_TEXAS_PUB, PARIS_TEXAS_SALON, PARIS_TX];
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas'), PARIS_TX);
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, United States'), PARIS_TX);
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, US'), PARIS_TX);
// No region, or a region nothing lies in, leaves the choice to the name rules.
assert.equal(selectQualifiedFeature(pubFirst, 'Paris'), null);
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Ontario'), null);
assert.equal(selectQualifiedFeature(undefined, 'Paris, Texas'), null);
});
test('a region nearby fits nothing, so the unbiased answer decides', async () => {
const fetchImpl = photonPair([PARIS_TEXAS_PUB], [PARIS_TX, PARIS_TEXAS_PUB]);
const result = await geocodeKeyless('Paris, Texas', { bias: '47.40,19.00|47.50,19.10', fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 2);
});
test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby answer', async () => {
const pubFirst = [PARIS_TEXAS_PUB, PARIS_TX];
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas, USA'), PARIS_TX);
// "Paris," names no region: the camera-biased pass still answers.
const fetchImpl = photonPair([PARIS_TX], []);
const result = await geocodeKeyless('Paris,', {
bias: '33.60,-95.60|33.70,-95.50',
fetchImpl,
});
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region match takes the place itself, not a place whose name starts with it', () => {
const station = {
geometry: { type: 'Point', coordinates: [4.86, 45.76] },
properties: {
osm_key: 'railway',
name: 'Paris Gare de Lyon',
city: 'Paris',
country: 'France',
countrycode: 'FR',
},
};
const paris = {
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
properties: {
osm_key: 'place',
name: 'Paris',
state: 'Île-de-France',
country: 'France',
countrycode: 'FR',
},
};
assert.equal(selectQualifiedFeature([station, paris], 'Paris, France'), paris);
assert.equal(selectQualifiedFeature([station], 'Paris, France'), null);
});
test('"UK" names the United Kingdom', () => {
const london = {
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
properties: {
osm_key: 'place',
name: 'London',
country: 'United Kingdom',
countrycode: 'GB',
},
};
const ontario = {
geometry: { type: 'Point', coordinates: [-81.25, 42.98] },
properties: {
osm_key: 'place',
name: 'London',
state: 'Ontario',
country: 'Canada',
countrycode: 'CA',
},
};
assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london);
});
test('a nearby place whose own name has a comma still wins when nothing matches as place and region', async () => {
const store = (lon, lat, city) => ({
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
osm_key: 'shop',
osm_value: 'houseware',
name: 'Bed, Bath & Beyond',
city,
countrycode: 'US',
},
});
const nearby = store(-104.94, 39.66, 'Denver');
const fetchImpl = photonPair([nearby], [store(-73.99, 40.74, 'New York')]);
const result = await geocodeKeyless('Bed, Bath & Beyond', {
bias: '39.60,-105.00|39.70,-104.90',
fetchImpl,
});
assert.match(result.label, /Denver/);
// Main answered this from the nearby pass alone. The second request is the
// accepted cost of checking the region unbiased first.
assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first');
});
test('the nearby whole-name fallback takes an exact name, not one that only starts with the query', async () => {
const place = (name, lon, lat, props) => ({
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { osm_key: 'place', name, ...props },
});
const nearby = [
place('Springfield IL Auto', -89.6, 39.8, {
osm_key: 'shop',
city: 'Springfield',
state: 'Illinois',
countrycode: 'US',
}),
];
const anywhere = [
place('Springfield', -89.65, 39.78, { state: 'Illinois', countrycode: 'US' }),
];
const fetchImpl = photonPair(nearby, anywhere);
const result = await geocodeKeyless('Springfield, IL', {
bias: '39.70,-89.70|39.90,-89.50',
fetchImpl,
});
assert.doesNotMatch(result.label, /Auto/);
assert.match(result.label, /^Springfield/);
});
test('a region query without a camera bias still takes the place in the region', async () => {
// Unbiased, Photon can list the pub first; main took it because its name
// starts with "Paris".
const fetchImpl = photonPair([], [PARIS_TEXAS_PUB, PARIS_TX]);
const result = await geocodeKeyless('Paris, Texas', { fetchImpl });
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
});
test('a region is matched by whole words, and every word has to fit', () => {
const londonUk = {
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
properties: { osm_key: 'place', name: 'London', state: 'England', country: 'United Kingdom', countrycode: 'GB' },
};
// "on" is inside "london" and "england", but it is not a word of either.
assert.equal(selectQualifiedFeature([londonUk], 'London, ON'), null);
// "Texas" fits, "Ontario" does not, so the town does not qualify.
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Texas, Ontario'), null);
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Tex'), null);
});
test('a region can be the city or the county a candidate lies in', () => {
const street = {
geometry: { type: 'Point', coordinates: [-105.27, 40.02] },
properties: { osm_key: 'highway', name: 'Main Street', city: 'Boulder', countrycode: 'US' },
};
const town = {
geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] },
properties: { osm_key: 'place', name: 'Paris', county: 'Lamar', countrycode: 'US' },
};
assert.equal(selectQualifiedFeature([street], 'Main Street, Boulder'), street);
assert.equal(selectQualifiedFeature([town], 'Paris, Lamar'), town);
});
test('when a POI and the place both fit the region, the place wins', async () => {
// A cafe named "Paris" in Lyon is in France too. Nearby it can rank above the
// city, and it would frame at rooftop range.
const cafe = {
geometry: { type: 'Point', coordinates: [4.84, 45.76] },
properties: { osm_key: 'amenity', osm_value: 'cafe', name: 'Paris', city: 'Lyon', country: 'France', countrycode: 'FR' },
};
const paris = {
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'Paris', state: 'Île-de-France', country: 'France', countrycode: 'FR' },
};
const fetchImpl = photonPair([cafe, paris], [paris]);
const result = await geocodeKeyless('Paris, France', { bias: '45.70,4.80|45.80,4.90', fetchImpl });
assert.equal(Math.round(result.lat), 49, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 1);
// A POI alone still qualifies: "Main Street, Denver" names a street.
assert.equal(selectQualifiedFeature([cafe], 'Paris, France'), cafe);
});
test('if the unbiased request fails, the nearby whole-name match is still the answer', async () => {
const store = {
geometry: { type: 'Point', coordinates: [-104.94, 39.66] },
properties: { osm_key: 'shop', name: 'Bed, Bath & Beyond', city: 'Denver', countrycode: 'US' },
};
const urls = [];
const fetchImpl = (url) => {
urls.push(url);
if (!new URL(url).searchParams.has('lat')) return Promise.resolve({ ok: false, status: 503 });
return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [store] }) });
};
const outcome = await geocodeKeylessWithOutcome('Bed, Bath & Beyond', {
bias: '39.60,-105.00|39.70,-104.90',
fetchImpl,
cache: new Map(),
});
assert.match(outcome.place.label, /Denver/);
assert.equal(outcome.answered, true);
assert.equal(urls.length, 2);
});
test('accents and punctuation do not change what a name compares as', () => {
assert.equal(normalizeToponym('Huế'), 'hue');
assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay');
@@ -453,7 +693,6 @@ function readGeocodingConsumer(file) {
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n');
}
test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => {
for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) {
const params = new URL(photonSearchUrl('island', { bias })).searchParams;
+19 -32
View File
@@ -38,33 +38,21 @@ export const DIRECTIONS_MODES = Object.freeze({
export const DEFAULT_DIRECTIONS_MODE = 'car';
/**
* The route proxy's refusal texts (server/providers/places/routes.js). They
* stay English on the wire and in the AI tools; the row shows them translated.
* The row error for a failed route request (createDirectionsSource errors),
* as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS
* in src/ui/statusText.js).
*/
const ROUTING_REFUSALS = new Set([
msg('rate limited'),
msg('routing service is rate limited'),
msg('route leg too long'),
msg('route too long'),
]);
/** A refusal text from the route proxy in the language; unknown ones as given. */
export function presentRoutingRefusal(text) {
return ROUTING_REFUSALS.has(text) ? t(text) : text;
}
/** The row text for a failed route request (createDirectionsSource errors). */
export function presentRoutingFailure(error) {
if (error?.status === 429) {
return error.refusal
? t('{error} — try again in a moment', {
error: presentRoutingRefusal(error.refusal),
})
: t('Routing is rate limited — try again in a moment');
? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
`${error.refusal} — try again in a moment`
: msg('Routing is rate limited — try again in a moment');
}
if (Number.isFinite(error?.status) && error.status > 0)
return t('Routing unavailable (HTTP {status})', { status: error.status });
return error?.message || t('Routing unavailable');
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
return `Routing unavailable (HTTP ${error.status})`;
return error?.message || msg('Routing unavailable');
}
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
@@ -659,7 +647,9 @@ export function directionsStats(state) {
return {
count: route?.steps.length || 0,
lastUpdate,
error: t('Another map tool is using clicks — close it, then SET A again'),
error: msg(
'Another map tool is using clicks — close it, then SET A again',
),
status: 'empty',
source,
};
@@ -678,7 +668,7 @@ export function directionsStats(state) {
return {
count: 0,
lastUpdate,
error: error || t('No route found'),
error: error || msg('No route found'),
status: 'empty',
source,
};
@@ -1160,13 +1150,10 @@ export function createDirectionsLayer({ services, source }) {
clearRouteGraphics();
_status = 'error';
_error =
payload?.error === 'no route found'
? t('No route found between A and B')
: payload?.error
? t('Routing failed: {error}', {
error: presentRoutingRefusal(payload.error),
})
: t('No route found between A and B');
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
payload?.error && payload.error !== 'no route found'
? `Routing failed: ${payload.error}`
: msg('No route found between A and B');
return;
}
_route = route;
@@ -1183,9 +1170,9 @@ export function createDirectionsLayer({ services, source }) {
// happened instead.
_error =
error?.name === 'AbortError'
? t('Routing timed out')
? msg('Routing timed out')
: error?.name === 'TypeError'
? t('Routing unavailable — no response from the routing service')
? msg('Routing unavailable — no response from the routing service')
: presentRoutingFailure(error);
} finally {
clearTimeout(timer);
+121
View File
@@ -0,0 +1,121 @@
// Directions reports its fixed errors as English data (getStats(), and so the
// layer snapshot the voice tools and the analyst read); the Layers panel row
// shows them in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t as translate } from '../../i18n/index.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { directionsStats, presentRoutingFailure } from './index.js';
const idle = {
enabled: true,
mode: 'car',
armed: null,
a: null,
b: null,
status: 'idle',
error: null,
route: null,
lastUpdate: null,
};
const cases = [
[
{ ...idle, pointerBlocked: true },
'Another map tool is using clicks — close it, then SET A again',
],
[{ ...idle, status: 'error' }, 'No route found'],
[
{ ...idle, status: 'error', error: 'Routing timed out' },
'Routing timed out',
],
];
function rowFor(stats) {
return LayerPanel.prototype._buildMetaText({
id: 'directions',
enabled: true,
stats,
});
}
// Route request failures with the proxy's refusal or an HTTP status in them:
// [the request error, its English row error, the row text in the language]
const failures = () => [
[
{ status: 429, refusal: 'route leg too long' },
'route leg too long — try again in a moment',
translate('{error} — try again in a moment', {
error: translate('route leg too long'),
}),
],
[
{ status: 429, refusal: null },
'Routing is rate limited — try again in a moment',
translate('Routing is rate limited — try again in a moment'),
],
[
{ status: 503 },
'Routing unavailable (HTTP 503)',
translate('Routing unavailable (HTTP {status})', { status: 503 }),
],
[
{ status: 0, message: '' },
'Routing unavailable',
translate('Routing unavailable'),
],
];
for (const locale of ['es', 'ru']) {
test(`${locale}: Directions errors stay English in stats and read translated in the row`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
for (const [state, english] of cases) {
const stats = directionsStats(state);
assert.equal(stats.error, english);
const row = rowFor(stats);
assert.ok(row.includes(translate(english)), `${locale}: ${row}`);
assert.ok(!row.includes(english), `${locale}: ${row}`);
}
});
}
for (const locale of ['es', 'ru']) {
test(`${locale}: route request failures stay English in stats and read translated in the row`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
for (const [failure, english, shown] of failures()) {
const error = presentRoutingFailure(failure);
assert.equal(error, english);
const stats = directionsStats({ ...idle, status: 'error', error });
assert.equal(stats.error, english);
assert.ok(rowFor(stats).includes(shown), `${locale}: ${rowFor(stats)}`);
assert.notEqual(shown, english);
}
// The proxy's refusal after "Routing failed:", and a text it never sends.
for (const [refusal, shownRefusal] of [
['route too long', translate('route too long')],
['something else', 'something else'],
]) {
const row = rowFor(
directionsStats({
...idle,
status: 'error',
error: `Routing failed: ${refusal}`,
}),
);
assert.ok(
row.includes(
translate('Routing failed: {error}', { error: shownRefusal }),
),
`${locale}: ${row}`,
);
}
});
}
test('en: the Directions row shows the English error', () => {
setLocale('en');
const row = rowFor(directionsStats({ ...idle, status: 'error' }));
assert.match(row, /No route found/);
});
+5 -3
View File
@@ -56,10 +56,12 @@ const DEFAULT_DISPLAY = Object.freeze({
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
*
* Two inputs feed it. The receiver is any object with `getState()` returning
* `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`,
* `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`,
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
* `src/sources/adsbRecords.js`, and the optional `errorText` is the English
* text of an `error` status (`message`, the UI-language one, stands in for a
* receiver without it). The browser WebUSB session is one such receiver.
* The optional `feeds` session (`./feeds.js`) supplies records read
* by the server from local 1090/978 MHz decoders; it polls only while this
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
+1 -1
View File
@@ -51,7 +51,7 @@ function feedsConfigured(feedState) {
function webUsbStatus(receiver, heard, feedState) {
if (receiver.status === 'error')
return { status: 'error', error: receiver.message };
return { status: 'error', error: receiver.errorText ?? receiver.message };
if (receiver.status === 'connecting' || receiver.status === 'tuning')
return { loading: true, loadingLabel: msg('opening receiver') };
if (
+44 -1
View File
@@ -2,7 +2,8 @@
// "USB error", feed problems, even as the whole error) read in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import { setLocale, t } from '../../i18n/index.js';
import { SdrController } from '../../sdr/controller.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { localAdsbStatus } from './status.js';
@@ -66,3 +67,45 @@ for (const locale of ['es', 'ru']) {
});
}
}
// A receiver error is English data for the layer (stats, and what the voice
// tools and the analyst read); the Radio card and the row show it translated.
for (const locale of ['es', 'ru']) {
test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => {
const receiver = new SdrController({
storage: null,
webUsb: {},
providerFactory: () => ({
get: async () => {
throw new Error('No device was selected.');
},
}),
});
setLocale(locale);
try {
assert.equal(await receiver.connect('adsb'), false);
const state = receiver.getState();
assert.equal(state.status, 'error');
assert.equal(state.errorText, 'No RTL-SDR device selected');
assert.equal(state.message, t('No RTL-SDR device selected'));
assert.notEqual(state.message, state.errorText);
const status = localAdsbStatus({
receiver: state,
feedState: null,
heard: 0,
});
assert.equal(status.error, 'No RTL-SDR device selected');
const row = rowFor(status, 0);
assert.ok(row.includes(state.message), row);
assert.doesNotMatch(row, /No RTL-SDR device selected/, row);
// The English text goes with the error: stop() clears it.
await receiver.stop();
assert.equal(receiver.getState().errorText, null);
} finally {
setLocale('en');
await receiver.destroy();
}
});
}
+22 -3
View File
@@ -55,6 +55,8 @@ export function createFirePerimetersLayer({
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
// The proxy's last snapshot, served while WFIGS is unreachable.
let _stale = false;
let _lastError = null;
let _enabled = false;
let _clickHandler = null;
@@ -260,6 +262,7 @@ export function createFirePerimetersLayer({
_count = 0;
_lastUpdate = null;
_lastError = null;
_stale = false;
_enabled = false;
console.log('[Data:FirePerimeters] Initialized');
},
@@ -287,9 +290,21 @@ export function createFirePerimetersLayer({
_request = request;
refreshInciwebIndex(request.signal);
try {
const rows = await source.getSnapshot({ signal: request.signal });
// The source contract is getSnapshot; a source that can also say how
// fresh its rows are (the WFIGS proxy) offers getSnapshotDetail.
const snapshot =
typeof source.getSnapshotDetail === 'function'
? await source.getSnapshotDetail({ signal: request.signal })
: { rows: await source.getSnapshot({ signal: request.signal }) };
if (request.signal.aborted || _request !== request || !_enabled)
return false;
const { rows } = snapshot;
_stale = snapshot.stale === true;
// A stale snapshot is as old as its fetch, not this poll.
const updatedAt =
_stale && Number.isFinite(snapshot.fetchedAt)
? snapshot.fetchedAt
: Date.now();
// Incident facts may change independently of the polygon's source clock.
const signature = JSON.stringify(
@@ -299,7 +314,7 @@ export function createFirePerimetersLayer({
);
if (signature === _snapshotSignature) {
if (_selectedId) publishSelectedCard();
_lastUpdate = Date.now();
_lastUpdate = updatedAt;
_lastError = null;
return true;
}
@@ -355,7 +370,7 @@ export function createFirePerimetersLayer({
// Refresh (or drop) the selected incident's card against the new feed.
if (_selectedId) publishSelectedCard();
_count = rows.length;
_lastUpdate = Date.now();
_lastUpdate = updatedAt;
_lastError = null;
console.log(
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
@@ -366,6 +381,8 @@ export function createFirePerimetersLayer({
return false;
console.warn('[Data:FirePerimeters] Fetch error:', e);
_lastError = e?.message || msg('Perimeter source unavailable');
// A failure now outranks the stale snapshot still drawn.
_stale = false;
return false;
} finally {
if (_request === request) _request = null;
@@ -391,6 +408,7 @@ export function createFirePerimetersLayer({
_count = 0;
_lastUpdate = null;
_lastError = null;
_stale = false;
},
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
@@ -443,6 +461,7 @@ export function createFirePerimetersLayer({
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
stale: _stale,
};
},
};
+33
View File
@@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) {
if (action !== 'destroy') h.layer.destroy();
});
}
test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clears it', async () => {
const fetchedAt = Date.now() - 3 * 60 * 60 * 1000;
let snapshot = { rows: [row], stale: true, fetchedAt };
const h = harness({
getSnapshot: async () => snapshot.rows,
getSnapshotDetail: async () => snapshot,
});
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, true);
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, false);
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
});
test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => {
let fail = false;
const h = harness({
getSnapshot: async () => [row],
getSnapshotDetail: async () => {
if (fail) throw new Error('WFIGS HTTP 502');
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
},
});
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, true);
fail = true;
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, false);
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
});
+28 -13
View File
@@ -8,20 +8,35 @@ const msg = (text) => text;
export function createWfigsPerimeterSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
/**
* The rows with how fresh they are: the proxy answers from its last
* snapshot, marked stale, while WFIGS is unreachable.
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
*/
async function getSnapshotDetail({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters', { signal });
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error(msg('Malformed perimeter snapshot'));
return {
rows: payload.rows,
stale: payload.stale === true,
fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null,
};
}
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters', { signal });
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error(msg('Malformed perimeter snapshot'));
return payload.rows;
/** The perimeter rows (see getSnapshotDetail for staleness). */
async getSnapshot(options = {}) {
return (await getSnapshotDetail(options)).rows;
},
getSnapshotDetail,
};
}
+64
View File
@@ -115,3 +115,67 @@ test('response-body completion honors cancellation without replacing records', a
name: 'AbortError',
});
});
test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => {
let clock = 1_000_000_000;
let up = true;
const plugin = firePerimetersProxy({
now: () => clock,
fetchImpl: async () => {
if (!up) throw new Error('WFIGS unreachable');
return Response.json(validPayload);
},
});
let handler;
plugin.configureServer({
middlewares: { use: (_path, callback) => (handler = callback) },
});
const ask = async () => {
const reply = {};
await handler(
{ url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } },
{
writeHead(status, headers) {
reply.status = status;
reply.staleHeader = headers?.['X-Data-Stale'] === 'true';
},
end(body) {
reply.body = JSON.parse(body);
},
},
);
return reply;
};
const fresh = await ask();
assert.equal(fresh.status, 200);
assert.equal(fresh.body.stale, undefined);
up = false;
clock += 23 * 60 * 60 * 1000;
const stale = await ask();
assert.equal(stale.status, 200);
assert.equal(stale.staleHeader, true);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
clock += 2 * 60 * 60 * 1000;
assert.equal((await ask()).status, 502, 'a day-old snapshot is not served');
});
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
const fetchedAt = Date.UTC(2026, 9, 25, 12);
const source = createWfigsPerimeterSource({
fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }),
});
assert.deepEqual(await source.getSnapshotDetail(), {
rows: [],
stale: true,
fetchedAt,
});
assert.deepEqual(await source.getSnapshot(), []);
});
test('getSnapshot works when taken off its source object', async () => {
const { getSnapshot } = createWfigsPerimeterSource({
fetchImpl: async () => Response.json({ rows: [] }),
});
assert.deepEqual(await getSnapshot(), []);
});
+15
View File
@@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) {
liveMode: layerState._liveMode,
fetching: loading,
flowError: layerState._flowError,
stale: layerState._flowStale,
coveragePct: flowCoveragePct,
statusUnavailable: layerState._flowStatusUnavailable,
roadSource: layerState._roadSource,
@@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) {
lastUpdate: layerState._lastUpdate,
loading,
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:
layerState._roadError ||
layerState._roadWarning ||
+11 -2
View File
@@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) {
return layerState._warmFlow;
layerState._warmFlowGeneration = generation;
const signal = layerState._activeFetchAbort?.signal;
layerState._warmFlow = ensureFlowStatus(signal).then(() => {
layerState._warmFlow = ensureFlowStatus(signal).then(async () => {
if (!layerState._liveMode || generation !== layerState._loadGeneration)
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(() => {});
return layerState._warmFlow;
+4
View File
@@ -42,6 +42,8 @@ export function createLifecycle({
layerState._lastViewCenter = null;
layerState._flowRoads = null;
layerState._flowError = null;
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
if (TRAFFIC_TIMING_ENABLED) {
layerState._trafficTimingCurrentAnchor = null;
layerState._trafficTimingSequence = 0;
@@ -159,6 +161,8 @@ export function createLifecycle({
// A stale outage from the last session would misreport a fresh enable —
// the next load re-derives feed health from real evidence.
layerState._flowError = null;
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
layerState._surfaceFrameRemover?.();
layerState._surfaceFrameRemover = null;
+12
View File
@@ -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.fetching] - A viewport load is in flight.
* @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 {boolean} [input.statusUnavailable] - The status probe itself failed.
* @param {string} [input.roadSource] - Name of the geometry being drawn.
@@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) {
liveMode = false,
fetching = false,
flowError = null,
stale = false,
coveragePct = 0,
statusUnavailable = false,
roadSource = 'OpenStreetMap',
@@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) {
});
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.
if (liveMode && roadSource === 'TomTom') {
return {
+58
View File
@@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated'
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 () => {
const { createModel } = await import('./model.js');
const { trafficFeedPresentation: present } = createModel({
+10
View File
@@ -108,6 +108,16 @@ export function createState({ services }) {
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
* simulating because it could not ask — not because the server said "no key".
+3
View File
@@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
layerState._fetching = false;
layerState._flowPending = 0;
layerState._roadError = null;
// Nothing is drawn up here, cached or not.
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
parts.animation.clearDots();
layerState._lastBounds = null;
layerState._lastViewCenter = null;
+114 -17
View File
@@ -153,6 +153,8 @@ export class SceneDirector {
this._runAbort = null;
/** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */
this._loadAbort = null;
/** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */
this._seekAbort = null;
this._clock = createPlaybackClock({
isRunning: () => this._running,
timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot),
@@ -234,7 +236,7 @@ export class SceneDirector {
destroy() {
if (this._destroyPromise) return this._destroyPromise;
this._destroyed = true;
this._sceneSeekGeneration++;
this._bumpSceneSeekGeneration();
this._visibilityUnsubscribe?.();
this._cameraHandoffUnsubscribe?.();
this._removeCameraInput?.();
@@ -1071,7 +1073,7 @@ export class SceneDirector {
loadShot(sceneId, shotId, options) {
if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++;
this._bumpSceneSeekGeneration();
this._interactionTransitions = 0;
const previousGeneration = this._loadGeneration;
const work = this._loadShot(sceneId, shotId, options);
@@ -1516,9 +1518,30 @@ export class SceneDirector {
return new Promise((resolve) => this._runIdleResolvers.add(resolve));
}
/** Apply a new time inside the already-loaded shot without rebuilding its stack. */
_seekLoadedShot(scene, seekState) {
if (this._destroyed) return false;
/**
* Supersede any pending seek. Only a seek's own layer reconcile is aborted:
* a request that is later rejected must not cancel an in-flight LOAD.
*/
_bumpSceneSeekGeneration() {
if (this._seekAbort) {
this._seekAbort.abort();
if (this._loadAbort === this._seekAbort) this._loadAbort = null;
this._seekAbort = null;
}
return ++this._sceneSeekGeneration;
}
/**
* Apply a new time inside the already-loaded shot without rebuilding its stack.
* @returns {Promise<boolean|null>} Null when the shortcut does not apply and
* LOAD must rebuild the shot; otherwise whether the seek settled.
*/
async _seekLoadedShot(
scene,
seekState,
seekGeneration = this._sceneSeekGeneration,
) {
if (this._destroyed) return null;
const shot = seekState?.shot;
if (
!scene ||
@@ -1528,17 +1551,77 @@ export class SceneDirector {
this._loadedSceneId !== scene.id ||
this._selectedShotId !== shot.id
)
return null;
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
return false;
if (!this._claimCameraOwnership()) return false;
this._cancelActiveSceneTravel();
this._loadAbort?.abort();
this._loadAbort = null;
this._loadGeneration += 1;
const generation = ++this._loadGeneration;
this._clock.stopShot();
const states = this._layerStatesForShot(scene, shot, {
cameraSettled: seekState.cameraProgress >= 1,
sceneSeek: seekState,
});
// A manual toggle, or a superseded LOAD transition whose rollback is still
// settling, leaves declared layers off the shot's stack. Reconcile only
// those through the cancellable admission path.
const staleStates = Object.fromEntries(
Object.entries(states)
.filter(([id, state]) => {
const wantEnabled = !!state?.enabled;
const lifecycle = this.dataManager.getLayerLifecycleState(id);
return (
lifecycle &&
(lifecycle.enabled !== wantEnabled ||
lifecycle.lifecycleState === 'enabling' ||
lifecycle.lifecycleState === 'disabling' ||
lifecycle.uncertain ||
this.dataManager.isEffectivelyEnabled(id) !== wantEnabled)
);
})
.map(([id, state]) => [id, { enabled: !!state?.enabled }]),
);
if (Object.keys(staleStates).length) {
const previousEnabled = new Map(
Object.keys(staleStates).map((id) => [
id,
this.dataManager.isEffectivelyEnabled(id),
]),
);
const controller = new AbortController();
this._loadAbort = controller;
this._seekAbort = controller;
const layerResult = await this._applyLayerStates(
staleStates,
this._loadToken(generation, controller.signal, seekGeneration),
{ stopOnRefusal: true, exitContextMode: false },
);
if (this._loadAbort === controller) this._loadAbort = null;
if (this._seekAbort === controller) this._seekAbort = null;
const superseded = () =>
this._destroyed ||
this._loadAbort ||
this._running ||
seekGeneration !== this._sceneSeekGeneration;
if (layerResult.cancelled || superseded()) return false;
if (layerResult.refused.length) {
// Only a refusal rolls back. A cancelled reconcile leaves layer state
// to whatever superseded it, as a cancelled LOAD does.
for (const id of [...layerResult.applied].reverse()) {
if (superseded()) break;
await this.dataManager.setEnabled(
id,
previousEnabled.get(id) ?? false,
{ origin: 'scene' },
);
}
return false;
}
}
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
return false;
let applied = true;
for (const [layerId, state] of Object.entries(states)) {
if (!state?.enabled || !state.params) continue;
@@ -1550,7 +1633,9 @@ export class SceneDirector {
applied = false;
}
}
if (!applied) return false;
if (!applied) return null;
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
return false;
this._setCameraView(
seekState.camera || resolveCameraPose(scene, shot.camera),
);
@@ -1572,7 +1657,7 @@ export class SceneDirector {
seekScene(sceneId, progress) {
if (this._destroyed) return Promise.resolve(false);
this.stopScene(msg('Seeking scene clock'));
const generation = ++this._sceneSeekGeneration;
const generation = this._bumpSceneSeekGeneration();
return this._trackWork(this._seekScene(sceneId, progress, generation));
}
@@ -1586,7 +1671,10 @@ export class SceneDirector {
return false;
const seekState = this._sceneSeekState(scene, progress);
if (!seekState) return false;
if (this._seekLoadedShot(scene, seekState)) return true;
const seeked = await this._seekLoadedShot(scene, seekState, generation);
if (this._destroyed || generation !== this._sceneSeekGeneration)
return false;
if (seeked !== null) return seeked;
const result = await this._loadShot(scene.id, seekState.shot.id, {
sceneSeek: seekState,
});
@@ -1606,15 +1694,17 @@ export class SceneDirector {
* @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls
* @returns {{ cancelled: boolean, signal: AbortSignal|undefined }}
*/
_loadToken(generation, signal = undefined) {
_loadToken(generation, signal = undefined, seekGeneration = null) {
const director = this;
return {
signal,
get cancelled() {
return (
director._destroyed ||
signal?.aborted ||
director._loadGeneration !== generation
Boolean(signal?.aborted) ||
director._loadGeneration !== generation ||
(seekGeneration !== null &&
director._sceneSeekGeneration !== seekGeneration)
);
},
};
@@ -1753,7 +1843,7 @@ export class SceneDirector {
startScene(sceneId, options) {
if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++;
this._bumpSceneSeekGeneration();
return this._trackWork(this._startScene(sceneId, options));
}
@@ -1919,7 +2009,7 @@ export class SceneDirector {
this._setSceneMediaPlayback();
this._interactions?.clear();
this._dataPacks?.clear();
this._sceneSeekGeneration++;
this._bumpSceneSeekGeneration();
this._loadAbort?.abort();
this._loadAbort = null;
this._loadGeneration++;
@@ -2165,7 +2255,11 @@ export class SceneDirector {
* the transition in flight.
* @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>}
*/
async _applyLayerStates(targetStates, token = null) {
async _applyLayerStates(
targetStates,
token = null,
{ stopOnRefusal = false, exitContextMode = true } = {},
) {
const applied = [];
const refused = [];
const abort = () => ({ applied, refused, cancelled: true });
@@ -2175,8 +2269,10 @@ export class SceneDirector {
// the operator's pre-mode state, which is exactly where a stopped scene
// should come to rest. Tearing that transaction in half would strand
// Context, so it completes and cancellation is honoured immediately after.
await this._exitIsolatingContextMode();
if (token?.cancelled) return abort();
if (exitContextMode) {
await this._exitIsolatingContextMode();
if (token?.cancelled) return abort();
}
const signal = token?.signal;
const registered = new Set(
@@ -2206,6 +2302,7 @@ export class SceneDirector {
console.warn(
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
);
if (stopOnRefusal) break;
continue;
}
if (params && typeof this.dataManager.restoreLayerState !== 'function') {
+362
View File
@@ -14,6 +14,7 @@ import { readShellSource } from '../testSupport/readShellSource.mjs';
import assert from 'node:assert/strict';
import test from 'node:test';
import { LayerLifecycle } from '../data/lifecycle.js';
import { SceneDirector } from './director.js';
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
@@ -174,6 +175,348 @@ test('scene clock seek resolves the exact shot phase and camera in both directio
}
});
test('same-shot seeking restores declared layer visibility after manual changes', async () => {
const { director, dataManager, styleManager, restore } = makeDirector();
try {
const shot = director._project.scenes[0].shots[0];
shot.layers = { flights: { enabled: true }, traffic: { enabled: false } };
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('flights', false);
await dataManager.setEnabled('traffic', true);
await dataManager.setEnabled('satellites', true);
dataManager.setEnabledCalls.length = 0;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), true);
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer');
assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer');
assert.equal(enabled.get('satellites'), true, 'preserve undeclared operator layers');
assert.ok(dataManager.setEnabledCalls.every(({ id }) => id !== 'satellites'));
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => {
let refuse = false;
const { director, dataManager, styleManager, restore } = makeDirector({
data: { refuse: () => refuse },
});
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false);
refuse = true;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false);
const timing = director.getPlaybackTimingState();
assert.equal(timing.activeTimers, 0);
assert.ok(Math.abs(timing.snapshot.sceneProgress - 0.1) < 1e-9, 'keep the last settled seek position');
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking restores visibility a manual toggle is still changing', async () => {
const { director, dataManager, restore } = makeDirector();
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
const disabling = dataManager.setEnabled('flights', false);
assert.equal(await director.seekScene('scene-1', 0.25), true);
await disabling;
await settle();
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => {
const { director, dataManager, styleManager, viewer, restore } = makeDirector();
const lifecycle = new LayerLifecycle(viewer);
for (const id of ['flights', 'traffic', 'satellites']) {
lifecycle.register({
id,
name: id,
init() {},
enable() {},
disable() {},
update() {},
});
}
const realDirector = new SceneDirector(viewer, styleManager, lifecycle);
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('flights', false);
dataManager.setEnabledCalls.length = 0;
const reload = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (let i = 0; i < 20 && !dataManager.setEnabledCalls.length; i++) await Promise.resolve();
assert.equal(dataManager.getLayerLifecycleState('flights').lifecycleState, 'enabling');
assert.equal(await director.seekScene('scene-1', 0.25), true);
await reload;
await settle();
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
await realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await lifecycle.setEnabled('flights', false);
const queuedReload = realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (let i = 0; i < 20 && !lifecycle.isEffectivelyEnabled('flights'); i++) {
await Promise.resolve();
}
assert.deepEqual(lifecycle.getLayerLifecycleState('flights'), {
enabled: false,
lifecycleState: 'disabled',
uncertain: false,
});
assert.equal(await realDirector.seekScene('scene-1', 0.25), true);
await queuedReload;
await settle();
assert.equal(lifecycle.isEnabled('flights'), true);
lifecycle.layers.get('flights').lifecycleUncertain = true;
assert.equal(await realDirector.seekScene('scene-1', 0.3), true);
assert.equal(lifecycle.getLayerLifecycleState('flights').uncertain, false);
let navClaims = 0;
styleManager.runImmediateNavigation = () => {
navClaims += 1;
return false;
};
assert.equal(await realDirector.seekScene('scene-1', 0.4), false);
assert.equal(navClaims, 1, 'camera refusal fails the seek without falling back to LOAD');
} finally {
await realDirector.destroy();
await director.destroy();
restore();
}
});
test('same-shot seeking rolls back earlier layers and preserves Context, clock and camera when a later layer is refused', async () => {
let refuse = () => false;
const { director, dataManager, styleManager, viewer, restore } = makeDirector({
data: { refuse: (id, on) => refuse(id, on) },
});
try {
const shot = director._project.scenes[0].shots[0];
shot.layers = {
flights: { enabled: true },
traffic: { enabled: true },
satellites: { enabled: true },
};
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false);
await dataManager.setEnabled('traffic', false);
await dataManager.setEnabled('satellites', false);
styleManager.contextMode = 'space-missions';
styleManager.contextExits.length = 0;
refuse = (id) => id === 'traffic';
dataManager.setEnabledCalls.length = 0;
const viewsBefore = viewer.views.length;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(
dataManager.setEnabledCalls.some(({ id }) => id === 'satellites'),
false,
'stop the plan before later ids are written',
);
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
assert.equal(enabled.get('flights'), false, 'first stale layer stays disabled');
assert.equal(enabled.get('traffic'), false, 'refused second layer stays disabled');
assert.equal(enabled.get('satellites'), false, 'later stale layer is never written');
assert.equal(styleManager.contextMode, 'space-missions');
assert.deepEqual(styleManager.contextExits, []);
assert.equal(viewer.views.length, viewsBefore, 'camera stays at the pre-seek pose');
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
assert.ok(
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
'clock stays at the last settled seek position',
);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking aborts in-flight admission when superseded by an early-return load or start', async () => {
const { styleManager, viewer, restore } = makeDirector();
let holdEnable = false;
let releaseEnable;
const enableGate = new Promise((resolve) => {
releaseEnable = resolve;
});
const lifecycle = new LayerLifecycle(viewer);
lifecycle.register({
id: 'flights',
name: 'flights',
init() {},
async enable() {
if (holdEnable) await enableGate;
},
disable() {},
update() {},
});
const director = new SceneDirector(viewer, styleManager, lifecycle);
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await lifecycle.setEnabled('flights', false);
holdEnable = true;
const viewsBefore = viewer.views.length;
const seek = director.seekScene('scene-1', 0.25);
for (
let i = 0;
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
i++
) {
await Promise.resolve();
}
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
const admissionSignal = director._loadAbort?.signal;
assert.ok(admissionSignal, 'same-shot seek installs an admission controller');
const missingLoad = director.loadShot('missing', 'nope');
assert.equal(
admissionSignal.aborted,
true,
'missing load aborts the admission controller before returning',
);
assert.equal((await missingLoad).reason, 'shot-not-found');
assert.equal(viewer.views.length, viewsBefore, 'shortcut has not committed before abort');
releaseEnable();
assert.equal(await seek, false);
await settle();
assert.equal(lifecycle.isEnabled('flights'), false, 'layer is not restored');
assert.equal(viewer.views.length, viewsBefore, 'setView is not called');
assert.ok(
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
'sceneProgress stays at the last settled value',
);
let loadFallbackCalls = 0;
const origLoadShot = director._loadShot.bind(director);
director._loadShot = (...args) => {
loadFallbackCalls += 1;
return origLoadShot(...args);
};
const origSeekLoadedShot = director._seekLoadedShot.bind(director);
director._seekLoadedShot = async () => {
await director.startScene('missing');
return null;
};
assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(loadFallbackCalls, 0, 'stale seek returning null must not start _loadShot');
director._seekLoadedShot = origSeekLoadedShot;
} finally {
releaseEnable?.();
await director.destroy();
restore();
}
});
test('a rejected loadShot does not cancel an in-flight LOAD', async () => {
const { styleManager, viewer, restore } = makeDirector();
let holdEnable = true;
let releaseEnable;
const enableGate = new Promise((resolve) => {
releaseEnable = resolve;
});
const lifecycle = new LayerLifecycle(viewer);
lifecycle.register({
id: 'flights',
name: 'flights',
init() {},
async enable() {
if (holdEnable) await enableGate;
},
disable() {},
update() {},
});
const director = new SceneDirector(viewer, styleManager, lifecycle);
try {
const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (
let i = 0;
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
i++
) {
await Promise.resolve();
}
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found');
holdEnable = false;
releaseEnable();
assert.equal((await load)?.started, true, 'the valid LOAD still lands');
assert.equal(lifecycle.isEnabled('flights'), true);
} finally {
releaseEnable?.();
await director.destroy();
restore();
}
});
test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => {
let refuse = () => false;
const { director, dataManager, restore } = makeDirector({
data: { refuse: (id, on) => refuse(id, on) },
});
try {
const shot = director._project.scenes[0].shots[0];
shot.layers = { flights: { enabled: true }, traffic: { enabled: true } };
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false);
await dataManager.setEnabled('traffic', false);
refuse = (id) => id === 'traffic';
const manualEnable = dataManager.setEnabled('flights', true);
assert.equal(await director.seekScene('scene-1', 0.25), false);
await manualEnable;
await settle();
assert.equal(
dataManager.getAll().find(({ id }) => id === 'flights').enabled,
true,
'rollback restores the operator\'s intended state, not the stale committed one',
);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => {
const { director, dataManager, styleManager, restore } = makeDirector();
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('satellites', true);
dataManager.setEnabledCalls.length = 0;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), true);
assert.deepEqual(dataManager.setEnabledCalls, []);
assert.equal(styleManager.visualCalls.length, visualCalls);
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
} finally {
await director.destroy();
restore();
}
});
test('scene clock subscribers receive authoritative forward playback snapshots', () => {
const { director, restore } = makeDirector();
const snapshots = [];
@@ -647,6 +990,7 @@ function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
*/
function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) {
const enabled = new Map();
const pending = new Map();
const setEnabledCalls = [];
const setParamsCalls = [];
const committed = [];
@@ -659,12 +1003,25 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
setParamsCalls,
committed,
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
getLayerLifecycleState: (id) => {
if (!registered.includes(id)) return null;
const on = !!enabled.get(id);
const target = pending.get(id);
return {
enabled: on,
lifecycleState:
target === undefined ? (on ? 'enabled' : 'disabled') : target ? 'enabling' : 'disabling',
};
},
isEffectivelyEnabled: (id) => pending.get(id) ?? !!enabled.get(id),
getLayerParams: () => null,
async setEnabled(id, shouldEnable, { signal } = {}) {
setEnabledCalls.push({ id, enabled: shouldEnable, signal });
if (refuse(id, shouldEnable)) return false;
// Yield once so a stop landing during the transition is observable.
pending.set(id, shouldEnable);
await Promise.resolve();
if (pending.get(id) === shouldEnable) pending.delete(id);
if (signal?.aborted) return false;
enabled.set(id, shouldEnable);
committed.push({ id, enabled: shouldEnable });
@@ -707,15 +1064,20 @@ function fakeStyleManager({ contextMode = null, exitFails = false } = {}) {
/** Cesium viewer double whose flights complete on the next microtask turn. */
function fakeViewer() {
const flights = [];
const views = [];
let cancelled = 0;
return {
flights,
views,
get cancelledFlights() { return cancelled; },
camera: {
flyTo(options) {
flights.push(options);
Promise.resolve().then(() => options.complete?.());
},
setView(options) {
views.push(options);
},
cancelFlight() { cancelled++; },
},
};
+34 -34
View File
@@ -283,22 +283,33 @@ function clampFmFrequency(value) {
);
}
/** A failed connection in English; errorState() shows it translated. */
function describeConnectionError(error) {
const message = String(error?.message || error || '').trim();
if (/No device was selected|NotFoundError/i.test(message))
return t('No RTL-SDR device selected');
return msg('No RTL-SDR device selected');
if (
/does not support the WebUSB|usb/i.test(message) &&
/support/i.test(message)
) {
return t('WebUSB requires desktop Chrome or Edge on localhost/HTTPS');
return msg('WebUSB requires desktop Chrome or Edge on localhost/HTTPS');
}
if (/access|permission|claim|busy/i.test(message)) {
return t(
return msg(
'RTL-SDR access failed; close other SDR apps and check USB permissions',
);
}
return message ? translateKnown(message) : t('RTL-SDR connection failed');
return message || msg('RTL-SDR connection failed');
}
/**
* State fields for a receiver error: `message` in the UI language for the
* Radio card, `errorText` as English data for the Local ADS-B layer's stats.
* A new connection, a mode switch and stop() clear `errorText` again.
* @param {string} text English error text (ours or the browser's).
*/
function errorState(text) {
return { status: 'error', message: translateKnown(text), errorText: text };
}
function streamingMessage(mode) {
@@ -350,6 +361,7 @@ export class SdrController {
gain: this._gainByMode.fm,
seeking: false,
seekMessage: '',
errorText: null,
snr: null,
aircraft: [],
decodedMessages: 0,
@@ -555,10 +567,7 @@ export class SdrController {
// A failed worker (e.g. its module did not load) never recovers: drop
// it so the next session builds and configures a fresh one.
this._discardWorker();
this._setState({
status: 'error',
message: t('SDR decoder worker failed'),
});
this._setState(errorState(msg('SDR decoder worker failed')));
if (this._device) void this.stop({ preserveMessage: true });
};
return worker;
@@ -779,16 +788,16 @@ export class SdrController {
// Published before stopping; stop() preserves it, and nothing is set
// afterwards, so a reconnect started during teardown keeps its own state.
this._setState({
status: 'error',
...errorState(
reason === 'startup'
? msg('SDR decoder did not start; receiver stopped')
: reason === 'stalled'
? msg('SDR decoder stopped responding; receiver stopped')
: msg('SDR decoder fell behind; receiver stopped'),
),
connected: false,
decoderOverload: reason,
decoderOverloadSnapshot: snapshot,
message:
reason === 'startup'
? t('SDR decoder did not start; receiver stopped')
: reason === 'stalled'
? t('SDR decoder stopped responding; receiver stopped')
: t('SDR decoder fell behind; receiver stopped'),
});
void this.stop({ preserveMessage: true });
}
@@ -1075,6 +1084,7 @@ export class SdrController {
this._setState({
status: 'connecting',
message: t('Waiting for an RTL-SDR device…'),
errorText: null,
});
let acquisition = null;
try {
@@ -1085,7 +1095,7 @@ export class SdrController {
// consumes the click activation.
if (nextMode === 'fm' && !(await this._ensureAudio())) {
throw new Error(
t('Browser audio is suspended; press Connect again to resume it'),
msg('Browser audio is suspended; press Connect again to resume it'),
);
}
if (superseded()) return false;
@@ -1153,8 +1163,7 @@ export class SdrController {
if (superseded()) return false;
this._setState({
connected: false,
status: 'error',
message: describeConnectionError(error),
...errorState(describeConnectionError(error)),
});
return false;
}
@@ -1507,8 +1516,7 @@ export class SdrController {
console.warn('[SDR] Sample read failed:', error);
this._setState({
connected: false,
status: 'error',
message: t('RTL-SDR sample stream stopped'),
...errorState(msg('RTL-SDR sample stream stopped')),
});
await this.stop({ preserveMessage: true });
break;
@@ -1549,11 +1557,12 @@ export class SdrController {
message: t('Switching to {mode}…', {
mode: nextMode === 'adsb' ? 'ADS-B' : 'FM',
}),
errorText: null,
});
try {
if (nextMode === 'fm' && !(await this._ensureAudio())) {
throw new Error(
t('Browser audio is suspended; select FM again to resume it'),
msg('Browser audio is suspended; select FM again to resume it'),
);
}
// A stall teardown, stop() or reconnect during the switch supersedes
@@ -1578,10 +1587,7 @@ export class SdrController {
} catch (error) {
if (!this._owns(token, device)) return false;
console.warn('[SDR] Mode switch failed:', error);
this._setState({
status: 'error',
message: describeConnectionError(error),
});
this._setState(errorState(describeConnectionError(error)));
return false;
}
}
@@ -1622,10 +1628,7 @@ export class SdrController {
if (targetMode === 'adsb') this._setState(this._resetAdsbCounters());
} catch (error) {
console.warn('[SDR] Gain change failed:', error);
this._setState({
status: 'error',
message: t('RTL-SDR gain change failed'),
});
this._setState(errorState(msg('RTL-SDR gain change failed')));
return null;
}
return setting;
@@ -1679,10 +1682,7 @@ export class SdrController {
});
return true;
} catch (error) {
this._setState({
status: 'error',
message: describeConnectionError(error),
});
this._setState(errorState(describeConnectionError(error)));
return false;
}
}
@@ -1873,7 +1873,7 @@ export class SdrController {
status: preserveMessage ? this.state.status : idleStatus,
...(preserveMessage
? {}
: { message: message || t('RTL-SDR disconnected') }),
: { message: message || t('RTL-SDR disconnected'), errorText: null }),
snr: null,
samplesPerSecond: 0,
workerBlocks: 0,
+18
View File
@@ -71,6 +71,7 @@ export function createApplicationRequestServices({
// Discovery is bounded; failed, malformed or timed-out probes are forgotten and not
// retried for a short backoff, during which queries go to the server as
// they would against an older server without the probe.
let summaryCapability = null;
let boundaryCapability = null;
let boundaryProbeRetryAt = 0;
function boundariesConfigured() {
@@ -175,6 +176,23 @@ export function createApplicationRequestServices({
},
},
summary: {
// The capability probe, asked once per page: true or false from a
// server that answers it, null (unknown) from one that does not.
configured() {
summaryCapability ??= request(`${urls.summary}/status`, {
signal: AbortSignal.timeout(3000),
}).then(
(response) =>
response.ok && typeof response.data?.configured === 'boolean'
? response.data.configured
: null,
(error) => {
if (lifetime?.aborted) throw error;
return null;
},
);
return summaryCapability;
},
async summarize(context, { signal } = {}) {
return request(urls.summary, {
method: 'POST',
+21
View File
@@ -94,3 +94,24 @@ test('a failed probe is retried after its backoff, not on every query', async ()
'GET /api/overpass/status',
]);
});
test('the summary capability probe is asked once and unknown when unanswered', async () => {
for (const [answer, expected] of [
[() => Response.json({ configured: false }), false],
[() => Response.json({ configured: true }), true],
[() => new Response('missing', { status: 404 }), null],
[() => Response.json({ configured: 'yes' }), null],
[() => { throw new TypeError('offline'); }, null],
]) {
const seen = [];
const services = createApplicationRequestServices({
fetchImpl: async (url, init) => {
seen.push(`${init.method || 'GET'} ${url}`);
return answer();
},
});
assert.equal(await services.summary.configured(), expected);
assert.equal(await services.summary.configured(), expected);
assert.deepEqual(seen, ['GET /api/openai/hud-summary/status']);
}
});
+153
View File
@@ -411,3 +411,156 @@ test('terrain middleware retains successful chunks around a failure and retries
);
assert.equal(requests.length, 4);
});
test('after a 429 the terrain proxy leaves the upstream alone and logs once', async (t) => {
isolate(t);
const warnings = [];
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('', { status: 429 });
});
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
const request = install(terrainHeightsProxy());
// The retries' backoff runs on mocked time.
let settled = false;
const first = request('/?points=1,2').then(() => {
settled = true;
});
for (let step = 0; step < 60 && !settled; step += 1) {
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(1_000);
}
assert.ok(settled, 'the first request settles within its retry budget');
await first;
const afterFirst = calls;
assert.ok(afterFirst >= 1);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /HTTP 429; pausing upstream for 60 s/);
// New points during the pause: no upstream call, no new log line.
const paused = await request('/?points=3,4');
assert.equal(calls, afterFirst);
assert.equal(paused.status, 502);
assert.equal(warnings.length, 1);
});
test('a far-off Retry-After pauses the terrain upstream for at most 10 minutes', async (t) => {
isolate(t);
t.mock.method(console, 'warn', () => {});
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let limited = true;
t.mock.method(globalThis, 'fetch', async () =>
limited
? new Response('', { status: 429, headers: { 'retry-after': '86400' } })
: Response.json({ results: [{ ellipsoid: 100 }] }),
);
const request = install(terrainHeightsProxy());
// The first request spends its retry budget on 429s, then the pause begins.
let settled = false;
const first = request('/?points=1,2').then((res) => {
settled = true;
return res;
});
// Bounded: the retry budget is 10 s, so 60 simulated seconds is plenty.
for (let step = 0; step < 60 && !settled; step += 1) {
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(1_000);
}
assert.ok(settled, 'the first request settles within its retry budget');
assert.equal((await first).status, 502);
limited = false;
const pausedAt = Date.now();
t.mock.timers.setTime(pausedAt + 9 * 60_000);
assert.equal((await request('/?points=3,4')).status, 502, 'still paused');
t.mock.timers.setTime(pausedAt + 10 * 60_000 + 1);
assert.equal((await request('/?points=3,4')).status, 200, 'pause over');
});
/** Drive mocked timers until `promise` settles; the retry budget is 10 s. */
async function settleOnMockedTime(t, promise) {
let settled = false;
const tracked = promise.finally(() => {
settled = true;
});
for (let step = 0; step < 60 && !settled; step += 1) {
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(1_000);
}
assert.ok(settled, 'the request settles within its retry budget');
return tracked;
}
test('during a terrain 429 pause fully cached points, even stale ones, are still served', async (t) => {
isolate(t);
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let calls = 0;
let limited = false;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return limited
? new Response('', { status: 429, headers: { 'retry-after': '300' } })
: Response.json({ results: [{ ellipsoid: 100 }] });
});
const request = install(terrainHeightsProxy());
assert.equal((await request('/?points=5,6')).status, 200);
// Past the 30-day TTL, so 5,6 is stale and would normally be refreshed.
t.mock.timers.setTime(Date.now() + 31 * 24 * 3600_000);
limited = true;
const first = await settleOnMockedTime(t, request('/?points=1,2'));
assert.equal(first.status, 502);
assert.equal(calls, 2, 'a Retry-After past the budget is not retried');
const stale = await request('/?points=5,6');
assert.equal(stale.status, 200);
assert.deepEqual(json(stale).results, [{ ellipsoid: 100 }]);
assert.equal((await request('/?points=5,6;7,8')).status, 502);
assert.equal(calls, 2, 'no upstream call during the pause');
});
test('a terrain upstream failure other than 429 does not pause later requests', async (t) => {
isolate(t);
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('', { status: 503 });
});
const request = install(terrainHeightsProxy());
const first = await settleOnMockedTime(t, request('/?points=1,2'));
assert.equal(first.status, 502);
const afterFirst = calls;
const second = await settleOnMockedTime(t, request('/?points=3,4'));
assert.equal(second.status, 502);
assert.ok(calls > afterFirst, 'new points still go upstream after a 503');
});
test('a shorter concurrent Retry-After never cuts a terrain 429 pause short', async (t) => {
isolate(t);
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let calls = 0;
t.mock.method(globalThis, 'fetch', async (raw) => {
calls += 1;
const points = new URL(raw).searchParams.get('points');
if (points.startsWith('0.'))
return Response.json({ results: [{ ellipsoid: 1 }] });
const retryAfter = points.startsWith('1.') ? '300' : '30';
return new Response('', {
status: 429,
headers: { 'retry-after': retryAfter },
});
});
const request = install(terrainHeightsProxy());
// Load the disk cache first so both requests below reach the upstream in
// order: the 300 s answer lands first, the 30 s answer after it.
assert.equal((await request('/?points=0,0')).status, 200);
const [long, short] = await settleOnMockedTime(
t,
Promise.all([request('/?points=1,2'), request('/?points=3,4')]),
);
assert.equal(long.status, 502);
assert.equal(short.status, 502);
const afterBoth = calls;
t.mock.timers.setTime(Date.now() + 120_000);
assert.equal((await request('/?points=5,6')).status, 502);
assert.equal(calls, afterBoth, 'still paused by the 300 s Retry-After');
});
+23
View File
@@ -729,6 +729,29 @@ test('Realtime service configuration selects compatible endpoint/model without f
assert.doesNotMatch(response.body, /server-fixture|voice\.example/);
});
test('the HUD summary status says whether a summary is configured, without calling OpenAI', async (t) => {
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('the status probe must not reach OpenAI');
});
const handler = install(openAiRealtimeProxy()).get('/api/openai/hud-summary');
for (const [key, configured] of [
[undefined, false],
[' ', false],
['fixture-key', true],
]) {
env(t, 'OPENAI_API_KEY', key);
const response = await request(handler, { url: '/status' });
assert.equal(response.status, 200);
assert.deepEqual(response.json(), { configured });
assert.equal(response.headers['cache-control'], 'no-store');
}
assert.equal(
(await request(handler, { url: '/' })).status,
405,
'only the status path answers GET',
);
});
test('OpenAI routes answer generically when the upstream or the request fails', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
+13 -2
View File
@@ -257,7 +257,14 @@ export const getFirePerimeters = defineTool({
requires: ['perimeters'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const rows = (await services.perimeters.getSnapshot({ signal }))
const snapshot =
typeof services.perimeters.getSnapshotDetail === 'function'
? await services.perimeters.getSnapshotDetail({ signal })
: { rows: await services.perimeters.getSnapshot({ signal }) };
// The proxy answers from its last snapshot, marked stale, while WFIGS is
// unreachable.
const stale = snapshot.stale === true;
const rows = snapshot.rows
.filter((fire) => polygonsTouchArea(fire.polygons, area))
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
.filter(({ point }) => point)
@@ -279,9 +286,13 @@ export const getFirePerimeters = defineTool({
}))
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
return {
summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`,
summary:
`${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}` +
(stale ? ' (data may be stale).' : '.'),
data: {
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
stale,
retrieved_at: isoTime(snapshot.fetchedAt),
...capRows(rows, args.limit),
},
};
+36
View File
@@ -871,3 +871,39 @@ test('installation names wait for the name pack, briefly', async (t) => {
assert.equal(slow.data.names_pending, true);
assert.match(slow.summary, /site names are still loading\)\.$/);
});
test('stale fire perimeters say so, with when they were fetched', async () => {
const fetchedAt = Date.UTC(2026, 9, 22, 6);
const ring = [
[
[-118.5, 35.2],
[-118.4, 35.2],
[-118.4, 35.3],
[-118.5, 35.2],
],
];
const perimeters = {
getSnapshot: async () => [],
getSnapshotDetail: async () => ({
rows: [
{
stableId: 'held',
name: 'Held Fire',
acres: 300,
polygons: [ring],
},
],
stale: true,
fetchedAt,
}),
};
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
area: { bbox: [-120, 34, -117, 36] },
});
assert.equal(
result.summary,
'1 mapped wildfire perimeter in the requested box (data may be stale).',
);
assert.equal(result.data.stale, true);
assert.equal(result.data.retrieved_at, new Date(fetchedAt).toISOString());
});
+8 -2
View File
@@ -714,18 +714,24 @@ export class LayerPanel {
});
}
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) {
return t('{state} · {source} · {ago} · retrying in {seconds}s', {
state: stateLabel,
source,
ago,
ago: dataAgo,
seconds: stats.retryInSec,
});
}
return t('{state} · {source} · {ago}', {
state: stateLabel,
source,
ago,
ago: dataAgo,
});
}
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
+21
View File
@@ -93,6 +93,27 @@ test('partial feed controls distinguish incomplete records from stale data and o
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 () => {
const { LayerPanel } = await import('./layerPanel.js');
const { railFixture } = await import('./railTestFixture.mjs');
+35 -1
View File
@@ -112,12 +112,46 @@ const FEED_ERROR_PATTERNS = [
],
];
// Directions route failures (layers/directions/index.js), English data with
// the route proxy's refusal or an HTTP status in them. Ahead of the feed
// patterns, which would take "Routing failed: rate limited" for a source.
// The route proxy's refusal texts (server/providers/places/routes.js) show
// translated; any other text in {error} shows its catalog entry, or as given.
const ROUTING_REFUSALS = new Set([
msg('rate limited'),
msg('routing service is rate limited'),
msg('route leg too long'),
msg('route too long'),
msg('routing busy — too many routes at once'),
msg('route proxy error'),
]);
const routingRefusal = (text) =>
ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text);
const ROUTING_ERROR_PATTERNS = [
[
/^Routing unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Routing unavailable (HTTP {status})', { status }),
],
[
/^Routing failed: (.+)$/,
([, error]) =>
t('Routing failed: {error}', { error: routingRefusal(error) }),
],
[
/^(.+) — try again in a moment$/,
([, error]) =>
t('{error} — try again in a moment', { error: routingRefusal(error) }),
],
];
const ERROR_PATTERNS = [...ROUTING_ERROR_PATTERNS, ...FEED_ERROR_PATTERNS];
/** One error text in the UI language, or null when nothing matches. */
function feedErrorText(text) {
if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
for (const [pattern, format] of ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
+43 -11
View File
@@ -7,10 +7,8 @@ import { setLocale, t, translateKnown } from '../i18n/index.js';
import { presentStreetLevelPanel } from './streetLevelPresentation.js';
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
import { createWeatherSource } from '../layers/weather/source.js';
import {
presentRoutingFailure,
presentRoutingRefusal,
} from '../layers/directions/index.js';
import { presentRoutingFailure } from '../layers/directions/index.js';
import { translateFeedError } from './statusText.js';
import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js';
const NO_IMAGERY = 'No street-level imagery within 50 m of that point';
@@ -53,9 +51,13 @@ for (const locale of ['es', 'ru']) {
test(`${locale}: the route proxy's refusals are shown translated`, () => {
setLocale(locale);
try {
const shown = t('Routing failed: {error}', {
error: presentRoutingRefusal('route leg too long'),
});
// Directions stores these in English; the Layers panel shows them
// through translateFeedError.
const shown = translateFeedError('Routing failed: route leg too long');
assert.equal(
shown,
t('Routing failed: {error}', { error: t('route leg too long') }),
);
assert.doesNotMatch(shown, /route leg too long/);
const limited = presentRoutingFailure(
Object.assign(new Error('rate limited — try again in a moment'), {
@@ -63,16 +65,46 @@ for (const locale of ['es', 'ru']) {
refusal: 'rate limited',
}),
);
assert.doesNotMatch(limited, /rate limited|try again in a moment/);
assert.equal(limited, 'rate limited — try again in a moment');
assert.doesNotMatch(
translateFeedError(limited),
/rate limited|try again in a moment/,
);
const http = presentRoutingFailure(
Object.assign(new Error('Routing unavailable (HTTP 503)'), {
status: 503,
}),
);
assert.notEqual(http, 'Routing unavailable (HTTP 503)');
assert.match(http, /503/);
assert.equal(http, 'Routing unavailable (HTTP 503)');
assert.notEqual(translateFeedError(http), http);
assert.match(translateFeedError(http), /503/);
// A text the proxy never sends is shown as given.
assert.equal(presentRoutingRefusal('something else'), 'something else');
assert.equal(
translateFeedError('Routing failed: something else'),
t('Routing failed: {error}', { error: 'something else' }),
);
// Every refusal the proxy sends (server/providers/places/routes.js),
// and "rate limited" after "Routing failed:", which the feed pattern
// "<source> rate limited" would otherwise take.
for (const [english, shown] of [
[
'routing busy — too many routes at once — try again in a moment',
t('{error} — try again in a moment', {
error: t('routing busy — too many routes at once'),
}),
],
[
'Routing failed: route proxy error',
t('Routing failed: {error}', { error: t('route proxy error') }),
],
[
'Routing failed: rate limited',
t('Routing failed: {error}', { error: t('rate limited') }),
],
]) {
assert.equal(translateFeedError(english), shown);
assert.doesNotMatch(shown, /routing busy|route proxy|rate limited/);
}
} finally {
setLocale('en');
}
+81 -4
View File
@@ -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,
+359
View File
@@ -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);
});