Compare commits

..
Author SHA1 Message Date
Said Turksever f45d4bcc93 fix: resolve review findings and refresh PR #1523 against main 2026-11-03 16:17:24 +00:00
Said Turksever 3689d2e6ca docs: complete review bookkeeping for #1523 2026-11-03 16:10:09 +00:00
Said Turksever 2ebde97fb3 Merge pull request #1206 from m3ayush/feat/radio-country-coverage
feat(radio): add configurable priority-country coverage
2026-11-03 16:07:29 +00:00
Said Turksever bc6af1ccd1 Merge branch 'main' into feat/radio-country-coverage 2026-11-03 15:46:24 +00:00
Said Turksever ea69bb4ce2 Merge pull request #1435 from Rohilalala/fix/d-military-positionless-rows
fix(military): don't treat adsb.lol's position-less rows as a partial snapshot
2026-11-03 15:44:49 +00:00
Said Turksever 3afbd0c638 Merge branch 'main' into fix/d-military-positionless-rows 2026-11-03 15:28:59 +00:00
Said Turksever 3ad3cc2ab7 Merge pull request #1308 from bilawalsidhu/codex/overpass-admission-review-fixes
fix(overpass): keyless gate behind a reverse proxy, loopback admission headroom, per-phase arrival budgets
2026-11-03 15:27:19 +00:00
Said Turksever 6af7cef3a3 Merge remote-tracking branch 'upstream/main' into codex/pr1523-current-main 2026-11-03 15:11:27 +00:00
Said Turksever 25340f735c test: cover Minimal HUD banner and attribution clearance 2026-11-03 15:07:58 +00:00
Said Turksever 34906c9abb Merge branch 'main' into codex/overpass-admission-review-fixes 2026-11-03 15:07:08 +00:00
Said Turksever e5d15f24d5 Merge pull request #114 from Tom-Neverwinter/prereq-10-osm-overlays
feat: OpenSeaMap / OpenSnowMap overlay layers (#10)
2026-11-03 15:02:24 +00:00
Said Turksever 94e4c2176e fix(radio): canonicalize priority country aliases before querying 2026-11-03 14:50:23 +00:00
Said Turksever a6d20120a7 Merge upstream main into PR #1435 for integration review 2026-11-03 14:50:23 +00:00
Said Turksever aff540ad39 Merge upstream main into PR #1206 for integration review 2026-11-03 14:50:23 +00:00
Said Turksever 315c561a7b Restore main's catalog strings and re-pin the voice schema after the main merges
The two Update-branch merges resolved the locale conflicts by keeping
this branch's side, which dropped nine strings main had added (Utilities
panel, drawing colours, cockpit exit). Rebuild each catalog as main's copy
plus Sea Marks and Ski Pistes, and re-pin the voice tool schema length and
digests on top of main's Utilities target.
2026-11-03 14:49:54 +00:00
Said Turksever 2a6a3922af Keep the Minimal HUD readout clear of the attribution row
The bottom-left MGRS / lat-lon readout sat 5.4rem up while the Cesium /
Esri credits row, logo included, reaches about 6.75rem, so the two
overlapped. Raise it to 7.25rem and drop the Scope-off override: the
backplates add no vertical padding, so both modes now leave the same gap.
2026-11-03 14:46:32 +00:00
Said Turksever 8e40d2689b Merge branch 'main' into prereq-10-osm-overlays 2026-11-03 14:34:32 +00:00
Said Turksever 11602742a8 Document the Minimal HUD banner removal (#1523) 2026-11-03 14:31:16 +00:00
Said Turksever 657277dbf9 Hide the bottom MGRS banner in the Minimal HUD layout
The bottom-left readout already shows MGRS and lat/lon, and the centred
bar sat across the globe.
2026-11-03 14:30:55 +00:00
German Khokhlov f3adccd01a Merge pull request #1456 from quanticsoul4772/upstream/terrain-partial-heights
fix(terrain): keep known heights when a terrain request fails
2026-11-03 16:03:07 +04:00
German Khokhlov c664f8eacb Merge branch 'main' into upstream/terrain-partial-heights 2026-11-03 15:28:20 +04:00
German Khokhlov 39c648094b Merge pull request #1488 from bilawalsidhu/german/hal-623-hud-summary-locale
fix(hud): AI summary in the UI language, canonical feed state, partial aggregation
2026-11-03 15:22:40 +04:00
German Khokhlov be53c338c8 Merge branch 'main' into upstream/terrain-partial-heights 2026-11-03 14:59:12 +04:00
German Khokhlov d413362467 Merge branch 'main' into german/hal-623-hud-summary-locale 2026-11-03 14:55:40 +04:00
German Khokhlov b80c54b7f1 Merge pull request #1474 from bilawalsidhu/fix/hal-722-radio-next-no-autoplay
Keep Radio Next and Previous from starting playback before Play (HAL-722)
2026-11-03 14:55:14 +04:00
German Khokhlov ac5164b14b Merge remote-tracking branch 'origin/main' into german/hal-623-hud-summary-locale 2026-11-03 14:45:45 +04:00
German Khokhlov 03c168d7c9 refactor(hud): share the warning line and trim the summary helpers
One provenanceLine() builds STATE | text | LAYERS for the HUD and
get_hud_caption. hasWord is one lookaround regex, capAtWord one grapheme
pass. Feed-state helpers are imported from layerSnapshot.js directly, and the
kept-whole sanitizer cases are one table.
2026-11-03 14:45:32 +04:00
German Khokhlov ab8691383b docs(hud): describe the character-only summary cap and trim comments 2026-11-03 14:34:32 +04:00
German Khokhlov 238a3d0634 Merge remote-tracking branch 'origin/main' into fix/hal-722-radio-next-no-autoplay 2026-11-03 14:33:12 +04:00
German Khokhlov 81ffa5b663 Suspend the tuner noise context once a pending resume settles
A dial click shorter than the device start-up let suspend() run on a still-suspended context, so the later resume() left it running with no nodes. Cover paused, connecting and voice-fallback releases in radioNextState tests.
2026-11-03 14:32:55 +04:00
German Khokhlov 00cce1e672 Merge pull request #1515 from bilawalsidhu/codex/docs-codespaces-limitations
docs: clarify Codespaces feature restrictions
2026-11-03 14:28:44 +04:00
German Khokhlov 33381c5981 Merge remote-tracking branch 'origin/main' into german/hal-623-hud-summary-locale
# Conflicts:
#	src/voice/gevActions.test.mjs
2026-11-03 14:28:10 +04:00
German Khokhlov 57da1090d0 docs: shorten Codespaces restrictions 2026-11-03 12:09:46 +04:00
German Khokhlov c6de459436 docs: clarify Codespaces feature restrictions 2026-11-03 12:06:01 +04:00
Said Turksever bd7ef69843 Merge branch 'main' into prereq-10-osm-overlays 2026-11-02 17:48:19 +00:00
Said Turksever c35eba1704 Merge main into prereq-10-osm-overlays 2026-11-02 17:27:07 +00:00
Said Turksever fc7ff1055c Split Open Map Overlays into Sea Marks and Ski Pistes layers
Each overlay is now its own Infrastructure row instead of a chip on one
shared row: sea-marks (token 6) and ski-pistes (token 7), both
enabled-only, built by createOsmOverlayLayers() from one descriptor
list. Drops the option group and its share-codec defaults, gives each
layer its own name, voice entry and CURRENT-STATE row, and shifts the
token-ledger fixtures to the next free digit 8.
2026-11-02 16:20:08 +00:00
Said Turksever c489e65796 Keep one OpenStreetMap copyright link in the data credits
The generic OpenStreetMap credit owns the link; the overlay credits name
OSM in plain text, as dataCredits.test.mjs requires.
2026-11-02 16:07:01 +00:00
Said Turksever ce1ad3b95a Merge main into prereq-10-osm-overlays and port Open Map Overlays
Main moved on since this PR opened; the overlay layer is ported to the
current contracts:

- Layer-state token 6 (j belongs to transit; new single-character
  tokens are digits) with a reservation-ledger row, and the option owner
  renamed to the layer id so the compact share codec can encode it.
- Constructed per catalog by createOsmOverlaysLayer() in
  constructCatalog.js; src/standalone/data.js no longer registers layers.
- Drapes through maps/imageryHost.js, so the overlays show on Google 3D
  (tileset imagery) as well as the globe stacks, and follow map-stack
  switches. Imagery layers are created on enable and destroyed on
  disable/destroy.
- Translated chip labels and layer name in every locale, LAYER_NAME_KEYS,
  Infrastructure panel group, voice manifest entry.
- Attribution: OpenSeaMap tiles CC BY-SA 2.0, OpenSnowMap credit as
  required, OSM ODbL credit for both; bulk-download note in DATA_SOURCES.
- CURRENT-STATE row, changelog fragment, share-codec absent defaults,
  updated token-ledger fixtures and voice schema pins.
2026-11-02 15:55:40 +00:00
quanticsoul4772 468d5078e3 Merge branch 'main' into upstream/terrain-partial-heights 2026-11-02 06:50:47 -08:00
German Khokhlov cfc83a6078 Release the tuner noise graph after a silent dial release
A release that only selects a station now stops the looping noise source and suspends its AudioContext. The qa-radio dial scenarios start from an active stream, or expect a silent release from a stopped Radio.
2026-11-02 17:19:53 +04:00
German Khokhlov 645e13aeaa fix(hud): catch the UNAVAILABLE word flush against Han and keep multi-word names whole
Han and kana have no word edges, so a CJK forbidden word now counts anywhere
in the reply. The proxy no longer counts words: the model reads Rue de Rivoli
as one, so only the 80-character word-boundary cap remains.

Also restore src/voice/gevActions.test.mjs, reformatted by mistake in 818d2c82.
2026-11-02 17:13:58 +04:00
German Khokhlov 818d2c8295 fix(hud): keep localized summary words whole and drop padding
- The proxy keeps combining marks, in-word apostrophes and name dots
  (ar vowel signs, fr d'Orléans, ja ・), normalizes to NFC, caps every
  summary in characters and cuts at a word boundary.
- feedState is nullable instead of a `none` token, and the summary has at
  most five words: "exactly five" made the model pad short views with
  "no data" or "unavailable".
- The HUD caches only the model's words and adds the current warning on
  every paint; an RTL locale gets a leading right-to-left mark.
- The echo check also rejects an all-Latin reply in a non-Latin language,
  translated layer ids and, with every feed healthy, the UNAVAILABLE word;
  places are matched by word and one-word layer names are skipped in
  Latin-script locales.
- The warning tag names the worst layers first; a partial analyst card
  shows one partial chip.
2026-11-02 13:44:53 +04:00
German Khokhlov 857b007fb1 Keep the Radio tuning dial silent unless a stream is playing
Releasing the tuning dial always started the chosen station, plus tuning
static, even when nothing was playing. It now follows the Previous/Next
rule: the release plays only if a stream was loading, buffering or playing
when the drag began; otherwise it just selects the station.

The privacy note no longer lists finishing tuning as a way to connect and
now names voice instead, in all locales. Docs, changelog and comments are
tightened to match.
2026-11-02 13:27:55 +04:00
German Khokhlov 0ec6735440 Merge remote-tracking branch 'origin/fix/hal-722-radio-next-no-autoplay' into fix/hal-722-radio-next-no-autoplay 2026-11-02 12:36:51 +04:00
German Khokhlov a2be947aba Merge origin/main into fix/hal-722-radio-next-no-autoplay
main moved the Radio markup into Vue views and rebuilt the UI tests on
stores. Resolved:
- src/ui/templates/context.html: deleted in main; the new privacy note now
  lives in src/ui/views/panels/RadioPanel.vue.
- src/ui/radioControls.test.mjs: main's store fixture; the Next/Previous
  autoplay test drives store.act('cycleRadio').
- src/voice/gevRealtime.test.mjs: main's createTestVoiceUi; the stop and
  select handoff cases stay one table-driven test.
- src/data/radioNextState.test.mjs reads the published gev-radio view.
- src/i18n/locales/ar.json: the new note translated for the Arabic locale.
2026-11-02 12:36:31 +04:00
German Khokhlov 0d3c1ef1d9 fix(hud): ask again in the new language after an in-place switch
- _relocalize() now drops the received reply and aborts one in flight, so
  the next tick asks in the new language instead of keeping old-language
  text under the new-language state tag.
- The English-echo check also allows state words inside a place label
  (Estación Loading Dock).
- Reword the non-English instruction so it reads for every language name.
- Add the two new test files to the format scope.
2026-11-02 12:00:44 +04:00
German Khokhlov 1a6687632d Merge remote-tracking branch 'origin/main' into german/hal-623-hud-summary-locale
# Conflicts:
#	src/hud.js
#	src/hudAltitudeDatum.test.mjs
2026-11-02 11:59:08 +04:00
m3ayush f19da92610 Merge upstream main and resolve radio coverage conflicts 2026-10-31 20:34:57 +05:30
German Khokhlov dbfbfcceb9 fix(hud): ask the model only with a place label, catch flush English echoes
- Skip the model when the view has layers but no place, street or nearby
  label: with layers alone it invented a place.
- The English-echo check treats only Latin letters as part of a word, so
  ids flush against kana or Han and hyphenated names are caught; it skips
  nominal, state words a locale keeps in English and names inside place
  labels.
- Outside English, per-layer state ids are left out of the request.
- The ja/zh cap steps back to the start of a Latin word, not the last space.
- Fold the CJK cap cases into hudSummaryResponse.test.mjs.
2026-10-30 20:53:31 +04:00
German Khokhlov ebdd4628db Merge branch 'main' into fix/hal-722-radio-next-no-autoplay 2026-10-30 20:37:12 +04:00
German Khokhlov 5ed86200e5 Bring back the tuner-release and connecting-label Radio fixes
Restore the two fixes the previous commit moved out:

- A tuner release onto a station the directory dropped mid-drag stops the
  stream the drag paused, publishing one state with the gesture's origin,
  so the button reads PLAY and a voice handoff is cancelled.
- The transport buttons offer Pause while a stream connects, matching
  what a click does.

Their tests now use the shared Radio layer harness.
2026-10-30 16:44:15 +04:00
German Khokhlov a55450e2e7 Trim the Radio Next/Previous change to the HAL-722 scope
- Selections take the playback fallback as a `playFallback` option and
  set it after releasing a stale stream, instead of callers arming state
  that the release then had to save and restore.
- Move the tuner `station-unavailable` release and the Pause label while
  a stream connects out of this branch: both were wrong on main already
  and go in a separate change.
- Share the Radio layer test setup in src/testSupport/radioLayerHarness.mjs
  and fold overlapping Next/Previous tests (13 to 7).
- One-line comments and a two-line changelog entry.
2026-10-30 16:37:36 +04:00
German Khokhlov 7713fb8ae8 fix(hud): keep English out of localized summaries, cap ja/zh by characters only
Second follow-up review fixes:

- Japanese and Chinese summaries are capped at 20 characters only, without
  the five-token cut first, and the cap steps back to a space instead of
  splitting a Latin word.
- The HUD request sends localized layer names in feedProvenance too and
  leaves the English narration note out outside English. A non-English reply
  that still echoes a feed-state id or a translated English layer name falls
  back to the deterministic line.
- A camera move during typing shows the deterministic line, not a fragment.
- A view with no place labels and no enabled layer does not ask the model.
- Behaviour tests on IntelHUD replace the regex checks of hud.js; docs and
  the instruction wording updated.
2026-10-30 16:28:56 +04:00
German Khokhlov 7b223d7d7b fix(hud): state word first, layer names last; CJK cap by locale
Follow-up review fixes:

- The HUD line is STATE | text | LAYERS for the AI summary and the
  fallback line, so the narrow minimal and cyber layouts show both the
  warning and the summary. get_hud_caption builds its caption the same
  way. The unused "{state} {layers}" key is pruned from the catalogs.
- The 20-character cap applies only when the request locale is Japanese
  or Chinese; other languages keep five words around a Han place name.
- enabledLayers names are localized too, and the instructions name layers
  only from enabledLayerLabels, never from feedProvenance or its note.
- The keyless test restores GEV_RATELIMIT_OPENAI_PER_MIN again; comments
  and docs updated.
2026-10-30 14:27:59 +04:00
German Khokhlov fa840ae3c3 Translate the Radio privacy note into the new locales
main added de, fr, it, ja and pt with the old "after you press play"
note; give them the updated wording. main also removed the compact
Context Radio player, so the Next/Previous tests stop checking its
button.
2026-10-30 14:26:58 +04:00
German Khokhlov a7c16a701c Merge remote-tracking branch 'origin/main' into fix/hal-722-radio-next-no-autoplay 2026-10-30 14:23:09 +04:00
German Khokhlov 4dd5a313b3 Keep Radio playback state consistent after a silent Next
Review follow-ups for HAL-722. A Next or Previous that only selects a
station now leaves nothing of the previous stream behind:

- Selecting another station without playing stops a paused or failed
  stream, so the panel, Stop, voice summaries and the audio element agree
  on "stopped". A fallback the caller armed for the new selection is kept.
- A silent UI step arms no broadcaster fallback, and a user Play of the
  selected station drops one left by a cancelled voice step. Voice steps
  pass `fallback: true` because voice starts the prepared playback later.
- A user step to another station emits a `select` playback control, and
  the realtime voice handoff treats it like Pause and Stop.
- A tuner release onto a station the directory no longer has stops the
  stream the drag paused, publishing one state with the gesture's origin,
  so the button reads PLAY and a voice handoff is cancelled.
- The transport buttons offer Pause while a stream connects, matching
  what a click does.
- The privacy note names every action that connects to a broadcaster
  (play, finishing tuning, choosing a station on the globe) in all
  locales.

The Next/Previous layer tests live in src/data/radioNextState.test.mjs.
2026-10-30 13:44:48 +04:00
German Khokhlov f967218a04 fix(hud): lead with the state tag, keep feedState in get_hud_caption
Review fixes for the HUD summary language change:

- The translated feed-state tag now leads the one-line summary and the
  fallback line, so the readout's ellipsis cannot hide it.
- get_hud_caption checks the reply's feedState, returns it and appends the
  state tag; a reply naming another state keeps only the place.
- The model gets the layer names the user sees (localizeLayerName).
- Japanese and Chinese summaries are capped at 20 characters; pt, zh-CN and
  zh-Hant get explicit language names.
- A partial view is narrated as incomplete coverage, not as not live.
- provenanceTag and hudSummaryMatchesProvenance move to layerSnapshot.js so
  the tools package can use them without browser globals.
- docs/CURRENT-STATE.md, changelog and comments updated; redundant tests
  trimmed.
2026-10-30 13:06:53 +04:00
German Khokhlov 54750a01e5 changelog: HUD summary language (#1488) 2026-10-30 12:38:18 +04:00
German Khokhlov 563df3bad9 fix(hud): AI summary in the UI language, canonical feed state, partial aggregation
The HUD sends its locale with the summary context; the proxy names that
language in the instructions and the input, English when missing or
unsupported. The model returns {summary, feedState}; the HUD shows the
summary only when feedState matches its own state for a view that is not
nominal, and appends its own translated state tag.

partial joins the shared feed-state table between fallback and nominal,
so one partial layer no longer reads off and partial plus nominal no
longer reads nominal.
2026-10-30 12:37:43 +04:00
German KhokhlovandClaude Opus 5.5 b775bfed70 Add the changelog entry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:36:28 +04:00
German KhokhlovandClaude Opus 5.5 1ef9be2248 Keep Radio Next and Previous from starting playback before Play
The Next and Previous buttons cycled stations with the layer's default
autoplay, so a first press started buffering a broadcaster stream even
though the panel says audio connects only after Play. Stepping now only
selects until a stream is loading, buffering or playing; once one is,
playback follows the new station so the old broadcaster does not keep
playing under the new label.

The compact Next fallback scenario in qa-radio.mjs started from a stopped
stream and relied on the old behaviour, so it now starts from a playing
station.

Fixes HAL-722

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:36:02 +04:00
quanticsoul4772andClaude Opus 5.5 692e99729c Merge branch 'main' into upstream/terrain-partial-heights
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:58:22 -07:00
quanticsoul4772 9b918d475a Merge branch 'upstream/terrain-partial-heights' of https://github.com/quanticsoul4772/gods-eye-view into upstream/terrain-partial-heights 2026-10-28 11:24:36 -07:00
quanticsoul4772andClaude Opus 5.5 d0f6f5c0d8 docs(changelog): add the terrain partial-heights entry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:24:23 -07:00
quanticsoul4772 49b65ddc55 Merge branch 'main' into upstream/terrain-partial-heights 2026-10-28 11:24:19 -07:00
quanticsoul4772andClaude Opus 5.5 19b4a7a7e0 fix(terrain): keep known heights when a terrain request fails
A request with any point the proxy cannot answer gets 502, and the page
used to put every point of that chunk on its geoid, including the ones the
proxy had real (cached or stale) heights for. During an upstream 429 pause
that drops cached ground under every chunk that also holds a new point.

The 502 body now carries `partial`: the request's real heights in order,
null for points without one, left out when there are none. The terrain
request service attaches it to the error it throws, and the page caches
those heights and uses its geoid only for the rest. `get_terrain_height`
still reports the failure as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 10:10:50 -07:00
Aditya dc56ada3a5 docs(changelog): add #1435 fragment 2026-10-27 21:57:34 +05:30
Aditya 60dad0c35e fix(military): don't treat adsb.lol's position-less rows as a partial snapshot
readsb omits lat/lon for a contact heard without a current position; about a
third of every /v2/mil response is such rows. They made every military
snapshot partial, so a contact that left the feed was retained at full
opacity for five minutes instead of fading and leaving on the missed-poll
limits. A row with a hex and no lat/lon now leaves the snapshot complete and
is listed in retainIds, as OpenSky's null-position rows already are.
2026-10-27 15:44:36 +05:30
Said Turksever 0ac32f82ba Merge remote-tracking branch 'origin/main' into codex/overpass-admission-review-fixes
# Conflicts:
#	README.md
#	SECURITY.md
#	scripts/format-scope.json
#	server/providers/overpass.js
2026-10-26 14:21:39 +00:00
Said Turksever d026952397 fix(track): name a measured angle violation in the harness log; docs: nginx Host $http_host keeps the port the Origin check compares
- fmtArr prints reveal-err and flags an angle violation on a timeout result.
- Regression: a late hidden reveal frame records no angle and stays retryable.
- README/SECURITY: $host drops a non-default port, so recommend $http_host.
- requestAdmission tests: say why a new admission test must use freshClock.
2026-10-26 14:19:21 +00:00
Said Turksever 01c764d4fd fix(track): read a late arrival frame before timing it out, so a measured wrong angle is never retried away
- observeArrivalRotation reads the sample before the deadline check and keeps
  a late frame's angle on the timeout result; arrivalAngleViolated then makes
  it final. Regressions: wrong late reveal and wrong late settle, each followed
  by a clean second attempt, still fail; a clean late frame stays retryable.
- Overpass admission tests: a monotonic per-test clock removes order
  dependence on the module-level limiters; the proxy test covers ::1 and
  ::ffff:127.0.0.1 sockets; a new test pins the proxied: upstream bucket.
- Retry edges (wrong on-time reveal, maxAttempts, inclusive 10° limit) and a
  check that every name the track harness imports is exported.
- Docs: nginx needs Host, X-Forwarded-Proto and X-Forwarded-For; Docker bridge
  networking makes every host client one remote peer; list the exact
  forwarding headers; owner misses can also see 503.
2026-10-26 14:10:52 +00:00
Said Turksever 8f16d10f7a fix(overpass): keep proxied traffic out of the direct-loopback admission reserve; keep a measured arrival-angle failure through a reveal timeout
- /api/overpass: a loopback socket with forwarding headers (a local reverse
  proxy) is admitted from the remote pool under a socket-keyed proxied: bucket,
  never the owner's direct-loopback window; the upstream limiter uses the same
  key. Regression: owner cache hits survive an exhausted proxy bucket.
- Arrival check: a reveal timeout keeps the settle angle it measured; the new
  observeArrivalRotationWithRetry retries only inconclusive timeouts, so a
  real violation cannot be retried into a pass. Regression: wrong settle angle
  -> reveal timeout -> clean second attempt still fails.
- SECURITY.md, README, changelog and CURRENT-STATE: scope the guarantee to
  admission isolation, name the forwarding headers a local proxy must send,
  and state that the upstream budget stays shared.
2026-10-26 13:59:18 +00:00
Said TurkseverandClaude Fable 5.1 7a421c35fc chore: drop the duplicate format-scope entry from the main merge
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 17:34:58 +01:00
Said Turksever 09ea8f33b9 Merge remote-tracking branch 'upstream/main' into codex/overpass-admission-review-fixes
# Conflicts:
#	scripts/format-scope.json
2026-10-21 17:34:23 +01:00
Said TurkseverandClaude Fable 5.1 daedd76acc docs(changelog): fragment for #1308
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 17:33:13 +01:00
Said TurkseverandClaude Fable 5.1 bf2c96db0a chore: leave the #1264 changelog fragment as merged
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 16:33:10 +01:00
Said TurkseverandClaude Fable 5.1 e3c4e88d6f test(track): freshen rotation passes before the arrival probe and retry an inconclusive timeout
A headless SwiftShader run showed a 926 ms first frame on the military probe,
which no wall-clock budget can accept: the 1 s idle refresh may already have
run inside it. Raise moveEnd and wait one frame before each attempt so the
settle frame is measured against a pass that just ran, and treat a timeout
(never a wrong angle) as inconclusive with one more attempt. Document why the
per-phase budget stays at half the refresh interval.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 16:17:37 +01:00
Said TurkseverandClaude Fable 5.1 9059cd942f style: format the Overpass admission tests
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 16:17:37 +01:00
Said TurkseverandClaude Fable 5.1 55f979c128 fix(overpass): keyless gate, loopback admission headroom and per-phase arrival budgets
Address gekh's review of 69a87fe2:

- P1: /api/overpass now uses admitSameSiteKeyless, like /api/terrain/heights,
  so an instance behind the README's reverse proxy keeps its Overpass-backed
  layers; cross-site shapes are still refused and forwarding headers no
  longer launder them. SECURITY.md, the gate header and CURRENT-STATE say so.
- P2: admission keys IPv6 peers by /64 and folds IPv4-mapped peers
  (clientNetworkKey), and loopback has its own 300/min window outside the
  900/min global, so a few LAN peers cannot lock the owner's browser out.
- P2 (track): the settle and reveal frames each get their own 500 ms budget,
  measured from moveEnd and from the settle frame respectively, so a ~300 ms
  SwiftShader frame no longer trips the reveal deadline while both stay
  inside the 1 s idle refresh.
- Smaller: drop the dead settings-block check and stale probe comment in
  query.js, clamp whitespace-tolerant [ timeout : N ], and add tests for the
  global window, the /64 keying, loopback headroom, proxied admission and
  cache hits spending admission but not upstream budget.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-21 16:17:37 +01:00
m3ayush cbfcaff8df fix(radio): isolate country coverage from catalog health 2026-10-18 15:06:13 +05:30
m3ayush 14d7ddf06b feat(radio): add configurable priority-country coverage 2026-10-18 12:19:25 +05:30
Tom-NeverwinterandClaude Sonnet 5 feba5af0b9 Add OpenSeaMap / OpenSnowMap overlay layers (#10)
Two independently toggleable open-data raster overlays — OpenSeaMap
sea marks (buoys, lighthouses, ports) and OpenSnowMap ski pistes —
drawn as Cesium.ImageryLayers directly on the Cesium globe surface.
Same "small array of descriptors" shape as MAP_STACKS in
mapStackController.js.

Visible on the osm/bing-aerial/bing-labels map stacks and invisible
(not broken — the layer's stats say so in plain text) on the default
photoreal stack, since Google Photorealistic 3D Tiles render with the
globe hidden.

OpenRailwayMap and OpenInfraMap, also requested in #10, are
deliberately NOT included in this pass: OpenRailwayMap's public tile
server enforces a Referer allowlist scoped to its own site (confirmed
by direct request — every other Referer, including none, 403s), and
OpenInfraMap serves MapLibre GL vector tiles, not raster XYZ — that
would need a genuine vector-tile rendering pipeline, not a
Cesium.ImageryLayer drop-in. Worth its own dedicated pass.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 17:07:33 -05:00
169 changed files with 4215 additions and 2736 deletions

No files matched your search

+5
View File
@@ -295,3 +295,8 @@ OVERPASS_UPSTREAMS=
# frame-ancestors sources such as "https://example.com", or * for any page.
# Unset, no page may frame it. Other pages of the app can never be framed.
# GEV_EMBED_FRAME_ANCESTORS=
# Optional keyless Radio Browser coverage: up to 8 ISO alpha-2 countries.
# Each reserves up to 40 stations within the existing 750-station directory.
# Restart after changing. Leave blank for the existing global selection.
# RADIO_PRIORITY_COUNTRIES=IN,PL
+6 -1
View File
@@ -98,6 +98,8 @@ How to read this:
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
### Local receiver input (not fetched from a service)
@@ -118,6 +120,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
<a id="gnss-integrity"></a>
@@ -180,7 +183,9 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
+11 -8
View File
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
## ⚡ Quick Start
**Start without an account or API keys.** Every path opens the same app with
**Local setup needs no account or API keys.** The app opens with
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
unreachable. Flights, military traffic, satellites, earthquakes, public
cameras, radio, and launches are available without keys.
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
</details>
@@ -236,7 +236,11 @@ the container.
### Path 4 — GitHub Codespaces (cloud, no local install)
Runs the same dev server in a cloud workspace, so Node and the dependencies
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
never touch your machine. The globe still renders in your own browser, so it
needs the same graphics there as any other path. GitHub Free includes 120
core-hours a month with no card on file — about 60 hours on the default 2-core
@@ -265,14 +269,13 @@ ignored on purpose, so pass that exact name.
⚠️ **A codespace is reachable from the internet.** Treat it as the
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
Settings does not open on the forwarded URL, so put keys in the workspace
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
threat model.
Settings is unavailable on the forwarded URL. Never commit your workspace
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
chip in the bottom-right corner: Provider Settings lists every supported key,
For a local setup, keys are upgrades, not prerequisites. When you want one,
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
restarts itself with the new capability on. Once everything is configured the
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
+1 -1
View File
@@ -80,7 +80,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
+3
View File
@@ -0,0 +1,3 @@
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
overlay layers under Infrastructure. They drape on the globe stacks and on
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
@@ -0,0 +1,6 @@
- Add optional keyless Radio priority-country coverage with up to 40 stations
per configured country inside the existing 750-station cap; the UK alias uses
the canonical GB code. Country query
failures do not block a healthy global catalog. Bound mirror attempts to three
and retry failed or partial refreshes after 60 seconds on all installations,
including those without priority countries configured (m3ayush, #1206).
@@ -0,0 +1,13 @@
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
now uses the keyless same-site gate, admits forwarding headers and still
refuses cross-site requests. Direct loopback requests keep their own
admission window; proxied requests, including those a local reverse proxy
relays over loopback, are admitted as remote traffic keyed by their socket,
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
exhaust the owner's admission. The upstream budget stays shared, so an owner
cache miss can still be rate-limited (asturksever, #1308).
- Give the tracking arrival check a wall-clock budget per observed frame and
retry only an inconclusive timeout: a timeout keeps every angle it
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
frames no longer fail the check and a real violation is never retried into a
pass (asturksever, #1308).
@@ -0,0 +1,5 @@
- Military aircraft that leave the adsb.lol feed fade and are removed again.
The feed's routine position-less rows no longer mark every snapshot partial,
which kept vanished aircraft at full opacity for up to five minutes; those
rows keep only their own aircraft, and a likely-landed one still leaves on
the landed limit (Aditya, @Rohilalala, #1435).
@@ -0,0 +1,3 @@
- A terrain request the proxy can only partly answer (a 429 pause, or a point
the upstream returns without a height) now keeps the real heights it has
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
@@ -0,0 +1,5 @@
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
before Play is pressed; while a stream plays, it follows the new station.
Play reads Pause while a stream connects, and a tuner release onto a station
the directory removed or changed no longer leaves a Resume for another
station (gekh, #1474).
+3
View File
@@ -0,0 +1,3 @@
- Write the AI HUD summary in the interface language, led by the HUD's own
translated feed-state warning. A partial layer no longer reads as off or
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
-5
View File
@@ -1,5 +0,0 @@
- One world clock now drives everything with a time of its own: the sky,
the HUD clock, satellites, launches and weather. A timeline along the
bottom of the screen, shown while one of those layers is on, plays, pauses
and scrubs it, with a tick for each launch or frame and a way back to
live. Share links made away from live carry the time (samehkhamis, #1512).
@@ -0,0 +1,3 @@
- The Minimal HUD layout no longer draws the centred MGRS / LAT / LON banner
across the globe; the bottom-left readout still shows the same position, and
now sits clear of the map attribution row instead of overlapping it (Said Turksever, #1523).
+116 -48
View File
@@ -263,6 +263,14 @@ that names Operator restores the default Tactical layout. Operator was the
only layout that showed the HUD's top banner bar, so its markup and styles are
removed too.
Minimal no longer shows the centred MGRS / LAT / LON bar near the bottom of the
globe (November 3, 2026, #1523): the bottom-left readout already carries the
same position, and the bar sat across the globe. That readout now sits 7.25rem
(plus 2vh) up, clear of the Cesium / Esri attribution row it used to overlap,
with Scope on or off. `scripts/qa-scope-hud.mjs` checks the hidden banner and
clearance above the rendered attribution row at desktop and intermediate
widths in both Scope modes, plus the existing compact phone treatment.
## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -1197,12 +1205,12 @@ addition to the locally accumulated trail, never a promise of complete coverage.
Snapshot coverage, completeness and freshness are separate fields. A partially
admitted aircraft snapshot retains absent contacts for up to five minutes before
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
longitude and latitude is a contact without a position, not a malformed row: it
leaves the snapshot complete and is listed in `retainIds`, so civil flights
retain that aircraft under the same five-minute bound, unless it is likely
landed, while other absent aircraft fade and leave on the missed-poll limits. A
nonempty snapshot with no positioned rows is still malformed. Military readsb
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a
contact without a position, not a malformed row: it leaves the snapshot complete
and is listed in `retainIds`, so civil and military flights retain that aircraft
under the same five-minute bound, unless it is likely landed, while other absent
aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no
positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty
snapshot retains the previous display. Empty vessel refreshes retain the existing
first-connect grace and warm-data behavior. Known source failures have bounded
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
@@ -3092,9 +3100,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
> Its Radio popover exposes compact power,
> transport, station, and volume controls. Cockpit Previous/Next preserves
> selection, autoplay, and broadcaster fallback audio without starting the
> map-view station flights that compete with the first-person camera. These
> transport, station, and volume controls. Cockpit Previous/Next changes the
> selection without starting the map-view station flights that compete with
> the first-person camera; like the Context Previous/Next buttons, it plays
> (with broadcaster fallback) only while a stream is already active. These
> are a Cockpit-only accordion:
> each static header uses the left accordion's label and divider with a dedicated
> directional-chevron disclosure button and no full-row hover treatment. Only one
@@ -3257,16 +3266,25 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> 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
> gets 502, whose `partial` lists the real heights it has (null for the rest)
> when it has any, and the page keeps those and uses its geoid fallback only
> for the points without one. The same holds when the upstream answers a
> point with a null height. 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
> placement continues to use the existing prior until real heights arrive.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
> POSTs through the shared local-request gate, then limits requests to 300/min
> per socket peer and 900/min globally before reading or validating bodies.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
> through the keyless same-site gate (reverse-proxy forwarding headers are
> admitted, as for `/api/terrain/heights`), then limits requests to 300/min
> per peer — IPv6 peers keyed by /64 — and 900/min across all remote peers
> before reading or validating bodies. Direct loopback (no forwarding
> headers) has its own 300/min window outside that global; a local reverse
> proxy's requests carry forwarding headers and are counted as remote, keyed
> `proxied:<socket peer>`, so they cannot spend the owner's window. Upstream
> quota stays shared, so owner cache misses can still hit 429.
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
> It then checks fresh memory, identical in-flight work, and fresh disk entries
> before invoking its separate local 90/min upstream limiter. Cache and
@@ -3990,31 +4008,33 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -4704,8 +4724,9 @@ programmatic activation do not trigger this reveal.
Radio directory admission is atomic on both sides of the proxy boundary. A
refresh is healthy only when it reaches the minimum accepted-query and station
coverage; schema-valid responses with zero normalized stations count as failed
queries rather than inflating refresh health. Each specialist query also needs
coverage from broad/specialist queries; their schema-valid responses with zero
normalized stations count as failed queries rather than inflating refresh health.
Optional priority-country outcomes do not gate catalog health. Each specialist query also needs
an accepted station whose normalized tags match that requested category; rows
tagged only for another category remain usable catalog data but do not earn
specialist health credit. A partial cold result remains
@@ -4736,10 +4757,12 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
ownership tokens plus the session boundary keep late callbacks from a retired
session inert.
A tuner drag resolves previews against the single accepted snapshot captured at
pointer-down, even while a newer catalog is admitted. Release starts playback
only when the current same-ID record still exactly matches the frozen
presentation and stream metadata; removal or replacement reports the channel as
unavailable and never silently retargets the drag. A cold degraded fallback may
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
release starts playback only if a stream was active when the drag began;
otherwise it just selects the station. It commits only when the current same-ID
record still exactly matches the frozen presentation and stream metadata.
Removal or replacement reports the channel as unavailable and never silently
retargets the drag. A cold degraded fallback may
still populate the directory and globe, but its null accepted generation cannot
populate or begin the tuner.
@@ -4916,7 +4939,9 @@ before the later control succeeds, only its playback handoff is suppressed; its
result reads enabled state from the manager and audio state from the Radio
module. Voice Pause is a playback-only no-op while the layer is disabled; it
never enables Radio and reports a fulfilled-false pause as failure. A
newer-response or user-origin Stop still cancels stale handoff work.
newer-response or user-origin Stop still cancels stale handoff work, as do a
user Pause, a user Previous/Next that only selects another station, and a tuner
release onto a station the directory removed or changed mid-drag.
Only a newer user turn or session teardown aborts the complete active-tool set.
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
controls and generic layer-visibility commands both forward cancellation.
@@ -5405,8 +5430,11 @@ and unreachable upstream (502/504) separately from road geometry.
### AI HUD Summary (June 2026)
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
- Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown.
- Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that line instead of the fragment.
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer, or partial plus nominal, reads partial, not off or nominal (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
### Place-search providers
@@ -5691,7 +5719,7 @@ pins and share links already supply complete boxes.
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout keeps every angle it measured (the settle frame before a reveal timeout, and the late frame itself, which is read before the deadline check), so a measured wrong angle fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
### TR-3B conversion Easter egg (August 2026)
@@ -6299,6 +6327,46 @@ Existing catalog validation, category filters, tuning and voice playback behavio
remain unchanged. Audio connects directly to the broadcaster after an explicit
play action; the source does not relay or record streams.
### Radio priority-country coverage
Optional `RADIO_PRIORITY_COUNTRIES` is a server-owned comma-separated list of
up to eight ISO alpha-2 codes (for example `IN,PL`). Restart after changing it.
The `UK` alias is canonicalized to `GB` before deduplication and querying.
It needs no API key and adds no POWER UP entry. An empty list preserves the
existing global/tag-based directory. Invalid configuration fails at startup.
Each country adds one Radio Browser search capped at 200 rows, with existing
HTTPS/geolocation/codec validation and an additional matching-country check.
Up to 40 unique accepted stations per country are seeded before the specialist
tag queries and global popularity fill, within the unchanged 750-station cap.
No country gets exhaustive coverage. A valid, in-limit country response with
zero eligible stations succeeds with zero selected stations.
The response's `coverage.priorityCountries` reports accepted query status and
selected counts when configured. The existing broad/specialist health policy
still applies separately. Country outcomes are advisory: failed, malformed or
oversized country queries report unsuccessful coverage but cannot degrade or
block admission of an otherwise healthy global catalog. Queries share the existing
three-worker pool, single-flight refresh and 45-minute cache.
Structurally malformed rows are skipped and counted in the upstream
`rejectedStationCount` diagnostic; mixed country responses retain their valid
rows, while an entirely malformed nonempty country response reports unsuccessful
coverage. The diagnostic does not block catalog admission.
For every installation, including those with no priority countries configured,
at most three mirrors are tried per request; fallback mirrors beyond the first
three candidates are not attempted. Failed or partial catalog refreshes retry
after one minute, so a cold-start failure can continue returning 503 for that
minute even if upstream recovers sooner. Healthy catalogs retain the 45-minute
cache. The seven-day last-good expiry and known-station-only click reporting
remain in force.
Validation: `node --test src/data/radioProxy.test.mjs` covers configuration,
country quotas under global popularity pressure, duplicate records, concurrency,
cache reuse, country mismatch/oversize/empty/ineligible responses, failure isolation on cold
and warm refreshes beyond seven days, and retry/backoff recovery both with and
without configured countries. `node scripts/qa-radio.mjs --url http://localhost:4173` covers the existing
750-station browser presentation and playback contract.
## Local RTL-SDR and Local ADS-B
The Radio panel holds a Local RTL-SDR card below internet radio. One browser
-5
View File
@@ -119,7 +119,6 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code.
@@ -133,10 +132,6 @@ style also extends the pinned style-defaults table.
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
layout and restores it as `tactical`. The code stays reserved.
Field 28 is the world time the link was made at, written only when it was away
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
link's creation (a forecast moment), and it opens paused at that time.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
-1
View File
@@ -89,7 +89,6 @@
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js",
"./time": "./src/time/worldClock.js",
"./build/vite": {
"node": "./build/vite.js"
},
+2 -94
View File
@@ -1102,6 +1102,7 @@
"scripts/qa-anime-sky.mjs",
"scripts/qa-sky-model-lighting.mjs",
"server/providers/overpass/requestAdmission.test.mjs",
"src/tooling/trackArrivalObservation.mjs",
"src/tooling/trackArrivalObservation.test.mjs",
"src/ui/styles/status-badge.css",
"src/ui/cctvBadges.test.mjs",
@@ -1110,98 +1111,5 @@
"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/preferencesView.test.mjs",
"src/voice/voiceControlView.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
"src/voice/panel/nativeActivity.test.mjs",
"src/voice/panel/inactivity.test.mjs",
"src/voice/panel/actionObserver.test.mjs",
"src/voice/realtimeCostPanel.test.mjs",
"docs/SHARE-LINK-FORMAT.md",
"src/share/wire.test.mjs",
"src/share/viewCodec.test.mjs",
"src/share/legacyShareState.test.mjs",
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
"src/share/shareLinkPath.test.mjs",
"src/ui/kit/UiRail.test.mjs",
"src/ui/kit/UiSegmented.vue",
"src/ui/kit/UiSegmented.test.mjs",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSlider.test.mjs",
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiButton.test.mjs",
"src/ui/kit/UiChip.vue",
"src/ui/kit/UiChip.test.mjs",
"src/ui/globeActions.test.mjs",
"src/ui/safeFrameView.test.mjs",
"src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs",
"src/ui/kit/UiBadge.vue",
"src/ui/kit/UiBadge.test.mjs",
"src/ui/kit/UiEmpty.vue",
"src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs",
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
"src/ui/stores/owners.js",
"src/ui/stores/owners.test.mjs",
"src/ui/stores/panels.test.mjs",
"src/ui/stores/status.js",
"src/ui/statusChipsView.test.mjs",
"src/ui/statusChipOwners.test.mjs",
"src/ui/toast.js",
"src/ui/toastView.test.mjs",
"src/ui/stores/sceneInteractions.js",
"src/scenes/dataPacks/presentationView.test.mjs",
"src/ui/stores/dataPacks.js",
"src/scenes/interactionsView.test.mjs",
"src/layers/wind/inspectionMarker.js",
"src/layers/wind/inspectionMarker.test.mjs",
"src/ui/stores/windInspection.js",
"src/ui/stores/missionReplay.js",
"src/layers/launches/replayOverlay.test.mjs",
"src/ui/stores/bhoteKoshiPanel.js",
"src/data/bhoteKoshiPanelView.test.mjs",
"src/ui/stores/celestialRing.js",
"src/celestialRingView.test.mjs",
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js",
"src/overlays/worldOverlayActionsView.test.mjs",
"src/ui/stores/awarenessDirection.js",
"src/layers/awareness/directionOverlay.test.mjs",
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs",
"src/testSupport/hudReadouts.mjs",
"src/ui/stores/firstRun.js",
"src/firstRunLauncherView.test.mjs",
"src/firstRunCopy.js",
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
"src/data/boundaryLabels.test.mjs",
"src/data/packGeometry.test.mjs",
"src/annotations/placeOutline.test.mjs",
"src/annotations/outlineIdentity.test.mjs",
"src/annotations/outlineRungs.test.mjs",
"src/annotations/outlineRungsFallback.test.mjs",
"src/search/photonSetting.test.mjs",
"src/sources/nominatimOutlines.test.mjs",
"src/sources/openFreeMapOutlines.test.mjs",
"src/sources/openFreeMapOutlinesEdges.test.mjs",
"src/standalone/layerSources.test.mjs",
"src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs"
"server/providers/common/rateLimitKeys.test.mjs"
]
+14 -37
View File
@@ -149,19 +149,10 @@
"src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js",
"src/ui/styleParameters.js",
"src/ui/i18n.js",
"src/time/worldClock.js",
"src/ui/kit/UiIcon.vue",
"src/ui/stores/worldTime.js",
"src/ui/views/panels/WorldTimeline.vue"
"src/ui/i18n.js"
],
"external": ["vue", "pinia", "vue-i18n"]
},
"time": {
"exports": ["./time"],
"modules": ["src/time/worldClock.js"],
"external": []
},
"viewer": {
"exports": ["./application/viewer"],
"modules": [
@@ -494,6 +485,7 @@
"exports": ["./server/providers/openai"],
"modules": [
"src/i18n/marker.js",
"src/i18n/locale.js",
"src/hudLayoutPolicy.js",
"server/providers/common/rate-limit.js",
"server/providers/common/request.js",
@@ -1132,8 +1124,7 @@
"src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js",
"src/time/worldClock.js"
"src/ui/i18n.js"
],
"external": ["cesium", "vue", "pinia", "vue-i18n"]
},
@@ -1344,9 +1335,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
"src/time/worldClock.js",
"src/time/julian.js"
"src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -1402,9 +1391,7 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js",
"src/time/julian.js"
"src/i18n/locales/zh-Hant.json"
],
"external": ["cesium"]
},
@@ -1589,8 +1576,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
"src/time/worldClock.js"
"src/ui/unitLabels.js"
],
"external": ["cesium", "satellite.js"]
},
@@ -1630,8 +1616,7 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
"src/i18n/locales/zh-Hant.json"
],
"external": ["cesium"]
},
@@ -1951,8 +1936,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/time/worldClock.js"
"src/layers/streetLevel/providers/mapillary/policy.js"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
},
@@ -2369,6 +2353,7 @@
"src/data/groundSnap.js",
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/osmOverlays.js",
"src/data/installationFeedback.js",
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
@@ -3192,9 +3177,6 @@
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js",
"src/time/viewerClock.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -3302,6 +3284,7 @@
"src/data/geoid.js",
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/osmOverlays.js",
"src/data/installationFeedback.js",
"src/data/satellitePass.js",
"src/data/labelArbiter.js",
@@ -3802,8 +3785,6 @@
"src/ui/stores/bhoteKoshiPanel.js",
"src/hudReadouts.js",
"src/ui/i18n.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -3963,8 +3944,7 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
"src/i18n/locales/zh-Hant.json"
],
"external": []
},
@@ -4483,8 +4463,7 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
"src/i18n/locales/zh-Hant.json"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
@@ -4536,8 +4515,7 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
"src/i18n/locales/zh-Hant.json"
],
"external": ["cesium", "pbf"]
},
@@ -4657,8 +4635,7 @@
"src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js",
"src/i18n/marker.js",
"src/visualStylePolicy.js",
"src/time/worldClock.js"
"src/visualStylePolicy.js"
],
"external": []
},
+20 -3
View File
@@ -3073,20 +3073,27 @@ async function main() {
expectedSlot: targetIndex,
previewPlayDelta: previewPlays - playsBefore,
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
audioState: state.audioState,
tuningStatic: state.tuningStatic,
samples,
spread: Math.max(...samples) - Math.min(...samples),
};
});
check(
'pointer preview never plays and release commits the nearest station once without a needle sweep',
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
tunerDirectRelease.targetId
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
&& tunerDirectRelease.previewPlayDelta === 0
&& tunerDirectRelease.commitPlayDelta === 1
&& tunerDirectRelease.commitPlayDelta === 0
&& tunerDirectRelease.audioState === 'stopped'
&& !tunerDirectRelease.tuningStatic
&& tunerDirectRelease.spread < 0.5,
JSON.stringify(tunerDirectRelease),
);
// The release plays only if a stream was active when the drag began.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
// Earlier cases scroll and resize the directory. Bring the physical drag
// target back into its scroller before deriving viewport mouse coordinates.
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
@@ -3494,6 +3501,9 @@ async function main() {
});
});
await sleep(500);
// Next left the Radio stopped; a release plays only from an active stream.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
const failedTuner = await page.evaluate(async () => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
@@ -3692,7 +3702,14 @@ async function main() {
radio.stopPlayback();
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
// Next plays only while a stream is active, so start from a playing station.
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
});
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
await page.evaluate(() => {
const gev = window.__godsEyeView;
const camera = gev.viewer.camera;
const radio = gev.dataManager.layers.get('radio').module;
const pool = gev.styleManager._radioControls._radioTunerPool;
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
+75 -1
View File
@@ -5,7 +5,8 @@
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
* feather 0/11/100/200, layout independence,
* share/scene restore, and visible text backing including rotated metadata.
* share/scene restore, visible text backing including rotated metadata,
* and Minimal's banner removal and coordinate/attribution clearance.
*/
import fs from 'node:fs';
import path from 'node:path';
@@ -666,6 +667,79 @@ try {
await settleTextBackings(false);
checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome());
}
await page.select('#hud-layout-select', 'minimal');
for (const viewport of [
{ width: 1600, height: 900 },
{ width: 1024, height: 700 },
{ width: 820, height: 700 },
{ width: 390, height: 844 },
]) {
await page.setViewport(viewport);
for (const enabled of [true, false]) {
await page.evaluate(
(enabled) =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled },
}),
enabled,
);
await settleTextBackings(enabled);
// Wait for the responsive shell and the real attribution logo to settle.
await page.waitForFunction(
(mobile) =>
document.body.classList.contains('mobile-shell') === mobile &&
[...document.querySelectorAll('#cesium-credits img')].some(
(img) => img.complete && img.naturalWidth > 0,
),
{},
viewport.width <= 720,
);
const geometry = await page.evaluate(() => {
const banner = document.querySelector('#intel-hud .hud-bottom-bar');
const coordinates = document.querySelector(
'#intel-hud .hud-bottom-left',
);
// The fixed host has zero size; Cesium positions its actual row inside it.
const credits = document.querySelector(
'#cesium-credits .cesium-widget-credits',
);
const coordinateBox = coordinates.getBoundingClientRect();
const creditBox = credits.getBoundingClientRect();
return {
bannerHidden: getComputedStyle(banner).display === 'none',
coordinatesVisible:
coordinateBox.width > 0 && coordinateBox.height > 0,
coordinatesHidden: getComputedStyle(coordinates).display === 'none',
creditsVisible: creditBox.width > 0 && creditBox.height > 0,
gap: creditBox.top - coordinateBox.bottom,
bottom: getComputedStyle(coordinates).bottom,
};
});
const name = `Minimal ${viewport.width}px Scope ${enabled ? 'ON' : 'OFF'}`;
check(
`${name}: centred coordinate banner stays hidden`,
geometry.bannerHidden,
geometry,
);
if (viewport.width <= 720) {
check(
`${name}: phone keeps its compact HUD treatment`,
geometry.coordinatesHidden &&
geometry.creditsVisible &&
geometry.bottom === 'auto',
geometry,
);
} else {
check(
`${name}: coordinate block clears the full attribution row`,
geometry.coordinatesVisible &&
geometry.creditsVisible &&
geometry.gap >= 7.5,
geometry,
);
}
}
}
check('runtime has no uncaught browser errors', errors.length === 0, errors);
} finally {
await browser.close();
+26 -9
View File
@@ -94,7 +94,7 @@
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
@@ -2698,16 +2698,18 @@ async function main() {
// Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then
// trigger each path with the camera idle. The fleet runs every 80 ms,
// independently of rendered FPS. Observe the first completed settle and
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
// rotation refresh can repair an incorrect first reveal.
// independently of rendered FPS. Observe the first completed settle frame
// within 500 ms of forcing moveEnd and the first reveal frame within
// 500 ms of that settle frame: each budget sits inside the 1 s idle
// rotation refresh that could otherwise repair an incorrect first reveal,
// and a ~300 ms headless SwiftShader frame fits inside each of them.
// ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager;
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
// 3D models OFF for this phase: a model-handed-off billboard is hidden
// and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from).
@@ -2769,7 +2771,23 @@ async function main() {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
const observed = await observeArrivalRotation({
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
// landed where the 1 s idle refresh could already have repaired it
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
// observation gets one more attempt; one that measured a wrong angle —
// a wrong first reveal, or a wrong settle frame before a reveal
// timeout — never does.
const observed = await observeArrivalRotationWithRetry({
async prepare() {
// Freshen both layers' rotation passes right before the probe, so
// the settle frame is measured against a pass that just ran rather
// than one left over from the previous probe — with the camera
// idle, a layer otherwise runs a pass only every
// ROTATION_REFRESH_MS.
v.camera.moveEnd.raiseEvent();
await nextFrames(1);
},
scene: v.scene,
startMoveEnd() {
// Only moveEnd can repair the tampered angle at the idle pose.
@@ -2790,7 +2808,6 @@ async function main() {
};
},
});
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
return { r0, ...observed, hasModel };
};
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
@@ -2800,8 +2817,8 @@ async function main() {
military: await probe('bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
+44
View File
@@ -1,3 +1,4 @@
import { isIPv6 } from 'node:net';
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
@@ -60,3 +61,46 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
export function clientKey(req) {
return String(req.socket?.remoteAddress || 'local');
}
const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
/**
* Whether a socket peer address is this machine: IPv4 loopback in any of its
* forms (`127.0.0.0/8`, including the IPv4-mapped `::ffff:127.x` a dual-stack
* listener reports) or the IPv6 loopback. Non-address keys are not loopback.
* @param {string} address - A `clientKey` value.
* @returns {boolean}
*/
export function isLoopbackPeer(address) {
const raw = String(address || '');
const v4 = IPV4_MAPPED_RE.exec(raw)?.[1] ?? raw;
return v4.startsWith('127.') || raw === '::1';
}
/**
* Rate-limit key that survives address rotation, for limiters whose global
* backstop everyone shares. IPv6 privacy extensions hand one host a fresh
* address at will inside its /64, so keying by the full address lets a single
* host mint per-peer quota until the global window closes for everyone else.
* IPv6 peers are keyed by their /64 prefix instead, and an IPv4-mapped
* address folds to its IPv4 form so one host cannot hold two buckets. Any
* other value (IPv4, or the `local` fallback) passes through unchanged.
* @param {import('node:http').IncomingMessage} req
* @returns {string}
*/
export function clientNetworkKey(req) {
const address = clientKey(req);
const mapped = IPV4_MAPPED_RE.exec(address);
if (mapped) return mapped[1];
const bare = address.replace(/%.*$/, ''); // link-local zone id
if (!isIPv6(bare)) return address;
const [head, tail = ''] = bare.split('::');
const headParts = head ? head.split(':') : [];
const tailParts = tail ? tail.split(':') : [];
const missing = Math.max(0, 8 - headParts.length - tailParts.length);
const hextets = [...headParts, ...Array(missing).fill('0'), ...tailParts];
return `${hextets
.slice(0, 4)
.map((h) => h.toLowerCase().replace(/^0+(?=.)/, ''))
.join(':')}::/64`;
}
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { clientNetworkKey, isLoopbackPeer } from './rate-limit.js';
const req = (remoteAddress) => ({ socket: { remoteAddress } });
test('clientNetworkKey keys IPv6 peers by /64 and folds IPv4-mapped peers', () => {
assert.equal(clientNetworkKey(req('2001:db8:1:2::1')), '2001:db8:1:2::/64');
assert.equal(
clientNetworkKey(req('2001:0db8:0001:0002:abcd:ef01:2345:6789')),
'2001:db8:1:2::/64',
);
assert.equal(clientNetworkKey(req('2001:DB8::1')), '2001:db8:0:0::/64');
assert.equal(clientNetworkKey(req('fe80::1%en0')), 'fe80:0:0:0::/64');
assert.equal(clientNetworkKey(req('::1')), '0:0:0:0::/64');
assert.equal(clientNetworkKey(req('::ffff:10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req('10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req(undefined)), 'local');
assert.equal(clientNetworkKey(req('not-an-address')), 'not-an-address');
});
test('isLoopbackPeer recognizes every loopback form and nothing else', () => {
for (const address of ['127.0.0.1', '127.9.9.9', '::1', '::ffff:127.0.0.1']) {
assert.equal(isLoopbackPeer(address), true, address);
}
for (const address of [
'10.0.0.1',
'::ffff:10.0.0.1',
'2001:db8::1',
'::2',
'local',
'',
undefined,
]) {
assert.equal(isLoopbackPeer(address), false, String(address));
}
});
+1 -1
View File
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
/**
* Cross-site gate for keyless routes the map needs in order to render
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
* (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
* those protect credentials and spend, which a keyless route has neither of,
+69 -13
View File
@@ -1,5 +1,12 @@
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
import {
HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_MAX_CHARS,
HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT,
hudSummaryInput,
hudSummaryInstructions,
hudSummaryIsUnspaced,
hudSummaryLocale,
keylessHudSummaryResponse,
} from '../../../src/hudSummaryResponse.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
@@ -18,14 +25,58 @@ function extractOpenAiResponseText(data) {
.trim();
}
function toFiveWordHudSummary(value) {
return String(value || '')
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.trim()
/** Parse the summary and normalize the canonical feed state. */
function parseHudSummaryReply(text, locale) {
let reply = null;
try {
reply = JSON.parse(text);
} catch {
/* not JSON: no summary */
}
const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
? reply.feedState
: null;
return {
summary: toHudSummary(reply?.summary, locale),
feedState,
};
}
function toHudSummary(value, requested) {
const locale = hudSummaryLocale(requested);
const words = String(value || '')
.normalize('NFC')
// Marks, and apostrophes and dots inside a word, belong to it: ar vowel
// signs, fr d'Orléans, ja サンフランシスコ・ベイエリア.
.replace(/[^\p{L}\p{M}\p{N}\s'’·・-]/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.slice(0, 5)
.join(' ');
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
.filter(Boolean);
// Characters, not words: the model counts Rue de Rivoli as one word.
const max = hudSummaryIsUnspaced(locale)
? HUD_SUMMARY_MAX_UNSPACED_CHARS
: HUD_SUMMARY_MAX_CHARS;
return capAtWord(words.join(' '), max, locale);
}
/**
* Cut `text` to `max` characters at a word boundary; a first word longer
* than that is cut where the cap falls.
*/
function capAtWord(text, max, locale) {
const graphemes = [
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
];
if (graphemes.length <= max) return text;
const cut = graphemes[max].index;
let end = 0;
for (const { index, segment } of new Intl.Segmenter(locale, {
granularity: 'word',
}).segment(text)) {
if (index + segment.length > cut) break;
end = index + segment.length;
}
return text.slice(0, end).trim() || text.slice(0, cut);
}
async function handleHudSummary(req, res) {
@@ -77,14 +128,18 @@ async function handleHudSummary(req, res) {
model:
process.env.OPENAI_HUD_SUMMARY_MODEL ||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
instructions: HUD_SUMMARY_INSTRUCTIONS,
input: JSON.stringify(context),
instructions: hudSummaryInstructions(context?.locale),
input: hudSummaryInput(context),
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
reasoning: { effort: 'minimal' },
max_output_tokens: 100,
max_output_tokens: 200,
}),
});
const data = await response.json().catch(() => ({}));
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
const { summary, feedState } = parseHudSummaryReply(
extractOpenAiResponseText(data),
context?.locale,
);
res.statusCode = response.ok && summary ? 200 : response.status || 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
@@ -93,6 +148,7 @@ async function handleHudSummary(req, res) {
res.end(
JSON.stringify({
summary: summary || null,
feedState,
// Never relay `data.error.message`: that is OpenAI's own wording, and
// it carries request ids, organization hints and quota phrasing.
error: response.ok ? null : 'OpenAI HUD summary request failed',
@@ -110,4 +166,4 @@ async function handleHudSummary(req, res) {
}
}
export { handleHudSummary };
export { handleHudSummary, parseHudSummaryReply };
+58 -9
View File
@@ -1,6 +1,12 @@
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import {
makeRateLimiter,
clientKey,
clientNetworkKey,
isLoopbackPeer,
} from './common/rate-limit.js';
import { hasProxySignals } from '../../src/localRequestGate.mjs';
import { readRequestBodyCapped } from './common/request.js';
import { admitSameSite } from './common/same-site.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import {
OVERPASS_MAX_BODY_BYTES,
OVERPASS_MAX_CONCURRENT,
@@ -29,10 +35,21 @@ const _overpassInFlight = new Map();
let _overpassConcurrent = 0;
// Bound body-reading and validation work, including malformed requests and
// cache hits. Keep the tighter upstream budget below independent of this gate.
const _overpassRequestLimiter = makeRateLimiter({
windowMs: 60_000,
max: 300,
// cache hits, before a byte is read. Remote peers share one global window so
// address rotation cannot mint admission without bound (IPv6 peers are keyed
// by /64 for the same reason). A direct loopback request — the owner's own
// browser and the repo QA harnesses, with no forwarding headers — has its own
// per-peer window outside that global, so busy LAN peers cannot lock the owner
// out of the map. A request relayed by a reverse proxy on this machine also
// arrives from loopback, but it is remote traffic: it is admitted from the
// remote pool under its own socket-keyed bucket, never from the owner's. The
// forwarded client address is not trusted as identity. Only admission is
// isolated: the upstream budget below (90/min per key, 300/min globally) is
// shared, so an owner request that misses cache can still be refused there.
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
const _overpassPeerAdmission = makeRateLimiter({
...OVERPASS_ADMISSION_WINDOW,
globalMax: 900,
});
@@ -42,6 +59,33 @@ const _overpassRateLimiter = makeRateLimiter({
globalMax: 300,
});
/**
* Classify a request for admission. Direct loopback — a loopback socket with
* no forwarding header — is the owner's own pool; everything else, including a
* loopback socket that carries forwarding headers (a local reverse proxy), is
* remote. A proxied request keeps its socket identity under a `proxied:`
* prefix, so it never shares a bucket with the owner's direct requests.
* @param {import('http').IncomingMessage} req
* @returns {{direct: boolean, key: string}}
*/
function overpassAdmissionClass(req) {
const peer = clientNetworkKey(req);
if (!isLoopbackPeer(clientKey(req))) return { direct: false, key: peer };
if (hasProxySignals(req.headers || {}))
return { direct: false, key: `proxied:${peer}` };
return { direct: true, key: peer };
}
/**
* Spend one admission slot for this request; false when its bucket is over its
* window (or, for remote traffic, when the shared global window is full).
* @param {{direct: boolean, key: string}} admission - `overpassAdmissionClass(req)`.
* @returns {boolean}
*/
function admitOverpassRequest({ direct, key }) {
return (direct ? _overpassLoopbackAdmission : _overpassPeerAdmission)(key);
}
/**
* Write a completed Overpass payload to the HTTP response.
*
@@ -103,8 +147,13 @@ function overpassProxy({ routing = {} } = {}) {
return;
}
if (admitSameSite(req, res)) return;
if (!_overpassRequestLimiter(clientKey(req))) {
// Keyless gate: refuses cross-site browser requests but, unlike the
// cost-bearing routes, admits the forwarding headers a reverse proxy
// adds — the layers that depend on Overpass must keep working for an
// instance deployed the way the README documents.
if (admitSameSiteKeyless(req, res)) return;
const admission = overpassAdmissionClass(req);
if (!admitOverpassRequest(admission)) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
@@ -163,7 +212,7 @@ function overpassProxy({ routing = {} } = {}) {
// requests; boundary-class queries keep their month-long TTL.
readDisk: () =>
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
allowUpstream: () => _overpassRateLimiter(admission.key),
loadUpstream: async () => {
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
return {
+10 -6
View File
@@ -122,12 +122,14 @@ function sanitizeOverpassBody(rawBody) {
const boundedSets = new Set();
for (let stmt of withoutTags.split(';')) {
stmt = stmt.trim();
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
// The settings block (`[out:json][timeout:25]`) was blanked together with
// the tag filters, so it arrives here as an empty statement.
if (!stmt || /^out\b/.test(stmt)) continue;
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
// misread as a spatial bound. Bounds live in (...) / function calls / set
// refs, never inside [...], so the probe loses nothing real.
// Strip output-set assignments (NOT input bounds). With the tag filters
// already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
// be misread as a spatial bound; bounds live in (...) / function calls /
// set refs, never inside [...], so nothing real was lost.
const outSets = [];
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
outSets.push(name);
@@ -166,8 +168,10 @@ function sanitizeOverpassBody(rawBody) {
if (bounded) for (const name of outSets) boundedSets.add(name);
}
// Tolerate whitespace around the directive so `[ timeout : N ]` is clamped
// rather than passed through as written.
const clamped = data.replace(
/\[timeout:\s*(\d+)\s*\]/gi,
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
(_, n) =>
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
);
@@ -3,10 +3,24 @@ import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { spawnSync } from 'node:child_process';
import { overpassProxy } from '../overpass.js';
import { _overpassCache } from './cache.js';
import { sanitizeOverpassBody } from './query.js';
const form = (query) => `data=${encodeURIComponent(query)}`;
// The limiters are module-level, so every test that spends admission mocks a
// clock a full day past the previous one in execution order: no test can see
// another's window, whatever order (or randomization) they run in. A new
// test that reaches admission must call it too: earlier tests leave
// timestamps days ahead of the real clock, and those still count.
let clockBase = Date.now() + 86_400_000;
function freshClock(t) {
clockBase += 86_400_000;
const clock = { now: clockBase };
t.mock.method(Date, 'now', () => clock.now);
return clock;
}
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
for (const query of [
'node[',
@@ -37,6 +51,15 @@ test('bounded queries preserve quoted brackets, comments, multiple tags and time
query.replace('[timeout:999]', '[timeout:30]'),
);
}
// Whitespace inside the directive is clamped too, not passed through.
const spaced = sanitizeOverpassBody(
form('[out:json][ timeout : 999 ];node(around:20,1,1);out;'),
);
assert.equal(spaced.ok, true);
assert.equal(
new URLSearchParams(spaced.body).get('data'),
'[out:json][timeout:30];node(around:20,1,1);out;',
);
});
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
@@ -126,7 +149,13 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
{ 'sec-fetch-site': 'cross-site' },
{ 'sec-fetch-site': 'same-site' },
{ origin: 'null' },
{ 'x-forwarded-for': '127.0.0.1' },
// Forwarding headers do not launder a cross-site request.
{ 'x-forwarded-for': '10.0.0.9', 'sec-fetch-site': 'cross-site' },
{
'x-forwarded-proto': 'https',
origin: 'http://localhost:4173',
'sec-fetch-site': 'same-origin',
},
]) {
const response = await call(route, { headers, forbidRead: true });
assert.equal(response.statusCode, 403);
@@ -136,12 +165,24 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
});
}
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async (t) => {
freshClock(t);
const route = handler();
for (const headers of [
{},
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
// The README's reverse-proxy deployment: Overpass is keyless, so the
// forwarding headers the proxy adds are admitted (as for terrain heights).
{
host: 'globe.example.lan',
origin: 'https://globe.example.lan',
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.7',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'globe.example.lan',
},
{ 'x-forwarded-for': '127.0.0.1' },
]) {
const response = await call(route, { headers });
assert.equal(response.statusCode, 400);
@@ -158,8 +199,7 @@ test('same-origin, LAN and non-browser requests still reach validation; GET stat
});
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
let now = Date.now() + 120000;
t.mock.method(Date, 'now', () => now);
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
@@ -173,6 +213,216 @@ test('invalid requests spend admission quota before parsing, including on an unc
assert.equal(denied.reads, 0);
assert.equal(denied.headers['retry-after'], '60');
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
now += 60001;
clock.now += 60_001;
assert.equal((await call(route, { peer })).statusCode, 400);
});
test('the 900/min global window spans non-loopback peers only; loopback keeps its own window', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (const peer of ['10.0.0.1', '10.0.0.2', '10.0.0.3']) {
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, { peer })).statusCode, 400, peer);
}
}
const starved = await call(route, { peer: '10.0.0.4', forbidRead: true });
assert.equal(starved.statusCode, 429, 'global window is full for LAN peers');
assert.equal(starved.reads, 0);
for (const peer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
const owner = await call(route, { peer });
assert.equal(owner.statusCode, 400, `${peer} is admitted past the global`);
assert.equal(owner.reads, 1);
}
clock.now += 60_001;
assert.equal((await call(route, { peer: '10.0.0.4' })).statusCode, 400);
});
test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: `2001:db8:1:2::${(i % 200) + 1}` }))
.statusCode,
400,
);
}
const rotated = await call(route, {
peer: '2001:db8:1:2:ffff:ffff:ffff:9',
forbidRead: true,
});
assert.equal(rotated.statusCode, 429, 'a rotating host stays in one bucket');
assert.equal(
(await call(route, { peer: '2001:db8:1:3::1' })).statusCode,
400,
);
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: '::ffff:10.1.1.1' })).statusCode,
400,
);
}
const unmapped = await call(route, { peer: '10.1.1.1', forbidRead: true });
assert.equal(unmapped.statusCode, 429, 'mapped and bare IPv4 share a bucket');
clock.now += 60_001;
assert.equal((await call(route, { peer: '10.1.1.1' })).statusCode, 400);
});
test('cache hits spend admission budget but not the 90/min upstream budget', async (t) => {
const clock = freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
_overpassCache.delete(hitKey);
});
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
// Not data: nothing is cached or written to disk by this test.
return new Response('upstream unavailable', { status: 503 });
});
const hit = 'node(around:20,1,1);out;';
const hitKey = `data=${encodeURIComponent(hit)}`;
_overpassCache.set(hitKey, {
status: 200,
body: '{"elements":[]}',
contentType: 'application/json',
endpoint: 'https://overpass.example/api/interpreter',
cachedAt: clock.now,
});
const route = handler();
const peer = '10.7.7.7';
// More cache hits than the upstream limiter would allow per peer.
for (let i = 0; i < 100; i++) {
const response = await call(route, { peer, body: form(hit) });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['x-overpass-cache'], 'HIT');
}
assert.equal(fetches, 0);
const miss = await call(route, {
peer,
body: form('node(around:20,2,2);out;'),
});
assert.equal(fetches, 1, 'the first miss still reaches upstream');
assert.notEqual(miss.statusCode, 429);
for (let i = 0; i < 199; i++) {
assert.equal(
(await call(route, { peer, body: form(hit) })).statusCode,
200,
);
}
const exhausted = await call(route, {
peer,
body: form(hit),
forbidRead: true,
});
assert.equal(exhausted.statusCode, 429, 'hits spent the admission window');
assert.equal(exhausted.reads, 0);
});
test('a local reverse proxy cannot spend the direct-loopback reserve; owner cache hits survive it', async (t) => {
const clock = freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
const hit = 'node(around:20,3,3);out;';
const hitKey = `data=${encodeURIComponent(hit)}`;
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
_overpassCache.delete(hitKey);
});
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
_overpassCache.set(hitKey, {
status: 200,
body: '{"elements":[]}',
contentType: 'application/json',
endpoint: 'https://overpass.example/api/interpreter',
cachedAt: clock.now,
});
const route = handler();
// The proxy connects over loopback; a rotating forwarded client address
// must not mint fresh buckets, so all 300 land in one socket-keyed bucket.
for (const proxyPeer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
const proxied = (i) => ({
peer: proxyPeer,
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
body: form(hit),
});
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, proxied(i))).statusCode, 200, proxyPeer);
}
const refused = await call(route, { ...proxied(300), forbidRead: true });
assert.equal(refused.statusCode, 429, `${proxyPeer} proxy bucket is full`);
assert.equal(refused.reads, 0);
for (const peer of ['127.0.0.1', '::1']) {
const owner = await call(route, { peer, body: form(hit) });
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
}
clock.now += 60_001;
assert.equal((await call(route, proxied(0))).statusCode, 200);
}
});
test('proxied loopback traffic draws on the shared remote global window', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (const peer of ['10.0.0.1', '10.0.0.2']) {
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, { peer })).statusCode, 400, peer);
}
}
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { headers: { via: '1.1 proxy' } })).statusCode,
400,
);
}
const starved = await call(route, { peer: '10.0.0.3', forbidRead: true });
assert.equal(starved.statusCode, 429, 'proxied requests counted globally');
assert.equal((await call(route, {})).statusCode, 400, 'owner unaffected');
});
test("a local proxy has its own upstream bucket, so its misses cannot spend the owner's", async (t) => {
freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
});
// Not data: nothing is cached, so every request below is a miss.
t.mock.method(
globalThis,
'fetch',
async () => new Response('upstream unavailable', { status: 503 }),
);
const route = handler();
const miss = form('node(around:20,4,4);out;');
// The upstream limiter's own refusal is the only miss without this header.
const passedLimiter = (response) =>
response.headers['x-overpass-cache'] !== undefined;
const proxied = { peer: '127.0.0.1', headers: { via: '1.1 proxy' } };
for (let i = 0; i < 90; i++) {
const response = await call(route, { ...proxied, body: miss });
assert.equal(passedLimiter(response), true, `proxied miss ${i}`);
}
const spent = await call(route, { ...proxied, body: miss });
assert.equal(spent.statusCode, 429);
assert.equal(passedLimiter(spent), false, 'the proxy bucket is spent');
const owner = await call(route, { peer: '127.0.0.1', body: miss });
assert.equal(passedLimiter(owner), true, 'the owner keeps its own bucket');
});
+6 -2
View File
@@ -7,9 +7,13 @@ export {
publicRadioHttpsUrl,
} from './radio/stations.js';
export function radioBrowserProxy() {
const middleware = createRadioProxyMiddleware();
let middleware;
const initialize = () =>
(middleware ||= createRadioProxyMiddleware({
priorityCountries: process.env.RADIO_PRIORITY_COUNTRIES || '',
}));
const install = (server) => {
server.middlewares.use('/api/radio', middleware);
server.middlewares.use('/api/radio', initialize());
};
return {
name: 'radio-browser-proxy',
+106 -15
View File
@@ -1,3 +1,4 @@
import { normalizeRadioCountryInput } from '../../../src/data/radioCountry.js';
import { lookup as lookupDns } from 'node:dns/promises';
import { randomUUID } from 'node:crypto';
@@ -27,6 +28,34 @@ import {
RADIO_FALLBACK_MIRRORS,
RADIO_REJECTED_STATION_COUNT_MAX,
} from './constants.js';
const PRIORITY_COUNTRY_LIMIT = 8;
const COUNTRY_QUERY_LIMIT = 200;
const COUNTRY_RESERVED_STATIONS = 40;
const REFRESH_RETRY_MS = 60_000;
const MAX_MIRROR_ATTEMPTS = 3;
/** Validate an operator-owned list; malformed configuration must not silently widen queries. */
export function parseRadioPriorityCountries(value = '') {
const entries = Array.isArray(value) ? value : String(value).split(',');
const countries = [];
for (const entry of entries) {
const input = String(entry).trim().toUpperCase();
if (!input) continue;
const normalized = normalizeRadioCountryInput(input);
if (!/^[A-Z]{2}$/.test(input) || !normalized.valid)
throw new TypeError(
'RADIO_PRIORITY_COUNTRIES requires ISO alpha-2 country codes',
);
const code = normalized.code;
if (!countries.includes(code)) countries.push(code);
}
if (countries.length > PRIORITY_COUNTRY_LIMIT)
throw new RangeError(
'RADIO_PRIORITY_COUNTRIES supports at most eight countries',
);
return countries;
}
async function mapRadioConcurrent(values, concurrency, mapper) {
const results = new Array(values.length);
let cursor = 0;
@@ -49,7 +78,9 @@ export function createRadioProxyMiddleware({
fetchImpl = null,
lookupImpl = lookupDns,
now = Date.now,
priorityCountries = [],
} = {}) {
const countries = parseRadioPriorityCountries(priorityCountries);
let mirrorCache = { origins: [...RADIO_FALLBACK_MIRRORS], cachedAt: 0 };
let mirrorPromise = null;
let catalogCache = null;
@@ -61,6 +92,9 @@ export function createRadioProxyMiddleware({
const catalogInstance = randomUUID();
let servedStationIds = new Set();
let refreshPromise = null;
let retryAfter = 0;
let partialCatalog = null;
let refreshError = null;
async function fetchJson(url, maxBytes = RADIO_RESPONSE_MAX_BYTES) {
const destination = radioProxyDestination(url);
@@ -139,7 +173,7 @@ export function createRadioProxyMiddleware({
async function fetchPath(pathname) {
let lastError = null;
for (const origin of await mirrors()) {
for (const origin of (await mirrors()).slice(0, MAX_MIRROR_ATTEMPTS)) {
try {
return await fetchJson(`${origin}${pathname}`);
} catch (error) {
@@ -160,20 +194,27 @@ export function createRadioProxyMiddleware({
'aviation',
'marine',
'traffic',
];
].map((tag) => ({ tag }));
const baseQueryCount = queries.length;
queries.push(...countries.map((country) => ({ country })));
const outcomes = await mapRadioConcurrent(
queries,
3,
async (tag, index) => {
async ({ tag, country }, index) => {
const params = new URLSearchParams({
has_geo_info: 'true',
is_https: 'true',
hidebroken: 'true',
order: 'clickcount',
reverse: 'true',
limit: index === 0 ? '1800' : '220',
limit: country
? String(COUNTRY_QUERY_LIMIT)
: index === 0
? '1800'
: '220',
});
if (tag) params.set('tag', tag);
if (country) params.set('countrycode', country);
try {
const rows = await fetchPath(`/json/stations/search?${params}`);
if (!Array.isArray(rows))
@@ -195,7 +236,12 @@ export function createRadioProxyMiddleware({
const rejectedRows = rows.length - structuralRows.length;
const stations = structuralRows
.map(normalizeRadioBrowserStation)
.filter(Boolean);
.filter(
(station) =>
station && (!country || station.countryCode === country),
);
if (country && rows.length > COUNTRY_QUERY_LIMIT)
throw new Error('Radio Browser exceeded the country query limit');
const requestedTag = normalizeRadioTag(tag);
const requestedTagCovered =
!requestedTag ||
@@ -207,10 +253,12 @@ export function createRadioProxyMiddleware({
),
);
return {
// Query coverage is based on accepted rows, not merely a payload that
// happens to match the upstream schema. Specialist responses must
// also contain an accepted station tagged for the requested category.
succeeded: stations.length > 0 && requestedTagCovered,
// A valid country response can contain no playable stations. Country
// coverage is advisory; only broad/specialist queries gate health.
// Specialist responses need an accepted station with matching tags.
succeeded: country
? rows.length === 0 || structuralRows.length > 0
: stations.length > 0 && requestedTagCovered,
stations,
rejectedRows,
};
@@ -233,9 +281,24 @@ export function createRadioProxyMiddleware({
seen.add(station.id);
selected.push(station);
};
// Reserve country slots first; low-click regional stations cannot lose all
// representation to the global charts. Keep room for all eight specialist
// seeds (8 * 45) and global fill even at the eight-country limit (8 * 40).
for (const rows of resultSets.slice(baseQueryCount)) {
const ranked = [...rows].sort(
(a, b) => b.clickCount - a.clickCount || a.id.localeCompare(b.id),
);
let added = 0;
for (const station of ranked) {
if (seen.has(station.id)) continue;
take(station);
if (++added >= COUNTRY_RESERVED_STATIONS) break;
}
}
// Seed specialist station-tag queries before popularity fill so operational
// categories remain represented even when global click charts skew musical.
for (const rows of resultSets.slice(1)) rows.slice(0, 45).forEach(take);
for (const rows of resultSets.slice(1, baseQueryCount))
rows.slice(0, 45).forEach(take);
resultSets
.flat()
.sort(
@@ -243,9 +306,9 @@ export function createRadioProxyMiddleware({
)
.forEach(take);
const timestamp = now();
const successfulQueries = outcomes.filter(
(outcome) => outcome.succeeded,
).length;
const successfulQueries = outcomes
.slice(0, baseQueryCount)
.filter((outcome) => outcome.succeeded).length;
const broadQueryHealthy =
outcomes[0].succeeded && outcomes[0].stations.length > 0;
const healthReasons = [];
@@ -261,7 +324,18 @@ export function createRadioProxyMiddleware({
);
const coverage = {
successfulQueries,
totalQueries: queries.length,
totalQueries: baseQueryCount,
...(countries.length
? {
priorityCountries: countries.map((country, index) => ({
countryCode: country,
succeeded: outcomes[baseQueryCount + index].succeeded,
stationCount: selected.filter(
(station) => station.countryCode === country,
).length,
})),
}
: {}),
stationCount: selected.length,
healthyStationMinimum: RADIO_CATALOG_HEALTHY_MIN_STATIONS,
};
@@ -315,7 +389,24 @@ export function createRadioProxyMiddleware({
return { ...catalogCache, stale: false };
}
if (!refreshPromise) {
refreshPromise = refreshCatalog().finally(() => {
refreshPromise = (async () => {
if (now() < retryAfter) {
if (refreshError) throw refreshError;
return partialCatalog;
}
try {
const catalog = await refreshCatalog();
refreshError = null;
partialCatalog = catalog.degraded ? catalog : null;
retryAfter = catalog.degraded ? now() + REFRESH_RETRY_MS : 0;
return catalog;
} catch (error) {
refreshError = error;
partialCatalog = null;
retryAfter = now() + REFRESH_RETRY_MS;
throw error;
}
})().finally(() => {
refreshPromise = null;
});
}
+3 -2
View File
@@ -1,6 +1,5 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { worldClock } from '../time/worldClock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js';
@@ -30,6 +29,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
@@ -73,7 +73,7 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock({ world: worldClock });
const weatherClock = createWeatherClock();
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
@@ -170,6 +170,7 @@ export function createApplicationCatalog({
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
+2 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 33);
assert.equal(first.layers.length, 35);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -155,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 33);
assert.equal(catalog.layers.length, 35);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+1 -5
View File
@@ -1,10 +1,6 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
import { worldTimeFor } from '../../time/worldClock.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer({
worldNow: worldTimeFor('gnss-integrity'),
...options,
});
return createGnssIntegrityLayer(options);
}
-4
View File
@@ -2,9 +2,6 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
import { worldTimeFor } from '../../time/worldClock.js';
const imageryTime = worldTimeFor('recent-imagery');
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
@@ -18,6 +15,5 @@ export function createApplicationRecentImagery({ source }) {
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
now: () => new Date(imageryTime()),
});
}
-8
View File
@@ -14,8 +14,6 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { worldClock, worldTimeFor } from '../time/worldClock.js';
import { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
@@ -60,12 +58,6 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
defer(
followWorldTime(viewer, worldTimeFor('sky'), {
subscribe: worldClock.subscribe,
requestRender: () => governorRequestRender('world-time'),
}),
);
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 33);
assert.equal(actual.layers.length, 35);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+1 -14
View File
@@ -1,9 +1,5 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js';
import { worldClock, worldTimeFor } from './time/worldClock.js';
// The ring shows the sun and moon at the time the sky is drawn.
const skyTime = worldTimeFor('sky');
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05;
@@ -449,12 +445,6 @@ export class CelestialRing {
if (typeof document !== 'undefined' && document.hidden) return;
governorRequestRender('celestial-ephemeris');
}, 60_000);
// A jump in the world time moves the sun and moon at once.
this._skyDate = new Date();
this._skyTime = new Cesium.JulianDate();
this._unsubscribeWorldTime = worldClock.subscribe(() => {
this._ephemerisDirty = true;
});
this.setEnabled(this.enabled);
}
@@ -811,8 +801,7 @@ export class CelestialRing {
return;
}
this._skyDate.setTime(skyTime());
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
const time = Cesium.JulianDate.now();
if (!this._updateEphemeris(time)) return;
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera;
@@ -901,8 +890,6 @@ export class CelestialRing {
clearInterval(this._ephemerisTimer);
this._ephemerisTimer = null;
}
this._unsubscribeWorldTime?.();
this._unsubscribeWorldTime = null;
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
if (this._shown()) {
+17 -41
View File
@@ -229,30 +229,13 @@ const GUARDED_PROPS = new Set([
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
// stack height, proven non-negative below, so evaluating them at 0 is a
// conservative floor rather than a guess. The world timeline's lift raises
// the dock, its trays and the credit together, so it moves no gap either.
// conservative floor rather than a guess.
const VETTED_VARS = new Set([
'--dock-pinned-stack-height',
'--dock-lower-pinned-height',
'--dock-location-pinned-height',
'--dock-presets-pinned-height',
'--world-timeline-lift',
]);
// Custom properties CSS sets to a fixed length besides their 0 default. The
// clearance checks run at each length CSS gives them, not only at 0.
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
const LIFTS_PX = [0, 50, 80];
let liftPx = 0;
function eachLift(check) {
for (const lift of LIFTS_PX) {
liftPx = lift;
try {
check(lift);
} finally {
liftPx = 0;
}
}
}
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
function ownBoxEntries() {
@@ -332,7 +315,6 @@ function toPx(value, viewportHeight, where) {
if (term.startsWith('var(')) {
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
if (name === '--world-timeline-lift') total += liftPx;
continue; // proven non-negative; 0 is the conservative floor
}
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
@@ -462,8 +444,7 @@ test('custom properties inside modelled offsets are provably non-negative', () =
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
for (const decl of declared) {
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
}
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const start = layout.indexOf('_updateCommandDockTrayStack() {');
@@ -536,18 +517,16 @@ test('every open dock tray clears the required credit at every modelled viewport
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
// 8px at 720px, while the credit keeps its 2vh base throughout.
const failures = [];
eachLift((lift) => {
for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
}
for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`);
}
}
}
});
}
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
});
@@ -587,24 +566,21 @@ test('the full-width context rail clears the required credit at every modelled v
'none',
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
);
eachLift((lift) => {
for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
}
});
for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`);
}
}
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
});
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
const lift = 'var(--world-timeline-lift, 0px)';
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
assert.equal(
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
`calc(2vh + 5rem + ${lift})`,
'calc(2vh + 5rem)',
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
);
});
+14
View File
@@ -150,6 +150,20 @@ export const DATA_CREDITS = [
key: 'usgs',
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
{
key: 'openseamap',
html:
'Sea marks overlay: ' +
'<a href="https://www.openseamap.org" target="_blank" rel="noopener">© OpenSeaMap contributors</a> (CC BY-SA 2.0) · ' +
'Data © OpenStreetMap contributors (ODbL)',
},
{
key: 'opensnowmap',
html:
'Ski pistes overlay: ' +
'<a href="https://www.opensnowmap.org" target="_blank" rel="noopener">© www.opensnowmap.org</a> (CC BY-SA) · ' +
'Data © OpenStreetMap contributors (ODbL)',
},
{
key: 'nasa-gibs',
html:
+68 -3
View File
@@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([
'loading',
'degraded',
'stale',
'partial',
'fallback',
'unavailable',
]);
@@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
partial: 'PARTIAL',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
@@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({
degraded: 2,
stale: 3,
fallback: 4,
nominal: 5,
off: 6,
partial: 5,
nominal: 6,
off: 7,
});
export { layerFeedState } from './feedState.js';
@@ -90,7 +93,7 @@ export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
/**
* Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'|'off'|null}
*/
export function worstFeedState(states) {
let worst = null;
@@ -205,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) {
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
});
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
if (overall === 'partial')
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
}
@@ -251,3 +256,63 @@ export function viewStateLayerRow(layer, options) {
lastUpdate: snap.lastUpdate,
};
}
/**
* The feed state and up to two layer names of a provenance envelope that is
* not nominal, or null; the worst layers come first, so the names match the
* state. Values stay English (`state` is a feed-state id, `names` are
* `layer.name`); the HUD translates them for display.
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
* @returns {{ state: string, names: string[] }|null}
*/
export function provenanceTag(envelope) {
const overall = envelope?.overall;
if (!overall || overall === 'nominal' || overall === 'off') return null;
const names = (envelope.layers || [])
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.sort(
(a, b) =>
FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState],
)
.map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2);
return { state: overall, names };
}
const upperCase = (word) => word.toUpperCase();
/**
* `STATE | text | LAYERS`: the state leads so a narrow readout never clips
* it; the layer names trail so they never push out the text.
* @param {string} text
* @param {{ state: string, names: string[] }|null} tag From `provenanceTag`.
* @param {(state: string) => string} [stateLabel]
* @param {(name: string) => string} [layerLabel]
* @returns {string}
*/
export function provenanceLine(
text,
tag,
stateLabel = upperCase,
layerLabel = upperCase,
) {
if (!tag) return text;
return [stateLabel(tag.state), text, tag.names.map(layerLabel).join('/')]
.filter(Boolean)
.join(' | ');
}
/**
* Require a matching feedState for warning states: a view that is not
* nominal falls back on a missing or different state.
* @param {{ feedState?: string|null }|null} response The proxy's reply.
* @param {{ overall?: string|null }|null} provenance The context's envelope.
* @returns {boolean}
*/
export function hudSummaryMatchesProvenance(response, provenance) {
const state = provenance?.overall;
if (!state || state === 'nominal' || state === 'off') return true;
return response?.feedState === state;
}
+52 -1
View File
@@ -11,6 +11,7 @@ import {
layerFeedState,
layerSnapshot,
layerSnapshots,
provenanceTag,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
@@ -90,7 +91,15 @@ test('layerFeedState matches the chip classifier for every honesty class', () =>
test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual(
[...LAYER_FEED_STATES],
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
[
'nominal',
'loading',
'degraded',
'stale',
'partial',
'fallback',
'unavailable',
],
);
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) {
@@ -107,6 +116,33 @@ test('chip labels and severity cover every feed-state the envelope can emit', ()
assert.equal(worstFeedState([]), null);
});
test('partial is a feed-state of its own: above nominal, below every fault', () => {
const layer = (id, stats) => ({ id, name: id, enabled: true, stats });
const partial = layer('flights', {
partial: true,
count: 4,
lastUpdate: NOW,
});
const nominal = layer('vessels', { count: 9, lastUpdate: NOW });
const stale = layer('cctv', { stale: true, count: 2, lastUpdate: NOW });
const overall = (layers) =>
feedProvenanceEnvelope(layers, { now: NOW }).overall;
assert.equal(overall([partial]), 'partial');
assert.equal(overall([partial, nominal]), 'partial');
assert.equal(overall([nominal, partial]), 'partial');
assert.equal(overall([partial, stale]), 'stale');
assert.equal(overall([stale, partial]), 'stale');
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
const note = feedProvenanceEnvelope([partial], { now: NOW }).note;
assert.match(note, /coverage is incomplete/);
assert.doesNotMatch(note, /do not describe this as live/);
assert.equal(
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
true,
);
});
test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot(
{
@@ -258,3 +294,18 @@ test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
test('the warning tag names the layers in the reported state first', () => {
const tag = provenanceTag({
overall: 'unavailable',
layers: [
{ name: 'Live Flights', feedState: 'partial' },
{ name: 'Live AIS Vessels', feedState: 'partial' },
{ name: 'CCTV', feedState: 'unavailable' },
],
});
assert.deepEqual(tag, {
state: 'unavailable',
names: ['CCTV', 'Live Flights'],
});
});
+2
View File
@@ -666,6 +666,8 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'satellites',
}),
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
Object.freeze({
id: 'street-level',
token: '0',
+13 -15
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 32);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
assert.equal(REGISTERED_LAYER_IDS.length, 34);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,20 +242,18 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '6');
assert.equal(nextLayerStateToken(), '8');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '6',
bravo: '7',
alpha: '8',
bravo: '9',
}),
'8',
'00',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
),
...Object.fromEntries([...'89'].map((digit) => [`prior-${digit}`, digit])),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
assert.equal(
@@ -307,8 +305,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '6',
next: '7',
future: '8',
next: '9',
}),
true,
);
@@ -318,7 +316,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 6/,
/next free token 8/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +339,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8', competing: '8' },
),
/next free token 7/,
/next free token 9/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
+30 -28
View File
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 34);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -137,6 +137,8 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'gnss-integrity': '3',
'air-quality': '4',
's2-ghostmaps': '5',
'sea-marks': '6',
'ski-pistes': '7',
},
);
assert.throws(
@@ -162,9 +164,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
parseLayerStateTokenReservations([...reservationRows, ['future', '8']])
.future,
'6',
'8',
);
assert.throws(
() =>
@@ -182,14 +184,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '6']] },
{ rows: [...reservationRows, ['retired-layer', '8']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '7');
assert.equal(nextLayerStateToken(published), '9');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '7',
newcomer: '9',
}),
true,
);
@@ -197,15 +199,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '6',
competing: '8',
}),
/next free token 7/,
/next free token 9/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '6',
newcomer: '8',
}),
/changed or removed: retired-layer/,
);
@@ -213,8 +215,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -235,8 +237,8 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '6']],
[{ id: 'pr-b', token: '6' }],
[...reservationRows, ['pr-b', '8']],
[{ id: 'pr-b', token: '8' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -244,10 +246,10 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']],
[
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '6' },
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '8' },
],
);
const staleRebase = runCheck();
@@ -255,16 +257,16 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '7');
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
assert.equal(nextLayerStateToken(published), '9');
const validBaselineRows = [...reservationRows, ['pr-a', '8']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 7/);
assert.match(helper.stdout, /pr-b: 9/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -272,20 +274,20 @@ test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '7' },
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '9' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /33 published, 1 new/);
assert.match(corrected.stdout, /35 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -337,7 +339,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'6789'].map((digit) => [
const priorDigits = [...'89'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -374,7 +376,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 75);
assert.equal(params.get('l').length, 79);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -392,7 +394,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 33);
assert.equal(Object.keys(published).length, 35);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -22,6 +22,8 @@
["rocket-launches", "x"],
["s2-ghostmaps", "5"],
["satellites", "s"],
["sea-marks", "6"],
["ski-pistes", "7"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
["traffic", "t"],
+190
View File
@@ -0,0 +1,190 @@
import * as Cesium from 'cesium';
import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
/**
* @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski
* pistes (#10). Each overlay is its own layer with its own row under
* Infrastructure; both are built from the descriptors below.
*
* OpenRailwayMap and OpenInfraMap, also named in #10, are deliberately not
* included:
* - OpenRailwayMap's public tile server (tiles.openrailwaymap.org) only
* serves requests whose Referer is openrailwaymap.org; every other Referer,
* including none, gets 403. A tab on this app sends its own origin, so the
* tiles would fail, and embedding them would run against their tile-usage
* policy either way.
* - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
*
* The overlays drape on whatever surface the active map stack offers (see
* `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google
* Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with
* neither leaves them hidden, and the row says so.
* @module data/osmOverlays
*/
/** @type {ReadonlyArray<{id:string,name:string,icon:string,source:string,urlTemplate:string,credit:string,maximumLevel:number}>} */
export const OSM_OVERLAYS = Object.freeze([
Object.freeze({
id: 'sea-marks',
name: 'Sea Marks',
icon: '⚓',
source: 'OpenSeaMap',
urlTemplate: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
// Chart tiles CC BY-SA 2.0; the underlying data is OpenStreetMap (ODbL).
credit:
'Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)',
maximumLevel: 18,
}),
Object.freeze({
id: 'ski-pistes',
name: 'Ski Pistes',
icon: '⛷',
source: 'OpenSnowMap',
urlTemplate: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png',
// OpenSnowMap asks for "(c)www.opensnowmap.org" wherever its tiles appear.
credit:
'Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)',
maximumLevel: 18,
}),
]);
/** Why an enabled overlay is showing nothing, or null. */
export function osmOverlayHiddenReason(enabled, hostKind) {
return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null;
}
/**
* Create the layer for one overlay descriptor. Each application catalog gets
* its own instances; the Cesium factories are injectable so tests run without
* WebGL.
*/
export function createOsmOverlayLayer(
overlay,
{
host = resolveImageryHost,
createProvider = (descriptor) =>
new Cesium.UrlTemplateImageryProvider({
url: descriptor.urlTemplate,
credit: descriptor.credit,
maximumLevel: descriptor.maximumLevel,
}),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
} = {},
) {
if (!OSM_OVERLAYS.includes(overlay))
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
let viewer = null;
let mapStack = null;
let unsubscribeMap = null;
let enabled = false;
let hostInfo = { collection: null, kind: 'none' };
/** The imagery layer this instance owns while enabled, and where it sits. */
let owned = null;
const usable = (collection) => collection && !collection.isDestroyed?.();
function detach(destroy) {
if (!owned) return;
const { collection, layer } = owned;
if (usable(collection) && collection.contains(layer))
collection.remove(layer, destroy);
else if (destroy && !layer.isDestroyed?.()) layer.destroy?.();
owned.collection = null;
if (destroy) owned = null;
}
function resolveHost() {
return (
host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || {
collection: null,
kind: 'none',
}
);
}
/** Create, move or drop the owned imagery layer to match the state. */
function sync() {
if (!viewer) return;
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
if (!enabled) {
detach(true);
} else {
owned ||= {
layer: createImageryLayer(createProvider(overlay)),
collection: null,
};
if (owned.collection !== hostInfo.collection) {
detach(false);
if (usable(hostInfo.collection)) {
// Appended, so it draws above the map stack's base imagery.
hostInfo.collection.add(owned.layer);
owned.collection = hostInfo.collection;
}
}
}
viewer.scene?.requestRender?.();
}
return {
id: overlay.id,
name: overlay.name,
icon: overlay.icon,
source: overlay.source,
updateInterval: 0,
init(nextViewer) {
viewer = nextViewer;
},
/** Follow map-stack switches so the drape moves with the surface. */
attachMapStackController(controller) {
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = controller || null;
const off = controller?.subscribe?.(() => {
if (enabled) sync();
});
unsubscribeMap = typeof off === 'function' ? off : null;
},
enable() {
enabled = true;
sync();
},
disable() {
enabled = false;
sync();
},
update() {
return true;
},
getStats() {
// Without a controller subscription the stats poll doubles as the
// map-stack watch.
if (enabled && !unsubscribeMap) {
const next = resolveHost();
if (next.collection !== hostInfo.collection) sync();
}
return {
count: enabled ? 1 : 0,
error: osmOverlayHiddenReason(enabled, hostInfo.kind),
};
},
/** Owned layer count and host kind, for tests and live gates. */
diagnostics() {
return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 };
},
destroy() {
enabled = false;
detach(true);
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = null;
viewer = null;
hostInfo = { collection: null, kind: 'none' };
},
};
}
/** One layer per overlay, in panel order. */
export function createOsmOverlayLayers(options) {
return OSM_OVERLAYS.map((overlay) => createOsmOverlayLayer(overlay, options));
}
+167
View File
@@ -0,0 +1,167 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
OSM_OVERLAYS,
createOsmOverlayLayer,
createOsmOverlayLayers,
osmOverlayHiddenReason,
} from './osmOverlays.js';
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
function fakeCollection() {
const layers = [];
const removed = [];
return {
layers,
removed,
add(layer) {
layers.push(layer);
},
contains(layer) {
return layers.includes(layer);
},
remove(layer, destroy) {
const index = layers.indexOf(layer);
if (index < 0) return false;
layers.splice(index, 1);
removed.push({ layer, destroy });
if (destroy) layer.destroyed = true;
return true;
},
};
}
function fakeMapStack() {
const subscribers = new Set();
return {
tileset: null,
getImageryHostTileset() {
return this.tileset;
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
},
emit() {
for (const fn of subscribers) fn();
},
subscribers,
};
}
function setup(overlay = OSM_OVERLAYS[0]) {
const globe = fakeCollection();
const viewer = {
imageryLayers: globe,
scene: { globe: { show: true }, requestRender() {} },
};
const created = [];
const layer = createOsmOverlayLayer(overlay, {
createProvider: (descriptor) => ({ descriptor }),
createImageryLayer: (provider) => {
const imagery = { provider, destroyed: false };
created.push(imagery);
return imagery;
},
});
const mapStack = fakeMapStack();
layer.init(viewer);
layer.attachMapStackController(mapStack);
return { layer, viewer, globe, created, mapStack };
}
test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => {
assert.deepEqual(
OSM_OVERLAYS.map((o) => o.id),
['sea-marks', 'ski-pistes'],
);
for (const overlay of OSM_OVERLAYS) {
assert.match(overlay.urlTemplate, /^https:\/\/.*\{z\}.*\{x\}.*\{y\}/);
assert.match(overlay.credit, /OpenStreetMap contributors \(ODbL\)/);
}
assert.match(OSM_OVERLAYS[0].credit, /CC BY-SA 2\.0/);
assert.match(OSM_OVERLAYS[1].credit, /www\.opensnowmap\.org/);
});
test('createOsmOverlayLayers builds one independent layer per overlay', () => {
const layers = createOsmOverlayLayers();
assert.deepEqual(
layers.map((layer) => [layer.id, layer.name]),
[
['sea-marks', 'Sea Marks'],
['ski-pistes', 'Ski Pistes'],
],
);
assert.notEqual(createOsmOverlayLayers()[0], layers[0]);
assert.throws(() => createOsmOverlayLayer({ id: 'other' }), /Unknown/);
});
test('osmOverlayHiddenReason: only when enabled with no imagery host', () => {
assert.equal(osmOverlayHiddenReason(false, 'none'), null);
assert.equal(osmOverlayHiddenReason(true, 'globe'), null);
assert.equal(osmOverlayHiddenReason(true, 'tileset'), null);
assert.equal(osmOverlayHiddenReason(true, 'none'), NO_IMAGERY_HOST);
});
test('a layer owns no imagery until enabled and destroys it on disable', () => {
const { layer, globe, created } = setup(OSM_OVERLAYS[1]);
assert.equal(created.length, 0);
layer.enable();
assert.equal(globe.layers.length, 1);
assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes');
assert.deepEqual(layer.getStats(), { count: 1, error: null });
layer.disable();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 });
assert.deepEqual(layer.getStats(), { count: 0, error: null });
layer.enable();
assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer');
});
test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => {
const { layer, viewer, globe, mapStack } = setup();
layer.enable();
const [imagery] = globe.layers;
const tileset = { imageryLayers: fakeCollection() };
viewer.scene.globe.show = false;
mapStack.tileset = tileset;
mapStack.emit();
assert.equal(globe.layers.length, 0);
assert.deepEqual(tileset.imageryLayers.layers, [imagery]);
assert.equal(globe.removed[0].destroy, false);
assert.equal(layer.diagnostics().host, 'tileset');
assert.equal(layer.getStats().error, null);
viewer.scene.globe.show = true;
mapStack.tileset = null;
mapStack.emit();
assert.deepEqual(globe.layers, [imagery]);
assert.equal(tileset.imageryLayers.layers.length, 0);
});
test('with no surface to drape on the row explains itself, and recovers when one appears', () => {
const { layer, viewer, globe, mapStack } = setup();
viewer.scene.globe.show = false;
layer.enable();
assert.equal(globe.layers.length, 0);
assert.equal(layer.getStats().error, NO_IMAGERY_HOST);
viewer.scene.globe.show = true;
mapStack.emit();
assert.equal(globe.layers.length, 1);
assert.equal(layer.getStats().error, null);
});
test('destroy removes owned imagery and unsubscribes from the map stack', () => {
const { layer, globe, created, mapStack } = setup();
layer.enable();
layer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.equal(mapStack.subscribers.size, 0);
});
+16 -75
View File
@@ -51,6 +51,7 @@ import {
radioViewIsGlobal,
stationMatchesRadioCategory,
} from './radio.js';
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
const stations = [
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
@@ -883,88 +884,33 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele
});
test('failed exact tuner release cannot consume a stale playback fallback', async () => {
const originalFetch = globalThis.fetch;
const originalAudio = globalThis.Audio;
const playUrls = [];
const stationRows = [
{
radioStationRow({
id: '00000000-0000-4000-8000-000000000071',
name: 'Exact target',
lat: 30,
lon: -97,
streamUrl: 'https://radio.example.com/exact.mp3',
homepage: null,
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
},
{
}),
radioStationRow({
id: '00000000-0000-4000-8000-000000000072',
name: 'Stale fallback',
lat: 31,
lon: -98,
streamUrl: 'https://radio.example.com/fallback.mp3',
homepage: null,
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
},
}),
];
globalThis.Audio = class FakeAudio {
constructor() {
this.volume = 0.8;
this.src = '';
}
addEventListener() {}
pause() {}
play() {
playUrls.push(this.src);
if (this.src === stationRows[0].streamUrl) {
return Promise.reject(new DOMException('Exact target unavailable', 'NotSupportedError'));
}
return Promise.resolve();
}
removeAttribute() { this.src = ''; }
load() {}
};
globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/')
? { ok: true }
: new Response(JSON.stringify({
stations: stationRows,
stale: false,
degraded: false,
acceptedGeneration: 1,
catalogInstance: 'qa-instance-a',
updatedAt: new Date().toISOString(),
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.enable();
await radioLayer.update();
assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true);
await withRadioLayer({
stations: stationRows,
failingUrls: [stationRows[0].streamUrl],
}, async ({ playUrls }) => {
// A playing stream, so the release plays.
assert.equal(radioLayer.selectStation(stationRows[1].id, { autoplay: true }), true);
await new Promise((resolve) => setTimeout(resolve));
assert.equal(radioLayer.cycleStation(1, {
stationIds: stationRows.map(({ id }) => id),
autoplay: false,
}), true, 'non-playing cycle creates the stale-fallback precondition');
fallback: true,
}), true, 'a voice-style prepared cycle creates the stale-fallback precondition');
playUrls.length = 0;
assert.equal(radioLayer.beginTuning(), true);
assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true);
assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true);
@@ -975,12 +921,7 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn
assert.equal(state.audioState, 'error');
assert.equal(state.tuningStatic, true);
assert.equal(state.tuningAwaitingStationId, stationRows[0].id);
} finally {
globalThis.fetch = originalFetch;
if (originalAudio === undefined) delete globalThis.Audio;
else globalThis.Audio = originalAudio;
radioLayer.destroy();
}
});
});
test('tuner cancellation restores the frozen start marker after catalog removal', async () => {
+417
View File
@@ -0,0 +1,417 @@
// Radio selection, fallbacks and voice handoffs on the real layer and its published view.
import assert from 'node:assert/strict';
import test from 'node:test';
import { radioLayer } from './radio.js';
import { setLocale } from '../i18n/index.js';
import { renderRadioState } from '../ui/radioPresentation.js';
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
const READY = 'Ready — playback starts only from your action';
const ids = [1, 2, 3].map((n) => `00000000-0000-4000-8000-00000000009${n}`);
const stations = ids.map((id, index) => radioStationRow({
id,
name: `Station ${'ABC'[index]}`,
lat: 30 + index,
lon: -97 - index,
streamUrl: `https://radio.example.com/${'abc'[index]}.mp3`,
}));
const withLayer = (failingUrls, body) => withRadioLayer({ stations, failingUrls }, body);
const settle = () => new Promise((resolve) => setTimeout(resolve));
// What the Radio panel and the Cockpit player show for a state.
function render(state) {
const previousDocument = globalThis.document;
globalThis.document = { getElementById: () => null, activeElement: null };
try {
let view = null;
const host = {
destroyed: false,
_radioPanel: {},
store: { publish: (published) => (view = published), publishTuner() {} },
actions: {
getLifecycle: () => null,
preservePanelStateDuringClear: () => false,
setPanelCollapsed() {},
scheduleLayout() {},
},
};
renderRadioState.call(host, state);
return {
status: view.playback.text,
error: Boolean(view.playback.error),
button: view.transport.play.text,
stopDisabled: view.transport.stopDisabled,
cockpit: view.cockpit.play.glyph,
};
} finally {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
}
}
const readyToPlay = { status: READY, error: false, button: 'PLAY', stopDisabled: true, cockpit: '▶' };
test('Next before Play only selects, and Play tries just that station', async () => {
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
await settle();
assert.deepEqual(playUrls, []);
assert.equal(radioLayer.getUIState().audioState, 'stopped');
await radioLayer.togglePlayback({ origin: 'user' });
await settle();
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[1].streamUrl], 'no fallback picked by the silent step');
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'error');
});
});
test('Next after a failed stream leaves the new station ready to play, without the old error', async () => {
setLocale('en');
await withLayer([stations[0].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
assert.equal(radioLayer.getUIState().audioState, 'error');
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
const state = radioLayer.getUIState();
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.playingStationId, null);
assert.deepEqual(render(state), readyToPlay);
await radioLayer.togglePlayback({ origin: 'user' });
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
});
});
test('Next while paused releases the paused stream, and Play starts the new station', async () => {
setLocale('en');
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
assert.equal(radioLayer.getUIState().audioState, 'paused');
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
const state = radioLayer.getUIState();
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.playingStationId, null);
assert.deepEqual(render(state), readyToPlay);
await radioLayer.togglePlayback({ origin: 'user' });
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'playing');
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
});
});
test('a catalog refresh after a paused Next cannot bring back Resume for an unnamed station', async () => {
setLocale('en');
await withLayer([], async ({ catalog }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
catalog.rows = stations.filter(({ id }) => id !== ids[1]);
catalog.generation = 2;
await radioLayer.update();
const state = radioLayer.getUIState();
assert.equal(state.selected, null);
assert.equal(state.audioState, 'stopped');
const shown = render(state);
assert.equal(shown.status, READY);
assert.equal(shown.button, 'PLAY');
});
});
test('a voice station request after a voice pause keeps the fallback of that request', async () => {
const failing = [];
await withLayer(failing, async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
radioLayer.pause({ origin: 'voice-duck' });
playUrls.length = 0;
// Anchor on the far station so the paused one is not the first match.
const first = radioLayer.selectRequestedStation(
{ categoryId: 'all', anchor: { lat: 32, lon: -99 } },
{ autoplay: false },
);
failing.push(first.streamUrl);
await radioLayer.play({ origin: 'voice' });
await settle();
await settle();
assert.equal(playUrls.length, 2);
assert.equal(playUrls[0], first.streamUrl);
assert.equal(radioLayer.getUIState().audioState, 'playing');
});
});
test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => {
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
// The handoff is cancelled (barge-in), so voice never starts playback.
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
await settle();
await settle();
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[1].streamUrl]);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'error');
});
});
test('only a user step to another station tells voice to drop its prepared playback', async () => {
await withLayer([], async () => {
const controls = [];
const unsubscribe = radioLayer.subscribePlaybackControls((event) => controls.push(event));
try {
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
radioLayer.cycleStation(1, { stationIds: [ids[1]], autoplay: false, origin: 'user' });
} finally {
unsubscribe();
}
assert.equal(radioLayer.getUIState().selected?.id, ids[1]);
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['select', 'user']]);
});
});
test('an unavailable tuner release stops the paused stream once, and Play still falls back', async () => {
setLocale('en');
const failing = [];
await withLayer(failing, async ({ playUrls, catalog }) => {
radioLayer.selectStation(ids[2], { autoplay: true, origin: 'user' });
await settle();
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
// A directory refresh changes the station under the needle mid-drag.
catalog.rows = stations.map((row) => row.id === ids[1]
? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' }
: row);
catalog.generation = 2;
await radioLayer.update();
const states = [];
const controls = [];
const unsubscribe = radioLayer.subscribe((state) => states.push(state));
const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => controls.push(event));
states.length = 0;
try {
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable');
} finally {
unsubscribe();
unsubscribeControls();
}
assert.equal(states.length, 1);
assert.equal(states[0].tuningUnavailableStationId, ids[1]);
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['stop', 'user']]);
const state = radioLayer.getUIState();
assert.equal(state.selected, null);
assert.equal(state.audioState, 'stopped');
assert.equal(state.playingStationId, null);
assert.equal(render(state).button, 'PLAY');
playUrls.length = 0;
// Play picks the nearest station; its broadcaster fails.
const play = globalThis.Audio.prototype.play;
globalThis.Audio.prototype.play = function playFirstFails() {
if (!failing.length) failing.push(this.src);
return play.call(this);
};
try {
await radioLayer.togglePlayback({ origin: 'user' });
await settle();
await settle();
} finally {
globalThis.Audio.prototype.play = play;
}
assert.equal(playUrls.length, 2);
assert.equal(radioLayer.getUIState().audioState, 'playing');
});
});
test('while a stream connects the transport buttons offer Pause, because a click pauses', () => {
setLocale('en');
const shown = render({
enabled: true,
audioState: 'loading',
audioError: null,
selected: { id: 'a', name: 'Station A' },
selectedIndex: 0,
filteredCount: 1,
stationCount: 1,
volume: 1,
});
assert.equal(shown.status, 'Connecting directly to broadcaster…');
assert.equal(shown.button, 'PAUSE');
assert.equal(shown.cockpit, 'Ⅱ');
});
test('a dial release plays only if a stream was active when the drag began', async () => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
await settle();
let state = radioLayer.getUIState();
assert.deepEqual(playUrls, []);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.tuningStatic, false);
await radioLayer.togglePlayback({ origin: 'user' });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[2], { rotate: false });
radioLayer.commitTuningStation(ids[2], { origin: 'user' });
await settle();
state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[1].streamUrl, stations[2].streamUrl]);
assert.equal(state.audioState, 'playing');
assert.equal(state.playingStationId, ids[2]);
});
});
// Like Chrome, resume() flips `state` only once the device starts, and suspend()
// on a context that is still suspended resolves at once without effect.
const RESUME_MS = 5;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function withFakeAudioContext(body) {
const log = { contexts: [], sources: [] };
const node = () => ({ connect() {}, disconnect() {} });
class FakeAudioContext {
constructor() {
Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} });
log.contexts.push(this);
}
resume() {
if (this.state === 'running') return Promise.resolve();
return new Promise((resolve) => setTimeout(() => {
if (this.state !== 'closed') this.state = 'running';
resolve();
}, RESUME_MS));
}
suspend() {
if (this.state === 'running') this.state = 'suspended';
return Promise.resolve();
}
close() { this.state = 'closed'; return Promise.resolve(); }
createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; }
createBufferSource() {
const source = { ...node(), started: false, stopped: false, start() { this.started = true; }, stop() { this.stopped = true; } };
log.sources.push(source);
return source;
}
createBiquadFilter() { return { ...node(), frequency: { value: 0 }, Q: { value: 0 } }; }
createGain() {
return { ...node(), gain: { value: 0, cancelScheduledValues() {}, setValueAtTime() {}, linearRampToValueAtTime() {} } };
}
}
const original = globalThis.AudioContext;
globalThis.AudioContext = FakeAudioContext;
return Promise.resolve(body(log)).finally(() => {
if (original === undefined) delete globalThis.AudioContext;
else globalThis.AudioContext = original;
});
}
test('a silent dial release stops the tuner noise and suspends its audio context, even before resume() settles', async () => {
await withFakeAudioContext(async ({ contexts, sources }) => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
await sleep(RESUME_MS * 4);
assert.deepEqual(playUrls, []);
assert.equal(sources[0].started, true);
assert.equal(sources[0].stopped, true, 'noise source still looping');
assert.equal(contexts[0].state, 'suspended', 'audio context still running');
});
});
});
test('a second gesture before the first resume() settles keeps its noise graph', async () => {
await withFakeAudioContext(async ({ contexts, sources }) => {
await withLayer([], async () => {
radioLayer.selectStation(ids[0], { autoplay: false });
radioLayer.beginTuning();
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
assert.equal(radioLayer.beginTuning(), true);
await sleep(RESUME_MS * 4);
assert.equal(contexts[0].state, 'running', 'second gesture lost its context');
assert.equal(sources.at(-1).stopped, false, 'second gesture lost its source');
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
await sleep(RESUME_MS * 4);
assert.equal(sources.at(-1).stopped, true);
assert.equal(contexts[0].state, 'suspended');
});
});
});
test('a dial release from a paused stream only selects, without static', async () => {
await withFakeAudioContext(async ({ contexts }) => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
assert.equal(radioLayer.getUIState().audioState, 'paused');
assert.equal(radioLayer.beginTuning(), true);
await sleep(RESUME_MS * 4);
radioLayer.previewTuningStation(ids[1], { rotate: false });
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
await sleep(RESUME_MS * 4);
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stations[0].streamUrl]);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'stopped');
assert.equal(state.tuningStatic, false);
assert.equal(state.tuningAwaitingStationId, null);
assert.equal(contexts[0].state, 'suspended');
});
});
});
test('a dial release while a stream is still connecting plays the new station', async () => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
assert.equal(radioLayer.getUIState().audioState, 'loading');
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false });
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
await settle();
const state = radioLayer.getUIState();
assert.equal(playUrls.at(-1), stations[1].streamUrl);
assert.equal(state.audioState, 'playing');
assert.equal(state.playingStationId, ids[1]);
});
});
test('a silent user step drops the fallback a voice step left behind', async () => {
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
// Voice steps to B and keeps C as its fallback; the user steps back to A.
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
radioLayer.cycleStation(-1, { stationIds: ids, autoplay: false, origin: 'user' });
await radioLayer.play({ origin: 'voice' });
await settle();
await settle();
assert.deepEqual(playUrls, [stations[0].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'error');
});
});
test('a silent dial release drops the fallback a voice step left behind', async () => {
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false });
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[0], { rotate: false });
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
await radioLayer.play({ origin: 'voice' });
await settle();
await settle();
assert.deepEqual(playUrls, [stations[0].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'error');
});
});
+211
View File
@@ -541,6 +541,191 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing',
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
});
test('priority-country configuration is bounded, normalized and validated before requests', async () => {
const { parseRadioPriorityCountries } = await import('../../server/providers/radio/catalog.js');
assert.deepEqual(parseRadioPriorityCountries(' in, PL,in, '), ['IN', 'PL']);
assert.deepEqual(parseRadioPriorityCountries(''), []);
assert.deepEqual(parseRadioPriorityCountries('uk,GB,UK'), ['GB']);
assert.deepEqual(parseRadioPriorityCountries('US,CA,IN,PL,DE,FR,UK,GB,JP'), ['US', 'CA', 'IN', 'PL', 'DE', 'FR', 'GB', 'JP']);
for (const value of ['ZZ', 'India', 'IN&limit=9999', 'US,CA,IN,PL,DE,FR,GB,JP,AU']) {
assert.throws(() => createProxy({ priorityCountries: value }), /RADIO_PRIORITY_COUNTRIES/);
}
});
test('UK aliases query and report GB once and retain British stations', async () => {
const countryQueries = [];
const middleware = createProxy({
priorityCountries: 'UK,GB',
fetchImpl: async (url) => {
const parsed = new URL(url);
if (parsed.pathname === '/json/servers')
return responseJson([{ name: 'de1.api.radio-browser.info' }]);
const country = parsed.searchParams.get('countrycode');
if (country) {
countryQueries.push(country);
return responseJson([station({ country: 'United Kingdom', countrycode: 'GB' })]);
}
return responseJson(healthyRowsForQuery(url));
},
});
const response = await invoke(middleware, '/stations');
assert.equal(response.status, 200);
const body = JSON.parse(response.body);
assert.deepEqual(countryQueries, ['GB']);
assert.deepEqual(body.coverage.priorityCountries, [
{ countryCode: 'GB', succeeded: true, stationCount: 1 },
]);
assert.equal(body.stations.find((row) => row.id === UUID)?.countryCode, 'GB');
});
test('low-click country stations survive a full global catalog, with bounded cached queries and deduplication', async () => {
const calls = [];
let active = 0;
let peak = 0;
const middleware = createProxy({
priorityCountries: 'IN,PL',
fetchImpl: async (url) => {
const parsed = new URL(url);
calls.push(parsed);
if (parsed.pathname === '/json/servers') return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (parsed.pathname.startsWith('/json/url/')) return responseJson({ ok: true });
peak = Math.max(peak, ++active);
await new Promise((resolve) => setTimeout(resolve, 1));
active--;
const country = parsed.searchParams.get('countrycode');
if (country) {
assert.equal(parsed.searchParams.get('limit'), '200');
assert.equal(parsed.searchParams.get('is_https'), 'true');
const rows = catalogRows(country === 'IN' ? '10000000' : '20000000', 'community', 60)
.map((row) => ({ ...row, countrycode: country, clickcount: 0 }));
return responseJson([rows[0], ...rows]);
}
return responseJson(catalogRows('30000000', queryTag(url) || 'news', 900));
},
});
const responses = await Promise.all([invoke(middleware, '/stations'), invoke(middleware, '/stations')]);
const body = JSON.parse(responses[0].body);
assert.equal(body.degraded, false);
assert.equal(body.stations.length, 750);
assert.equal(new Set(body.stations.map((row) => row.id)).size, 750);
for (const country of ['IN', 'PL'])
assert.ok(body.stations.filter((row) => row.countryCode === country).length >= 40);
assert.ok(body.stations.some((row) => row.countryCode === 'US'));
assert.equal(calls.filter((url) => url.pathname.includes('/stations/')).length, 11);
assert.ok(peak <= 3);
const count = calls.length;
await invoke(middleware, '/stations');
assert.equal(calls.length, count, 'country queries share the 45-minute cache');
const id = body.stations.find((row) => row.countryCode === 'IN').id;
assert.equal((await invoke(middleware, `/click/${id}`, 'POST')).status, 204);
});
test('country failures never block cold or warm global catalog admission, even after seven days', async () => {
for (const coldFailure of [false, true]) {
let clock = 4_000_000;
let fail = coldFailure;
let calls = 0;
const middleware = createProxy({
priorityCountries: ['IN'], now: () => clock,
fetchImpl: async (url) => {
calls++;
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (new URL(url).searchParams.has('countrycode')) {
if (fail) throw new Error('offline');
return responseJson([station({ countrycode: 'IN', country: 'India', tags: 'community' })]);
}
return responseJson(healthyRowsForQuery(url));
},
});
let generation = 0;
for (const elapsed of [0, 46 * 60 * 1000, 8 * 24 * 60 * 60 * 1000]) {
clock += elapsed;
if (elapsed) fail = true;
const response = await invoke(middleware, '/stations');
const body = JSON.parse(response.body);
assert.equal(response.status, 200);
assert.equal(body.stale, false);
assert.equal(body.degraded, false);
assert.ok(body.acceptedGeneration > generation);
generation = body.acceptedGeneration;
assert.equal(body.coverage.priorityCountries[0].succeeded, !fail);
assert.equal(body.coverage.priorityCountries[0].stationCount, fail ? 0 : 1);
const count = calls;
clock += 60_001;
assert.deepEqual(JSON.parse((await invoke(middleware, '/stations')).body), body);
assert.equal(calls, count, 'country failure does not shorten the healthy cache');
}
}
});
test('valid countries with no eligible stations are accepted and cached; malformed country responses stay isolated', async () => {
for (const mode of ['wrong', 'oversized', 'empty', 'ineligible', 'malformed']) {
let clock = 4_000_000;
let calls = 0;
const middleware = createProxy({
priorityCountries: ['IN'], now: () => clock,
fetchImpl: async (url) => {
calls++;
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (new URL(url).searchParams.has('countrycode')) {
const rows = {
empty: [], wrong: [station()], malformed: [{ name: 'missing fields' }],
oversized: catalogRows('10000000', 'community', 201).map((row) => ({ ...row, countrycode: 'IN' })),
ineligible: [{ hls: 1 }, { codec: 'OGG' }, { codec: 'OPUS' }, { url_resolved: 'https://127.0.0.1/live.mp3' }]
.map((overrides) => station({ countrycode: 'IN', ...overrides })),
};
return responseJson(rows[mode]);
}
return responseJson(healthyRowsForQuery(url));
},
});
const response = await invoke(middleware, '/stations');
const body = JSON.parse(response.body);
assert.equal(response.status, 200, mode);
assert.equal(body.degraded, false, mode);
assert.ok(body.acceptedGeneration > 0, mode);
assert.equal(body.coverage.priorityCountries[0].stationCount, 0, mode);
assert.equal(body.coverage.priorityCountries[0].succeeded, !['oversized', 'malformed'].includes(mode), mode);
const count = calls;
clock += 44 * 60 * 1000;
assert.deepEqual(JSON.parse((await invoke(middleware, '/stations')).body), body);
assert.equal(calls, count, `${mode} country response preserves the 45-minute cache`);
}
});
for (const priorityCountries of ['', 'IN']) {
test(`cold failures coalesce, bound mirrors and back off with priority countries ${priorityCountries || 'unset'}`, async () => {
let clock = Date.UTC(2026, 0, 1);
let calls = 0;
let offline = true;
const middleware = createProxy({
priorityCountries, now: () => clock,
fetchImpl: async (url) => {
calls++;
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (offline) throw new Error('offline');
if (new URL(url).searchParams.has('countrycode')) return responseJson([]);
return responseJson(healthyRowsForQuery(url));
},
});
const responses = await Promise.all([invoke(middleware, '/stations'), invoke(middleware, '/stations')]);
assert.ok(responses.every((response) => response.status === 503));
const initial = calls;
assert.equal(initial, 1 + (priorityCountries ? 10 : 9) * 3, 'one discovery and at most three mirrors per query');
offline = false;
clock += 59_999;
assert.equal((await invoke(middleware, '/stations')).status, 503);
assert.equal(calls, initial);
clock += 2;
const recovered = await invoke(middleware, '/stations');
assert.equal(recovered.status, 200);
assert.equal(JSON.parse(recovered.body).degraded, false);
assert.ok(calls > initial);
const afterRecovery = calls;
await invoke(middleware, '/stations');
assert.equal(calls, afterRecovery);
});
}
test('case expansion is capped after tag normalization in the portable and server station path', () => {
const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'}));
@@ -599,3 +784,29 @@ test('server structural rejections reach the client diagnostic without discardin
radioLayer.destroy();
}
});
test('country coverage retains valid rows and upstream rejection diagnostics in a cached healthy catalog', async () => {
let calls = 0;
const middleware = createProxy({
priorityCountries: 'IN',
fetchImpl: async (url) => {
calls++;
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
if (new URL(url).searchParams.has('countrycode')) return responseJson([
station({ stationuuid: '60000000-0000-4000-8000-000000000001', countrycode: 'IN' }),
{ name: 'missing required fields' },
]);
return responseJson(healthyRowsForQuery(url));
},
});
const response = await invoke(middleware, '/stations');
const body = JSON.parse(response.body);
assert.equal(response.status, 200);
assert.equal(body.degraded, false);
assert.equal(body.rejectedStationCount, 1);
assert.deepEqual(body.coverage.priorityCountries, [{ countryCode: 'IN', succeeded: true, stationCount: 1 }]);
const count = calls;
assert.deepEqual(JSON.parse((await invoke(middleware, '/stations')).body), body);
assert.equal(calls, count);
});
-14
View File
@@ -2,7 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { gstime } from 'satellite.js';
import { worldClock } from '../time/worldClock.js';
import { createModel } from '../layers/launches/model.js';
import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
@@ -1241,11 +1240,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
rocketLaunchesLayer.init(viewer);
initialized = true;
await rocketLaunchesLayer.enable();
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
await rocketLaunchesLayer.update();
const at = Date.parse(launchTime);
assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] });
assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline');
const entities = dataSources[0].entities.values;
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path');
@@ -1384,7 +1379,6 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
assert.equal(deselectedPublication[2][0].protected, false);
await rocketLaunchesLayer.disable();
assert.equal(worldClock.coverage(), null, 'off, launches show no time');
assert.deepEqual(hostCalls.slice(-4), [
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
@@ -1506,11 +1500,3 @@ test('missing payload details stay unknown without inventing mass or classificat
const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
assert.deepEqual(launch.payloads, []);
});
test('away from live the feed keeps its own window of launches', (t) => {
t.after(() => worldClock.goLive());
worldClock.seek(Date.now() - 20 * 86_400_000);
const net = new Date(Date.now() - 86_400_000).toISOString();
const launches = normalizeRocketLaunches([{ id: 'recent', pad: { latitude: 1, longitude: 2 }, net }]);
assert.deepEqual(launches.map((launch) => launch.id), ['recent']);
});
+45
View File
@@ -19,6 +19,7 @@ import assert from 'node:assert/strict';
import {
resolveEllipsoidalGround,
cachedEllipsoidalGround,
cachedRealEllipsoidalGround,
} from './terrainHeights.js';
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
@@ -362,3 +363,47 @@ test('resolveEllipsoidalGround: null and string heights cannot become real zeroe
);
}
});
test('resolveEllipsoidalGround: a 502 keeps the real heights it carries and falls back only for the rest', async () => {
const coords = [
{ lat: 62.001, lon: -150.001, sourceOrthometricM: 10 },
{ lat: 62.002, lon: -150.002, sourceOrthometricM: 10 },
{ lat: 62.003, lon: -150.003, sourceOrthometricM: 10 },
];
await withFakeFetch(
async () => ({
ok: false,
status: 502,
json: async () => ({
error: 'terrain heights fetch failed and no cache available for every point',
partial: [{ ellipsoid: 412.5 }, null, { ellipsoid: 'x' }],
}),
}),
async () => {
const out = await resolveEllipsoidalGround(coords);
assert.deepEqual(out[0], { ellipsoid: 412.5, source: 'reearth' });
assert.equal(out[1].source, 'geoid-fallback');
assert.equal(out[2].source, 'geoid-fallback', 'a non-numeric height is not real');
}
);
assert.equal(cachedRealEllipsoidalGround(62.001, -150.001), 412.5);
assert.equal(cachedRealEllipsoidalGround(62.002, -150.002), null);
});
test('resolveEllipsoidalGround: a partial list of the wrong length is ignored', async () => {
const coords = [
{ lat: 63.001, lon: -151.001 },
{ lat: 63.002, lon: -151.002 },
];
await withFakeFetch(
async () => ({
ok: false,
status: 502,
json: async () => ({ error: 'x', partial: [{ ellipsoid: 7 }] }),
}),
async () => {
const out = await resolveEllipsoidalGround(coords);
assert.deepEqual(out.map((r) => r.source), ['geoid-fallback', 'geoid-fallback']);
}
);
});
+10 -1
View File
@@ -190,7 +190,8 @@ export async function fetchTerrainChunkWithRetry(
* Resolve a requested point batch against a per-point cache and a missing-only
* fetcher. Results are reconstructed by canonical key in exact request order,
* including duplicates. Stale values remain eligible only when refresh fails.
* If any position has no real height, return 502 with no fabricated result.
* If any position has no real height, return 502 with no fabricated result;
* the body's `partial` lists the real heights it has, null for the rest.
*
* @param {object} options
* @param {Array<[number, number]>} options.points
@@ -269,11 +270,19 @@ export async function resolveTerrainHeightRequest({
return entry && validTerrainResult(entry.result) ? entry.result : null;
});
if (results.some((result) => result == null)) {
// The real heights this request does have, null elsewhere, so the page
// keeps them and uses its geoid only for the rest. Named `partial`, not
// `results`, so no reader mistakes it for a complete answer; left out when
// no point has a height.
const partial = results.some((result) => result != null)
? { partial: results }
: {};
return {
status: 502,
body: {
error:
'terrain heights fetch failed and no cache available for every point',
...partial,
},
cacheChanged,
upstreamError,
+28
View File
@@ -268,3 +268,31 @@ test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bou
[0, 0],
]);
});
test('a 502 lists the real heights it has, null for the rest', async () => {
const cache = new Map([
[terrainPointKey([1, 1]), { at: 0, result: result('stale-a', 11) }],
]);
const response = await resolveTerrainHeightRequest({
points: [
[1, 1],
[2, 2],
[1, 1],
],
cache,
ttlMs: 100,
now: () => 1000,
fetchMissing: async () => {
const error = new Error('HTTP 429');
error.status = 429;
error.coolingDown = true;
throw error;
},
});
assert.equal(response.status, 502);
assert.equal('results' in response.body, false);
assert.deepEqual(
response.body.partial.map((item) => item?.id ?? null),
['stale-a', null, 'stale-a'],
);
});
+4 -3
View File
@@ -1030,11 +1030,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// preserves the mission mapping and globe-rotation arguments.
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
// The layer alias hint leaves out aliases the layer id already spells.
// The Utilities panel adds one set_panel_open target.
assert.equal(block.length, 30919, 'serialized tool schema length drifted');
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
// Pistes extend the generated layer enums and alias hint.
assert.equal(block.length, 31026, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'dbf9a45649179e626b2e3d938c43903f573c50ffa279b3245b18244dbe9a9284',
'5f5275cd6e320afef4fd224b235f3e0f191ec06599b257ffe1f60b703a255199',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+156 -49
View File
@@ -23,11 +23,6 @@ import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js';
import { worldTimeFor } from './time/worldClock.js';
// The HUD stamps its readouts with the world time; its sun is the sky's.
const hudTime = worldTimeFor('hud');
const skyTime = worldTimeFor('sky');
import { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import {
@@ -37,17 +32,31 @@ import {
} from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js';
import {
hudSummaryMatchesProvenance,
hasWord,
hudSummaryHasEnglish,
hudSummaryIsLatin,
hudSummaryLayerContext,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
} from './hudSummaryResponse.js';
import {
hudSummaryMatchesProvenance,
provenanceLine,
provenanceTag,
} from './data/layerSnapshot.js';
import {
applyHudUiTheme,
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
import { formatNumber, msg, onLocaleChange, t } from './i18n/index.js';
import {
formatNumber,
getLocale,
msg,
onLocaleChange,
t,
} from './i18n/index.js';
import { isRtlLocale } from './i18n/locale.js';
import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js';
import { subscribeSelectedVessel } from './hudReadouts.js';
@@ -113,23 +122,58 @@ const FEED_STATE_LABELS = Object.freeze({
unavailable: msg('UNAVAILABLE'),
});
/** A summary line with its warning in the active language. */
function withProvenance(text, provenance) {
return provenanceLine(
text,
provenance,
(state) =>
FEED_STATE_LABELS[state]
? t(FEED_STATE_LABELS[state])
: state.toUpperCase(),
(name) => localizeLayerName(name).toUpperCase(),
);
}
/**
* The summary's feed-state suffix in the active language, e.g.
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in
* layer data; only this line translates them.
* The AI line. In an RTL locale a leading right-to-left mark keeps a reply
* that opens with a Latin name (Times Square …) reading right to left.
*/
function localizedProvenanceTag(provenance) {
if (!provenance) return null;
const state = FEED_STATE_LABELS[provenance.state]
? t(FEED_STATE_LABELS[provenance.state])
: provenance.state.toUpperCase();
if (!provenance.names.length) return state;
return t('{state} {layers}', {
state,
layers: provenance.names
.map((name) => localizeLayerName(name).toUpperCase())
.join('/'),
});
function aiSummaryLine(summary, warning) {
const line = withProvenance(summary, warning);
return isRtlLocale(getLocale()) ? `\u200f${line}` : line;
}
/**
* What a summary outside English must not carry: feed-state ids, translated
* layers' English names and ids, and with every feed healthy the
* UNAVAILABLE word. A state word the locale keeps in English (de FALLBACK)
* and words of a place label (Radio City, Loading Dock) are fine; so is a
* one-word layer name in a Latin-script locale, where it may be a word of
* the language (fr transit).
*/
function summaryForbiddenWords(context) {
const places = [
...(context.placeLabels || []),
...(context.streetLabels || []),
...(context.nearbyPlaceLabels || []),
].join(' ');
const latin = hudSummaryIsLatin(context.locale);
const overall = context.feedProvenance?.overall;
return [
...Object.keys(FEED_STATE_LABELS).filter(
(state) =>
t(FEED_STATE_LABELS[state]).toLowerCase() !== state &&
!hasWord(places, state),
),
...(context.englishLayerWords || []).filter(
(word) =>
!hasWord(places, word) && (!latin || /[^\p{L}\p{N}]/u.test(word)),
),
...(!overall || overall === 'nominal'
? [t(FEED_STATE_LABELS.unavailable)]
: []),
];
}
/** The name shown for a _viewBand() id. */
@@ -226,7 +270,7 @@ export class IntelHUD {
this._placeLabels = null;
this._lastSummarySignature = '';
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
this._lastSummaryText = null;
this._lastSummaryReply = 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..."
@@ -255,12 +299,17 @@ export class IntelHUD {
// the camera has moved to.
this._onCameraMoveStart = () => {
this._markSummaryDirty();
if (this._summaryRequest || this._summaryTypingInterval) {
const typing = Boolean(this._summaryTypingInterval);
if (this._summaryRequest || typing) {
// Cancelled requests and partially typed replies are not reusable answers.
this._lastSummarySignature = null;
}
this._summaryRequest?.abort();
this._stopSummaryTyping();
// A half-typed reply would stay until moveEnd, which a camera that
// keeps moving may not send for a long time: show the plain line.
if (typing && this._latestMetrics)
this._setSummaryText(this._composeSummary(), false);
};
this._onCameraMoveEnd = () => {
this._markSummaryDirty();
@@ -300,12 +349,12 @@ export class IntelHUD {
this._startTimers();
}
/**
* Show the HUD's own words in the new locale. The deterministic summary
* is composed again; the AI reply does not depend on the locale, so the
* next tick shows the one already received rather than asking again.
*/
/** Show the HUD's words in the new locale; drop AI text in the old one. */
_relocalize() {
this._markSummaryDirty();
this._lastSummarySignature = null;
this._lastSummaryReply = null;
this._summaryRequest?.abort();
this._present({
mode: MODE_LABELS[this._currentStyle]
? t(MODE_LABELS[this._currentStyle])
@@ -398,11 +447,11 @@ export class IntelHUD {
}
/**
* Format the HUD's world time as a UTC Zulu string.
* Format the current wall-clock time as a UTC Zulu string.
* @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format.
*/
_formatUTC() {
const now = new Date(hudTime());
const now = new Date();
const y = now.getUTCFullYear();
const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0');
@@ -505,7 +554,7 @@ export class IntelHUD {
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp
const now = new Date(hudTime());
const now = new Date();
const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0');
@@ -612,7 +661,7 @@ export class IntelHUD {
* @returns {number} Estimated sun elevation in degrees (negative = below horizon).
*/
_estimateSunElevation(lat, lon) {
const now = new Date(skyTime());
const now = new Date();
// Approximate local solar time by shifting UTC hours by longitude offset
const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15;
const solarNoon = 12;
@@ -768,8 +817,8 @@ export class IntelHUD {
LOCALIZED_LOCALITY,
);
const provenance = localizedProvenanceTag(
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
const provenance = hudTelemetryProvenance(
this._dataManager?.getAll?.() || [],
);
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
const line = t(
@@ -786,7 +835,7 @@ export class IntelHUD {
zone: localTag,
},
);
return provenance ? `${line} | ${provenance}` : line;
return withProvenance(line, provenance);
}
/** Stop a summary still typing out, leaving what it has typed so far. */
@@ -859,12 +908,28 @@ export class IntelHUD {
return;
}
if (revision !== this._summaryRevision) return;
// Without a place label the model invents a place: show the plain line.
const described = [
context.placeLabels,
context.streetLabels,
context.nearbyPlaceLabels,
].some((list) => list?.length);
if (!described) {
this._summaryDirty = false;
this._setSummaryText(fallbackText, false);
return;
}
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);
// The warning is the current one: layer states are not in a
// non-English signature.
if (this._lastSummaryReply)
this._setSummaryText(
aiSummaryLine(this._lastSummaryReply, context.warning),
);
return;
}
if (this._summaryRequest) return;
@@ -872,7 +937,7 @@ export class IntelHUD {
if (force) this._setSummaryText(fallbackText, false);
this._summaryDirty = false;
this._lastSummarySignature = signature;
this._lastSummaryText = null;
this._lastSummaryReply = null;
const controller = new AbortController();
let timedOut = false;
const timeout = window.setTimeout(() => {
@@ -895,9 +960,23 @@ export class IntelHUD {
if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
this._lastSummaryText = data.summary;
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
if (
hudSummaryMatchesProvenance(data, context.feedProvenance) &&
!hudSummaryHasEnglish(
data.summary,
context.locale,
summaryForbiddenWords(context),
)
) {
this._lastSummaryReply = data.summary;
}
// The warning is the HUD's own translated state, not the model's words.
this._setSummaryText(
this._lastSummaryReply
? aiSummaryLine(this._lastSummaryReply, context.warning)
: fallbackText,
animate,
);
} catch (error) {
// The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not.
@@ -955,18 +1034,46 @@ export class IntelHUD {
// 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?.()
?.filter((layer) => layer.enabled)
.map((layer) => layer.name) || [];
return {
const locale = getLocale();
const layerContext = hudSummaryLayerContext(
this._dataManager?.getAll?.() || [],
);
// Outside English a layer's state id is left out: the model echoes it,
// and `overall` already carries the state.
const named = ({ feedState, ...layer }) => ({
...layer,
name: localizeLayerName(layer.name),
...(locale === 'en' && { feedState }),
});
const { feedProvenance } = layerContext;
const context = {
locale,
placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels,
enabledLayerLabels: enabledLayers,
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
// The model sees only the layer names the user sees. The English note
// goes too: a small model copies its words. `overall` stays the id.
enabledLayerLabels:
layerContext.enabledLayerLabels.map(localizeLayerName),
enabledLayers: layerContext.enabledLayers.map(named),
feedProvenance: {
...feedProvenance,
layers: feedProvenance.layers.map(named),
note: locale === 'en' ? feedProvenance.note : undefined,
},
};
// Not sent and not part of the signature: the line's own warning, taken
// before the layer states are left out, and the English names and ids of
// translated layers, which a reply in another language must not echo.
Object.defineProperties(context, {
warning: { value: provenanceTag(feedProvenance) },
englishLayerWords: {
value: layerContext.enabledLayers
.filter(({ name }) => localizeLayerName(name) !== name)
.flatMap(({ name, id }) => [name, id].filter(Boolean)),
},
});
return context;
}
_markSummaryDirty() {
+11 -9
View File
@@ -295,7 +295,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
let place = 'Yellowstone';
hud._summaryContext = async () => ({ place });
hud._summaryContext = async () => ({ placeLabels: [place] });
const summary = () => env.elements.get('hud-summary').textContent;
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
@@ -321,7 +321,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
const moving = hud._updateSummary(false);
await settle();
assert.equal(pending.length, 2, 'the move marked the summary dirty');
assert.deepEqual(pending[1].context, { place: 'Atlantic' });
assert.deepEqual(pending[1].context, { placeLabels: ['Atlantic'] });
pending[1].reply('North Atlantic Ocean');
await moving;
assert.equal(summary(), 'North Atlantic Ocean');
@@ -354,7 +354,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'Yellowstone' });
hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
@@ -416,7 +416,7 @@ test('a cancelled summary retries the same context but completed answers stay re
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData();
const settle = () => new Promise((resolve) => setImmediate(resolve));
const first = hud._updateSummary(false);
@@ -474,7 +474,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
// The same place before and after: a slow follow camera, a small pan.
hud._summaryContext = async () => ({ place: 'Lisboa' });
hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
@@ -501,15 +501,17 @@ test('after a dropped or timed-out request the next tick asks again, and a move
});
await update;
// A reply still typing stops when the camera starts moving.
// A reply still typing stops when the camera starts moving, and the
// plain line replaces the fragment.
hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park');
t.mock.timers.tick(48);
const typed = element.textContent;
env.cameraListeners.moveStart();
const plain = element.textContent;
assert.equal(plain, hud._composeSummary());
t.mock.timers.tick(2_000);
assert.equal(
element.textContent,
typed,
plain,
'no more characters after the move',
);
} finally {
@@ -536,7 +538,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData();
await hud._updateSummary(true);
t.mock.timers.tick(48);
+300
View File
@@ -0,0 +1,300 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import './testSupport/stubMgrs.mjs';
import { hudReadouts } from './testSupport/hudReadouts.mjs';
const { IntelHUD } = await import('./hud.js');
const { setLocale } = await import('./i18n/index.js');
const { hudSummaryHasEnglish, hudSummaryInput } =
await import('./hudSummaryResponse.js');
const PARTIAL_FLIGHTS = {
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { count: 4, partial: true, source: 'opensky' },
};
function createHud(
t,
reply,
{ places = ['Остин'], locale = 'ru', layers = () => [PARTIAL_FLIGHTS] } = {},
) {
const { elements, publish } = hudReadouts();
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,
};
t.mock.timers.enable({ apis: ['setInterval'] });
const viewer = {
scene: { canvas: { width: 0, height: 0 }, pickPositionSupported: false },
camera: {
pitch: -Math.PI / 2,
heading: 0,
positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
const sent = [];
const hud = new IntelHUD(viewer, {
publish,
summaryService: {
configured: async () => true,
async summarize(context) {
sent.push(context);
return { ok: true, status: 200, data: { error: null, ...reply } };
},
},
});
t.after(() => {
hud.destroy();
setLocale('en');
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
});
// The test viewer has no view target, so no place lookup runs.
const buildContext = hud._summaryContext.bind(hud);
hud._summaryContext = async () =>
Object.assign(await buildContext(), { placeLabels: places });
hud._dataManager = { getAll: layers };
hud._latestMetrics = { altM: 600 };
setLocale(locale);
return {
hud,
sent,
shown: () => elements.get('hud-summary').textContent,
};
}
test('a ru HUD request carries no English layer name', async (t) => {
const { hud } = createHud(t, {});
const context = await hud._summaryContext();
assert.equal(context.locale, 'ru');
assert.deepEqual(context.enabledLayerLabels, ['Рейсы онлайн']);
const input = hudSummaryInput(context);
assert.doesNotMatch(input, /Live Flights/, input);
// The locale signs the dedupe signature; the English names stay local.
assert.match(JSON.stringify(context), /"locale":"ru"/);
assert.doesNotMatch(JSON.stringify(context), /englishLayerWords|warning/);
});
test('a ru summary shows the state word first and the layer names last', async (t) => {
const { hud, shown } = createHud(t, {
summary: 'Аэропорт задерживает рейсы',
feedState: 'partial',
});
await hud._updateSummary(false, true);
assert.equal(shown(), 'ЧАСТИЧНО | Аэропорт задерживает рейсы | РЕЙСЫ ОНЛАЙН');
});
for (const words of [
'Live Flights частично онлайн',
'Рейсы онлайн stale данные',
'Остин flights частично онлайн',
'Austin flights partially online',
]) {
test(`a ru reply echoing English falls back: ${words}`, async (t) => {
const { hud, shown } = createHud(t, {
summary: words,
feedState: 'partial',
});
await hud._updateSummary(false, true);
assert.ok(shown().startsWith('ЧАСТИЧНО | '), shown());
assert.ok(!shown().includes(words), shown());
});
}
test('a view with layers but no place label does not ask the model', async (t) => {
const { hud, sent, shown } = createHud(
t,
{ summary: 'Москва Рейсы онлайн частично', feedState: 'partial' },
{ places: [] },
);
await hud._updateSummary(false, true);
assert.equal(sent.length, 0);
assert.equal(shown(), hud._composeSummary());
});
test('a ja request leaves the layer state ids to `overall`', async (t) => {
const { hud } = createHud(t, {}, { locale: 'ja' });
const context = await hud._summaryContext();
assert.equal(context.feedProvenance.overall, 'partial');
assert.ok(!('feedState' in context.enabledLayers[0]));
assert.ok(!('feedState' in context.feedProvenance.layers[0]));
});
for (const [locale, summary] of [
['ja', '東京上空のstale航空情報'],
['ja', '東京上空のLive Flights'],
['zh-CN', '北京航班stale数据'],
['ru', 'Остин Live-Flights частично'],
]) {
test(`English flush against other text is an echo: ${summary}`, () => {
assert.ok(hudSummaryHasEnglish(summary, locale, ['stale', 'Live Flights']));
});
}
test('English inside a longer Latin word is not an echo', () => {
assert.ok(!hudSummaryHasEnglish('Datos stalesque', 'es', ['stale']));
});
for (const [locale, summary, places] of [
['es', 'Tráfico aéreo nominal sobre Madrid', ['Madrid']],
['de', 'Fallback Daten über Berlin', ['Berlin']],
['ja', 'Live Flights Plaza周辺', ['Live Flights Plaza']],
['es', 'Estación Loading Dock Madrid', ['Estación Loading Dock']],
]) {
test(`a ${locale} word that is not an echo is shown: ${summary}`, async (t) => {
const { hud, shown } = createHud(
t,
{ summary, feedState: 'partial' },
{ places, locale },
);
await hud._updateSummary(false, true);
assert.ok(shown().includes(summary), shown());
});
}
test('a language switch drops the reply in flight and asks again', async (t) => {
const { hud, sent, shown } = createHud(t, {});
let answer;
hud.summaryService.summarize = (context, { signal }) => {
sent.push(context);
return new Promise((resolve, reject) => {
answer = () =>
resolve({
ok: true,
status: 200,
data: {
error: null,
summary: 'Остин рейсы частично',
feedState: 'partial',
},
});
signal.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
});
};
const pending = hud._updateSummary(false, true);
while (!answer) await new Promise((resolve) => setImmediate(resolve));
setLocale('es');
answer();
await pending;
assert.ok(!shown().includes('Остин'), shown());
assert.equal(shown(), hud._composeSummary());
hud.summaryService.summarize = async (context) => {
sent.push(context);
return {
ok: true,
status: 200,
data: {
error: null,
summary: 'Austin vuelos parciales',
feedState: 'partial',
},
};
};
await hud._updateSummary(false);
assert.equal(sent.at(-1).locale, 'es');
assert.ok(shown().startsWith('PARCIAL | Austin vuelos parciales'), shown());
});
test('a reused ru reply carries the current warning, not the cached one', async (t) => {
// Both layers load an empty view: no count, no lastUpdate yet.
let flights = { loading: true, count: 0 };
const { hud, shown } = createHud(
t,
{ summary: 'Остин небо над городом', feedState: 'loading' },
{
layers: () => [
{ id: 'flights', name: 'Live Flights', enabled: true, stats: flights },
{
id: 'ais',
name: 'Live AIS Vessels',
enabled: true,
stats: { loading: true, count: 0 },
},
],
},
);
await hud._updateSummary(false, true);
assert.match(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
// Live Flights finishes with nothing to show; AIS is still loading.
flights = { count: 0 };
hud._markSummaryDirty();
await hud._updateSummary(false);
assert.match(
shown(),
/^ЗАГРУЗКА \| Остин небо над городом \| СУДА AIS/,
shown(),
);
assert.doesNotMatch(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
});
test('with every feed healthy a reply calling one unavailable falls back', async (t) => {
const { hud, shown } = createHud(
t,
{ summary: 'Остин рейсы недоступно сейчас', feedState: null },
{ layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), hud._composeSummary());
});
for (const [locale, place, summary] of [
['zh-CN', '北京', '北京航班不可用'],
['zh-Hant', '北京', '北京航班不可用'],
['ja', '東京', '東京航空便利用不可'],
]) {
test(`a ${locale} reply calling a feed unavailable flush against Han falls back`, async (t) => {
const { hud, shown } = createHud(
t,
{ summary, feedState: null },
{ locale, places: [place], layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), hud._composeSummary());
});
}
test('an ar reply opening with a Latin name keeps reading right to left', async (t) => {
const { hud, shown } = createHud(
t,
{ summary: 'Times Square نيويورك الولايات المتحدة', feedState: null },
{ locale: 'ar', places: ['Times Square'], layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), '\u200fTimes Square نيويورك الولايات المتحدة');
});
test('a one-word layer name that is a French word is not an echo', async (t) => {
const summary = 'Bus en transit à Paris';
const { hud, shown } = createHud(
t,
{ summary, feedState: null },
{
locale: 'fr',
places: ['Paris'],
layers: () => [{ id: 'transit', name: 'Transit', enabled: true }],
},
);
await hud._updateSummary(false, true);
assert.equal(shown(), summary);
});
+181 -27
View File
@@ -1,7 +1,10 @@
import {
SNAPSHOT_FEED_STATES,
feedProvenanceEnvelope,
layerSnapshots,
provenanceTag,
} from './data/layerSnapshot.js';
import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js';
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
@@ -43,19 +46,189 @@ export function isHudSummaryUnconfigured(status, data) {
}
/**
* HUD summary model instructions. The five-word line may only describe
* supplied labels, and must carry feed-state when any enabled layer is not
* nominal.
* HUD summary model instructions. The five words may only describe supplied
* labels; the feed state comes back as its own canonical field, so the check
* does not depend on the summary's language or wording.
*/
export const HUD_SUMMARY_INSTRUCTIONS = [
"Write one concise intelligence-HUD summary for God's Eye View.",
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.',
'In the summary, name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance into it.',
'Set feedState to feedProvenance.overall exactly as given, or null when it is null.',
'When feedProvenance.overall is null, say nothing about feeds or layers, present or missing.',
'If feedProvenance.overall is partial, do not present the coverage as complete; if it is any other state but nominal, do not present the view as live or healthy.',
'Do not infer from coordinates or invent a place or feed state.',
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
// "Exactly five words" made a small model pad a short view with words like
// "no data" or "unavailable".
'The summary has at most five words, with no title, punctuation, markdown, or introductory phrase.',
].join(' ');
/** Longest summary, in characters, for a script written without spaces. */
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
/** Longest summary, in characters; the cut falls between words. */
export const HUD_SUMMARY_MAX_CHARS = 80;
/** Responses API structured output: the summary plus the canonical state. */
export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
type: 'json_schema',
name: 'hud_summary',
strict: true,
schema: {
type: 'object',
properties: {
summary: { type: 'string' },
// Nullable rather than a `none` token, which the model wrote into the
// summary of a view without layers.
feedState: {
anyOf: [
{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] },
{ type: 'null' },
],
},
},
required: ['summary', 'feedState'],
additionalProperties: false,
},
});
/** A shipped locale for the request, English when missing or unsupported. */
export function hudSummaryLocale(value) {
return normalizeLocale(value) || DEFAULT_LOCALE;
}
// Names Intl.DisplayNames gets wrong for the shipped catalogs: pt is the
// Brazilian variant, and zh-Hant is the zh-CN wording in Traditional script.
const SUMMARY_LANGUAGES = Object.freeze({
pt: 'Brazilian Portuguese',
'zh-CN': 'Simplified Chinese',
'zh-Hant': 'Chinese in Traditional characters (mainland wording)',
});
/** Scripts written without spaces, capped in characters instead of words. */
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
/** Shipped locales not written in Latin letters. */
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh)(?:-|$)/;
/** Scripts whose letters extend a word; any other neighbour ends it. */
const WORD_SCRIPTS = [
'Latin',
'Cyrillic',
'Greek',
'Arabic',
'Hebrew',
'Han',
'Hiragana',
'Katakana',
'Hangul',
].map((script) => `\\p{Script=${script}}`);
/** Lower case, with punctuation and brackets as spaces: `Earthquakes 24h`. */
function flatWords(value) {
return String(value || '')
.normalize('NFC')
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim();
}
const UNSPACED_WORD =
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]+$/u;
function wordScript(char) {
return WORD_SCRIPTS.find((script) => new RegExp(script, 'u').test(char));
}
/**
* Whether `word` stands on its own in `text`. Only a letter of the same
* script extends it, so `stale` flush against kana or Han still counts.
* Han and kana have no word edges: a word in them counts anywhere.
* @param {string} text
* @param {string} word
* @returns {boolean}
*/
export function hasWord(text, word) {
const haystack = flatWords(text);
const needle = flatWords(word);
if (!needle) return false;
if (UNSPACED_WORD.test(needle)) return haystack.includes(needle);
const chars = [...needle];
const before = wordScript(chars[0]);
const after = wordScript(chars.at(-1));
// flatWords leaves no regex syntax in the needle.
return new RegExp(
`${before ? `(?<!${before})` : ''}${needle}${after ? `(?!${after})` : ''}`,
'u',
).test(haystack);
}
/**
* Whether a non-English summary carries what the HUD does not show: one of
* `words` (feed-state ids, translated layers' English names and ids), or only
* Latin letters in a language written in another script.
* @param {string} summary The model's words.
* @param {unknown} locale The request locale.
* @param {string[]} [words] Words the summary must not carry.
* @returns {boolean}
*/
export function hudSummaryHasEnglish(summary, locale, words = []) {
const tag = hudSummaryLocale(locale);
if (tag === DEFAULT_LOCALE) return false;
const letters = String(summary || '').replace(/[^\p{L}]/gu, '');
if (!hudSummaryIsLatin(tag) && !letters.replace(/\p{Script=Latin}/gu, ''))
return true;
return words.some((word) => hasWord(summary, word));
}
/** Whether the locale is written in Latin letters. */
export function hudSummaryIsLatin(locale) {
return !NON_LATIN_LOCALES.test(hudSummaryLocale(locale));
}
export function hudSummaryIsUnspaced(locale) {
return UNSPACED_LOCALES.test(hudSummaryLocale(locale));
}
/** The language a request's summary is written in, named in English. */
function summaryLanguage(tag) {
if (SUMMARY_LANGUAGES[tag]) return SUMMARY_LANGUAGES[tag];
try {
return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag;
} catch {
return tag;
}
}
/**
* @param {unknown} locale The `locale` the HUD sent.
* @returns {string}
*/
export function hudSummaryInstructions(locale) {
const tag = hudSummaryLocale(locale);
if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS;
const language = summaryLanguage(tag);
const length = hudSummaryIsUnspaced(tag)
? ` Instead of five words, write one phrase of at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters.`
: '';
return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English. Give places their usual names in that language and layers the names in enabledLayerLabels; feedState stays the English value.${length}`;
}
/**
* The model input: the HUD context, led by the summary language when it is
* not English. A small model follows the language far more reliably when it
* is part of the input as well as the instructions.
* @param {object} context The HUD summary context.
* @returns {string}
*/
export function hudSummaryInput(context) {
const tag = hudSummaryLocale(context?.locale);
const json = JSON.stringify(context ?? {});
if (tag === DEFAULT_LOCALE) return json;
return `Summary language: ${summaryLanguage(tag)} (${tag}).\n${json}`;
}
/**
* Stamp HUD summary context with the same layer snapshots voice tools use.
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
@@ -83,32 +256,13 @@ export function hudSummaryLayerContext(layers = [], options) {
}
/**
* The feed state and up to two layer names for the HUD telemetry suffix when
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
* id, `names` are `layer.name`); the HUD translates them for display.
* The warning for the HUD telemetry line when an enabled feed is not
* nominal.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {{ state: string, names: string[] }|null}
*/
export function hudTelemetryProvenance(layers = [], options) {
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
const envelope = feedProvenanceEnvelope(snapshots, options);
if (!envelope.overall || envelope.overall === 'nominal') return null;
const names = envelope.layers
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2);
return { state: envelope.overall, names };
}
/** Require the supplied non-nominal state before showing an AI summary. */
export function hudSummaryMatchesProvenance(summary, provenance) {
const state = provenance?.overall;
if (!state || state === 'nominal' || state === 'off') return true;
return String(summary || '')
.toUpperCase()
.split(/\W+/)
.includes(String(state).toUpperCase());
return provenanceTag(feedProvenanceEnvelope(snapshots, options));
}
+273 -24
View File
@@ -1,14 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { Readable } from 'node:stream';
import {
HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT,
HUD_SUMMARY_UNCONFIGURED_CODE,
hudSummaryInput,
hudSummaryInstructions,
hudSummaryLayerContext,
hudSummaryLocale,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
keylessHudSummaryResponse,
} from './hudSummaryResponse.js';
import {
SNAPSHOT_FEED_STATES,
hudSummaryMatchesProvenance,
provenanceTag,
} from './data/layerSnapshot.js';
import { parseHudSummaryReply } from '../server/providers/openai/hud-summary.js';
import { openAiRealtimeProxy } from '../vite.config.js';
const UNCONFIGURED_PAYLOAD = {
@@ -206,46 +217,284 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
);
});
test('HUD summary instructions require non-nominal feedState in the five words', () => {
test('HUD summary instructions ask for the canonical feedState as its own field', () => {
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/five words MUST include that feedState token/,
/Set feedState to feedProvenance\.overall exactly/,
);
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
/do not present the view as live or healthy/,
);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/partial, do not present the coverage as complete/,
);
assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [
'summary',
'feedState',
]);
assert.deepEqual(
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.anyOf,
[{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] }, { type: 'null' }],
);
assert.ok(!SNAPSHOT_FEED_STATES.includes('none'));
assert.ok(SNAPSHOT_FEED_STATES.includes('partial'));
});
test('the HUD proxy uses the shared provenance instructions', () => {
const local = readFileSync(
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
'utf8',
test('the instructions name the UI language, with English as the fallback', () => {
assert.match(hudSummaryInstructions('es'), /in Spanish \(es\)/);
assert.match(hudSummaryInstructions('ru-RU'), /in Russian \(ru\)/);
assert.equal(hudSummaryInstructions('en'), HUD_SUMMARY_INSTRUCTIONS);
assert.equal(hudSummaryInstructions('xx-YY'), HUD_SUMMARY_INSTRUCTIONS);
assert.equal(hudSummaryInstructions(undefined), HUD_SUMMARY_INSTRUCTIONS);
assert.equal(hudSummaryLocale('es-MX'), 'es');
const english = { locale: 'en', placeLabels: ['Austin'] };
assert.equal(hudSummaryInput(english), JSON.stringify(english));
assert.match(
hudSummaryInput({ locale: 'ru' }),
/^Summary language: Russian \(ru\)\.\n\{/,
);
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
assert.doesNotMatch(local, /enabled-layer text labels/);
assert.equal(hudSummaryLocale(42), 'en');
});
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
const { hudSummaryMatchesProvenance } =
await import('./hudSummaryResponse.js');
assert.equal(
hudSummaryMatchesProvenance('Austin flights operating normally today', {
overall: 'stale',
test('shipped catalogs get their exact language, and CJK a character limit', () => {
assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/);
assert.match(
hudSummaryInstructions('zh-Hant'),
/in Chinese in Traditional characters \(mainland wording\) \(zh-Hant\)/,
);
assert.match(hudSummaryInstructions('zh-CN'), /in Simplified Chinese/);
assert.match(
hudSummaryInstructions('ja'),
new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`),
);
assert.doesNotMatch(hudSummaryInstructions('es'), /characters/);
assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/);
});
let hudSummaryClient = 0;
async function postHudSummary(context, reply) {
// A fresh address per request: the per-IP limiter outlives a single test.
hudSummaryClient += 1;
const previousKey = process.env.OPENAI_API_KEY;
const previousFetch = globalThis.fetch;
process.env.OPENAI_API_KEY = 'test-key';
let sent = null;
globalThis.fetch = async (url, init) => {
sent = JSON.parse(init.body);
return {
ok: true,
status: 200,
json: async () => ({ output_text: reply }),
};
};
try {
// The handler itself: the installed route adds a same-site gate that a
// bare test request does not pass.
const { handleHudSummary: hud } =
await import('../server/providers/openai/hud-summary.js');
const req = Object.assign(
Readable.from([Buffer.from(JSON.stringify(context))]),
{
method: 'POST',
url: '/',
headers: {},
socket: { remoteAddress: `10.0.0.${hudSummaryClient}` },
},
);
const response = await new Promise((resolve, reject) => {
const res = {
statusCode: 200,
setHeader() {},
end(body = '') {
resolve({ statusCode: this.statusCode, body: JSON.parse(body) });
},
};
Promise.resolve(hud(req, res)).catch(reject);
});
return { sent, response };
} finally {
globalThis.fetch = previousFetch;
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = previousKey;
}
}
test('an es request reaches the model in Spanish and returns the canonical state', async () => {
const { sent, response } = await postHudSummary(
{
locale: 'es',
placeLabels: ['Austin'],
feedProvenance: { overall: 'stale' },
},
JSON.stringify({
summary: 'Austin vuelos con datos antiguos',
feedState: 'stale',
}),
);
assert.match(sent.instructions, /in Spanish \(es\)/);
assert.equal(sent.text.format.name, 'hud_summary');
const [lead, json] = sent.input.split('\n');
assert.equal(lead, 'Summary language: Spanish (es).');
assert.equal(JSON.parse(json).locale, 'es');
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, {
summary: 'Austin vuelos con datos antiguos',
feedState: 'stale',
error: null,
});
});
test('a Japanese summary is capped in characters, not words', async () => {
const long = 'テキサス州議会議事堂周辺の航空機データは古くなっています';
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({ summary: long, feedState: 'stale' }),
);
const shown = response.body.summary;
assert.ok(long.startsWith(shown), shown);
assert.ok([...shown].length <= HUD_SUMMARY_MAX_UNSPACED_CHARS, shown);
assert.ok([...shown].length >= 10, shown);
});
// Within the cap a reply comes back whole, in NFC: marks, in-word
// apostrophes and dots, multi-word names and Han names included.
for (const [locale, summary] of [
['ar', 'دبي حركة الطيران محدودة حاليًا'],
['ar', 'القاهرة تتبّع الرحلات الآن'],
['fr', "Vols au-dessus d'Orléans aujourd'hui partiels"],
['it', "Voli sull'aeroporto di Roma parziali"],
['fr', 'Données de vol à Paris'.normalize('NFD')],
['fr', 'Rue de Rivoli Vols en direct'],
['es', 'Times Square Nueva York Vuelos en vivo'],
['ru', 'Times Square Рейсы онлайн и суда'],
['en', 'Shibuya 渋谷 live flights overhead'],
['es', 'Vuelos sobre 東京駅 datos antiguos'],
['ru', 'Башня Тайбэй 101 台北101 рейсы'],
['ja', 'サンフランシスコ・ベイエリア'],
['ja', '東京 上空 の 航空 情報 は 古い'],
['zh-CN', 'SFO 附近 4 架 航班 数据 过期'],
['zh-Hant', 'SFO 附近 4 架 航班 數據 過期'],
]) {
test(`a ${locale} summary within the cap is kept whole: ${summary}`, () => {
const reply = JSON.stringify({ summary, feedState: null });
assert.equal(
parseHudSummaryReply(reply, locale).summary,
summary.normalize('NFC'),
);
});
}
test('a summary is capped in characters', async () => {
const { response } = await postHudSummary(
{ locale: 'de', feedProvenance: { overall: null } },
JSON.stringify({ summary: `Berlin ${'x'.repeat(200)}`, feedState: null }),
);
assert.equal(response.body.summary, 'Berlin');
});
test('a ja cap steps back to a space instead of cutting a Latin word', async () => {
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({
summary: 'San Francisco Bay Area flights',
feedState: 'stale',
}),
);
assert.equal(response.body.summary, 'San Francisco Bay');
});
test('a ja cap keeps the text before a Latin word it would split', async () => {
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({
summary: '東京 羽田空港周辺のFlightAware航空情報',
feedState: 'stale',
}),
);
assert.equal(response.body.summary, '東京 羽田空港周辺の');
});
test('a reply that skips the format carries no summary and no state', async () => {
const { response } = await postHudSummary(
{ locale: 'ru', feedProvenance: { overall: 'stale' } },
'Остин рейсы данные устарели сейчас',
);
assert.equal(response.body.summary, null);
assert.equal(response.body.feedState, null);
const unknown = await postHudSummary(
{ locale: 'ru', feedProvenance: { overall: 'stale' } },
JSON.stringify({ summary: 'Остин рейсы', feedState: 'fine' }),
);
assert.equal(unknown.response.body.feedState, null);
});
test("a localized summary is shown only with the HUD's own canonical state", () => {
const stale = { overall: 'stale' };
assert.equal(
hudSummaryMatchesProvenance(
{ summary: 'Остин рейсы данные устарели сейчас', feedState: 'stale' },
stale,
),
true,
);
assert.equal(
hudSummaryMatchesProvenance(
{ summary: 'Austin vuelos en vivo ahora', feedState: 'nominal' },
stale,
),
false,
);
assert.equal(
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
overall: 'stale',
}),
true,
hudSummaryMatchesProvenance(
{ summary: 'Austin stale flights over downtown', feedState: null },
stale,
),
false,
);
assert.equal(
hudSummaryMatchesProvenance('Austin flights operating normally today', {
overall: 'nominal',
}),
hudSummaryMatchesProvenance(
{ summary: 'Austin flights' },
{ overall: 'partial' },
),
false,
);
assert.equal(
hudSummaryMatchesProvenance(
{ summary: 'Austin flights operating normally today' },
{ overall: 'nominal' },
),
true,
);
assert.equal(hudSummaryMatchesProvenance({ summary: 'x' }, null), true);
});
test('a partial view keeps its warning tag', () => {
const now = 1_000_000;
const layers = [
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { partial: true, count: 4, lastUpdate: now },
},
{
id: 'vessels',
name: 'Live AIS Vessels',
enabled: true,
stats: { count: 9, lastUpdate: now },
},
];
const context = hudSummaryLayerContext(layers, { now });
assert.equal(context.feedProvenance.overall, 'partial');
assert.deepEqual(provenanceTag(context.feedProvenance), {
state: 'partial',
names: ['Live Flights'],
});
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
state: 'partial',
names: ['Live Flights'],
});
});
+3 -5
View File
@@ -562,7 +562,7 @@
"Radio volume": "مستوى صوت الراديو",
"Radio off": "الراديو متوقف",
"STATION SITE": "موقع المحطة",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث بعد الضغط على «تشغيل»، فيظهر لها عنوان IP الخاص بك.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث عند الضغط على «تشغيل» أو اختيار محطة على الكرة الأرضية أو طلب التشغيل بالصوت، فيظهر لها عنوان IP الخاص بك.",
"LOADING": "جارٍ التحميل",
"DEGRADED": "متدهور",
"STALE": "متقادم",
@@ -1146,7 +1146,6 @@
"OCEANIA": "أوقيانوسيا",
"NORTHERN OCEANIC GRID": "الشبكة المحيطية الشمالية",
"SOUTHERN OCEANIC GRID": "الشبكة المحيطية الجنوبية",
"{state} {layers}": "{state}: {layers}",
"view band|STREET": "شارع",
"view band|CITY": "مدينة",
"view band|METRO": "حضري",
@@ -3224,7 +3223,6 @@
"UTILITIES": "الأدوات",
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"World time": "وقت العالم",
"Back to live": "العودة إلى البث المباشر",
"Live": "مباشر"
"Sea Marks": "علامات بحرية",
"Ski Pistes": "مسارات التزلج"
}
+3 -5
View File
@@ -777,7 +777,7 @@
"Stop radio playback": "Radiowiedergabe stoppen",
"Radio volume": "Radio-Lautstärke",
"STATION SITE": "SENDER-WEBSITE",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe, bei der Wahl eines Senders auf dem Globus oder auf Wunsch per Sprachsteuerung wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.",
"Intelligence HUD (H)": "Lagebild-HUD (H)",
"Layout": "Layout",
"HUD layout": "HUD-Layout",
@@ -1114,7 +1114,6 @@
"OCEANIA": "OZEANIEN",
"NORTHERN OCEANIC GRID": "NÖRDLICHES OZEANRASTER",
"SOUTHERN OCEANIC GRID": "SÜDLICHES OZEANRASTER",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "STRASSE",
"view band|CITY": "STADT",
"view band|METRO": "GROSSRAUM",
@@ -2944,7 +2943,6 @@
"UTILITIES": "WERKZEUGE",
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
"World time": "Weltzeit",
"Back to live": "Zurück zu live",
"Live": "Live"
"Sea Marks": "Seezeichen",
"Ski Pistes": "Skipisten"
}
+3 -5
View File
@@ -450,7 +450,7 @@
"Radio volume": "Volumen de Radio",
"Radio off": "Radio desactivada",
"STATION SITE": "SITIO DE LA EMISORA",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora después de pulsar ESCUCHAR. Tu IP es visible para esa emisora.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora al pulsar ESCUCHAR, elegir una emisora en el globo o pedirlo por voz. Tu IP es visible para esa emisora.",
"LOADING": "CARGANDO",
"DEGRADED": "DEGRADADO",
"STALE": "DESACTUALIZADO",
@@ -2021,7 +2021,6 @@
"Globe field image unavailable": "Imagen del campo en el globo no disponible",
"status chip|UNAVAILABLE": "NO DISP.",
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Contiene referencias a contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
"{state} {layers}": "{state}: {layers}",
"Selected Space Mission": "Misión espacial seleccionada",
"{duration}s + {hold}s": "{duration} s + {hold} s",
"Configured Source": "Fuente configurada",
@@ -3014,7 +3013,6 @@
"UTILITIES": "UTILIDADES",
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
"World time": "Hora mundial",
"Back to live": "Volver al directo",
"Live": "En directo"
"Sea Marks": "Señales marítimas",
"Ski Pistes": "Pistas de esquí"
}
+3 -5
View File
@@ -331,7 +331,6 @@
"STALE": "OBSOLÈTE",
"PARTIAL": "PARTIEL",
"FALLBACK": "SECOURS",
"{state} {layers}": "{state} : {layers}",
"view band|STREET": "RUE",
"view band|CITY": "VILLE",
"view band|METRO": "MÉTROPOLE",
@@ -2819,7 +2818,7 @@
"Radio volume": "Volume de la radio",
"STATION SITE": "SITE DE LA STATION",
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture. Votre IP est visible par ce diffuseur.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.",
"Local RTL-SDR receiver": "Récepteur RTL-SDR local",
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.",
@@ -3014,7 +3013,6 @@
"UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
"World time": "Heure du monde",
"Back to live": "Revenir au direct",
"Live": "Direct"
"Sea Marks": "Balisage maritime",
"Ski Pistes": "Pistes de ski"
}
+3 -5
View File
@@ -331,7 +331,6 @@
"STALE": "OBSOLETO",
"PARTIAL": "PARZIALE",
"FALLBACK": "RISERVA",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "STRADA",
"view band|CITY": "CITTÀ",
"view band|METRO": "METRO",
@@ -2819,7 +2818,7 @@
"Radio volume": "Volume radio",
"STATION SITE": "SITO STAZIONE",
"DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play. Il tuo IP è visibile a quell'emittente.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play, scelto una stazione sul globo o chiesto la riproduzione a voce. Il tuo IP è visibile a quell'emittente.",
"Local RTL-SDR receiver": "Ricevitore RTL-SDR locale",
"LOCAL RTL-SDR": "RTL-SDR LOCALE",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.",
@@ -3014,7 +3013,6 @@
"UTILITIES": "STRUMENTI",
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
"World time": "Ora del mondo",
"Back to live": "Torna alla diretta",
"Live": "Diretta"
"Sea Marks": "Segnalamenti marittimi",
"Ski Pistes": "Piste da sci"
}
+3 -5
View File
@@ -325,7 +325,6 @@
"STALE": "更新遅延",
"PARTIAL": "部分的",
"FALLBACK": "フォールバック",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "街路",
"view band|CITY": "市街",
"view band|METRO": "都市圏",
@@ -2677,7 +2676,7 @@
"Radio volume": "ラジオの音量",
"STATION SITE": "放送局サイト",
"DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "再生ボタンを押すと、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "再生ボタンを押す、地球儀で局を選ぶ、または音声コントロールで再生を指示すると、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。",
"Local RTL-SDR receiver": "ローカルRTL-SDR受信機",
"LOCAL RTL-SDR": "ローカルRTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。",
@@ -2874,7 +2873,6 @@
"UTILITIES": "ユーティリティ",
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"World time": "世界時刻",
"Back to live": "ライブに戻る",
"Live": "ライブ"
"Sea Marks": "航路標識",
"Ski Pistes": "スキーゲレンデ"
}
+3 -5
View File
@@ -331,7 +331,6 @@
"STALE": "DESATUALIZADO",
"PARTIAL": "PARCIAL",
"FALLBACK": "ALTERNATIVO",
"{state} {layers}": "{state}: {layers}",
"view band|STREET": "RUA",
"view band|CITY": "CIDADE",
"view band|METRO": "METRÓPOLE",
@@ -2821,7 +2820,7 @@
"Radio volume": "Volume do rádio",
"STATION SITE": "SITE DA ESTAÇÃO",
"DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora depois que você aperta \"Ouvir\". Seu IP fica visível para essa emissora.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", escolhe uma estação no globo ou pede por voz. Seu IP fica visível para essa emissora.",
"Local RTL-SDR receiver": "Receptor RTL-SDR local",
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.",
@@ -3014,7 +3013,6 @@
"UTILITIES": "UTILITÁRIOS",
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
"World time": "Hora do mundo",
"Back to live": "Voltar ao vivo",
"Live": "Ao vivo"
"Sea Marks": "Sinais marítimos",
"Ski Pistes": "Pistas de esqui"
}
+3 -5
View File
@@ -727,7 +727,7 @@
"STATION TAG": "ТЕГ СТАНЦИИ",
"DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "После нажатия «Пуск» звук идёт напрямую от вещателя, и ему виден ваш IP-адрес.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук идёт напрямую от вещателя после нажатия «Пуск», выбора станции на глобусе или голосовой команды. Вещателю виден ваш IP-адрес.",
"Internet radio and local SDR": "Интернет-радио и локальный SDR",
"Filter stations by station tag": "Фильтровать станции по тегу",
"CONTEXT": "КОНТЕКСТ",
@@ -2055,7 +2055,6 @@
"Globe field image unavailable": "Изображение поля на глобусе недоступно",
"status chip|UNAVAILABLE": "НЕДОСТ.",
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные условия использования по-прежнему действуют.",
"{state} {layers}": "{state}: {layers}",
"Selected Space Mission": "Выбранная космическая миссия",
"{duration}s + {hold}s": "{duration} с + {hold} с",
"Configured Source": "Настроенный источник",
@@ -3084,7 +3083,6 @@
"UTILITIES": "ИНСТРУМЕНТЫ",
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"World time": "Мировое время",
"Back to live": "Вернуться к прямому эфиру",
"Live": "Сейчас"
"Sea Marks": "Морские знаки",
"Ski Pistes": "Горнолыжные трассы"
}
+3 -5
View File
@@ -334,7 +334,6 @@
"STALE": "ЗАСТАРІЛО",
"PARTIAL": "ЧАСТКОВО",
"FALLBACK": "РЕЗЕРВ",
"{state} {layers}": "{state}: {layers}",
"view band|STREET": "ВУЛИЦЯ",
"view band|CITY": "МІСТО",
"view band|METRO": "АГЛОМЕРАЦІЯ",
@@ -2889,7 +2888,7 @@
"Radio volume": "Гучність радіо",
"STATION SITE": "САЙТ СТАНЦІЇ",
"DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Після натискання «Відтворити» звук надходить безпосередньо від мовника. Мовник бачить вашу IP-адресу.",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук надходить безпосередньо від мовника після натискання «Відтворити», вибору станції на глобусі або голосової команди. Мовник бачить вашу IP-адресу.",
"Local RTL-SDR receiver": "Локальний приймач RTL-SDR",
"LOCAL RTL-SDR": "ЛОКАЛЬНИЙ RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальний тюнер для FM і шару «Локальний ADS-B».",
@@ -3084,7 +3083,6 @@
"UTILITIES": "ІНСТРУМЕНТИ",
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
"World time": "Світовий час",
"Back to live": "Повернутися до прямого ефіру",
"Live": "Зараз"
"Sea Marks": "Морські знаки",
"Ski Pistes": "Гірськолижні траси"
}
+3 -5
View File
@@ -325,7 +325,6 @@
"STALE": "过期",
"PARTIAL": "部分",
"FALLBACK": "备用",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "街道",
"view band|CITY": "城市",
"view band|METRO": "都市圈",
@@ -2576,7 +2575,7 @@
"Radio volume": "电台音量",
"STATION SITE": "电台网站",
"DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "点击播放后,音频将直接连接到广播方。广播方可以看到你的 IP。",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "点击播放、在地球上选择电台或通过语音要求播放后,音频将直接连接到广播方。广播方可以看到你的 IP。",
"Local RTL-SDR receiver": "本地 RTL-SDR 接收机",
"LOCAL RTL-SDR": "本地 RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。",
@@ -2874,7 +2873,6 @@
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
"World time": "世界时间",
"Back to live": "返回实时",
"Live": "实时"
"Sea Marks": "航标",
"Ski Pistes": "滑雪道"
}
+3 -5
View File
@@ -325,7 +325,6 @@
"STALE": "過期",
"PARTIAL": "部分",
"FALLBACK": "備用",
"{state} {layers}": "{state} {layers}",
"view band|STREET": "街道",
"view band|CITY": "城市",
"view band|METRO": "都市圈",
@@ -2564,7 +2563,7 @@
"Radio volume": "電臺音量",
"STATION SITE": "電臺網站",
"DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "點擊播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。",
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "點擊播放、在地球上選擇電臺或通過語音要求播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。",
"Local RTL-SDR receiver": "本地 RTL-SDR 接收機",
"LOCAL RTL-SDR": "本地 RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。",
@@ -2874,7 +2873,6 @@
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
"World time": "世界時間",
"Back to live": "返回即時",
"Live": "即時"
"Sea Marks": "航標",
"Ski Pistes": "滑雪道"
}
-1
View File
@@ -89,7 +89,6 @@
"Landsat 8/9",
"Latin",
"Layout",
"Live",
"London",
"Los Angeles",
"MIL",
+2 -3
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import {
COURSE_HOLD_SPEED_MPS,
lerpAngleDeg,
@@ -157,7 +156,7 @@ export function createMotion({
const renderTime =
sampledRenderTime ??
Cesium.JulianDate.addSeconds(
julianAt(flightState.now(), flightState._scratchRenderTime),
Cesium.JulianDate.now(flightState._scratchRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
@@ -333,7 +332,7 @@ export function createMotion({
const history = flightState._positionHistory.get(flightState._trackedIcao);
if (!history || history.length === 0) return true;
const renderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
Cesium.JulianDate.now(),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
+6 -8
View File
@@ -12,7 +12,6 @@ function fixture() {
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
now: Date.now,
};
const motion = createMotion({
flightState,
@@ -139,7 +138,7 @@ test('fleet render time is shared without resampling or mutating the supplied in
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
});
test('tracked callers still sample a fresh delayed time without a fleet epoch', () => {
test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
const { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const history = [
@@ -157,11 +156,10 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
flightState._positionHistory.set('tracked', history);
let elapsed = 60;
let reads = 0;
flightState.now = () => {
reads += 1;
return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000;
};
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
assert.equal(result, flightState._scratchRenderTime);
return Cesium.JulianDate.addSeconds(start, elapsed, result);
});
const out = new Cesium.Cartesian3();
close(
motion._deadReckon('tracked', out),
@@ -182,5 +180,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
new Cesium.Cartesian3(),
),
);
assert.equal(reads, 2);
assert.equal(spy.mock.callCount(), 2);
});
+1 -2
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js';
@@ -864,7 +863,7 @@ export function createRendering({
flightState._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now(), flightState._scratchFleetRenderTime),
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchFleetRenderTime,
);
-3
View File
@@ -1,7 +1,6 @@
import { FlightRecords } from './records.js';
import { createFlightFeed } from './ingestion.js';
import * as Cesium from 'cesium';
import { worldTimeFor } from '../../time/worldClock.js';
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
export function createFlightState({ source, services }) {
@@ -13,8 +12,6 @@ export function createFlightState({ source, services }) {
...services.groundFloor,
});
flightState._cullPositions = new Map();
/** The time contacts are drawn behind by the render delay, in epoch ms. */
flightState.now = worldTimeFor('flights');
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
+1 -2
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
@@ -384,7 +383,7 @@ export function createTracking({
// would draw the trail in front of the plane. They join the trail via _appendTrailFix
// as they age past the delay.
const seedRenderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
Cesium.JulianDate.now(),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
+1 -3
View File
@@ -69,8 +69,6 @@ export function createGnssIntegrityLayer({
source,
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
// The end of the window shown; retries and receipt times stay on `now`.
worldNow = now,
monotonicNow = () => performance.now(),
windowMs: requestedWindowMs,
} = {}) {
@@ -165,7 +163,7 @@ export function createGnssIntegrityLayer({
/** Age the store without new evidence, so expired cells leave the globe. */
function expire() {
pruneGnssObservations(_observations, worldNow(), { windowMs });
pruneGnssObservations(_observations, now(), { windowMs });
_cells = binGnssCells(_observations);
render();
}
+1 -1
View File
@@ -26,7 +26,7 @@ export function createControls({ state: layerState, services, parts, source }) {
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState._enabled) return [];
const now = layerState.now();
const now = Date.now();
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
const result = [];
for (const launch of layerState._launches || []) {
-1
View File
@@ -222,7 +222,6 @@ export function createIngestion({
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
layerState.coverageChanged();
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message;
+1 -1
View File
@@ -105,7 +105,7 @@ export function createLaunchPad({
}
const nowMs = Date.now();
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
layerState._missionRingDate.setTime(layerState.orbitNow());
layerState._missionRingDate.setTime(nowMs);
parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
layerState._missionRingDate,
);
-18
View File
@@ -1,6 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { worldClock } from '../../time/worldClock.js';
const HOVER_PICK_INTERVAL_MS = 120;
@@ -120,21 +119,6 @@ export function createLifecycle({
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null;
// While on, the launches loaded span the time from the first to the last,
// each one a mark. Registered once; off, the layer has no time to show, and
// each step tells the clock once it has settled.
worldClock.register({
id: 'rocket-launches',
coverage: () => {
if (!layerState._enabled) return null;
const marks = layerState._launches
.map((launch) => Date.parse(launch.launchTime))
.filter(Number.isFinite)
.sort((a, b) => a - b);
if (!marks.length) return null;
return { start: marks[0], end: marks.at(-1), marks };
},
});
const methods = {
init(viewer) {
@@ -210,7 +194,6 @@ export function createLifecycle({
}
throw error;
}
layerState.coverageChanged();
},
async _enableBody() {
@@ -248,7 +231,6 @@ export function createLifecycle({
parts.overlays.clearMissionOverlaySources();
await parts.ingestion.restoreSatelliteDependency();
parts.panel.renderMissionPanel();
layerState.coverageChanged();
},
async destroy(viewer) {
+1 -1
View File
@@ -124,7 +124,7 @@ export function createOrbitRendering({
collection.show = missionOrbitPrimitiveVisible(launch.id);
orbitFrameModelMatrix(
satelliteTrack.gmstAtBake,
new Date(layerState.orbitNow()),
new Date(),
collection.modelMatrix,
);
layerState._viewer.scene.primitives.add(collection);
+2 -2
View File
@@ -394,7 +394,7 @@ export function createRendering({
orbitCurrent.latitude,
orbitCurrent.altitude,
);
const liveTime = new Date(layerState.orbitNow());
const liveTime = new Date();
const liveTelemetry = { speedMps: null };
layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
let liveFrameNumber = -1;
@@ -405,7 +405,7 @@ export function createRendering({
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
liveFrameNumber = frameNumber;
const nowMs = layerState.orbitNow();
const nowMs = Date.now();
liveTime.setTime(nowMs);
if (satelliteTrack) {
const propagated =
-13
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
@@ -148,18 +147,6 @@ export function createState({ services }) {
state._missionRingDate = new Date(0);
/** The time the launch window ends at, in epoch ms. */
state.now = worldTimeFor('rocket-launches');
/** A launched mission's satellite flies on the satellites layer's time. */
state.orbitNow = worldTimeFor('satellites');
/** The launches loaded changed, and with them the time they cover. */
state.coverageChanged = () => worldClock.coverageChanged();
state._missionOrbitPatternRegistered = false;
state._pathDistanceCache = new WeakMap();
+2 -3
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import {
lerpAngleDeg,
speedRamp,
@@ -68,7 +67,7 @@ export function createMotion({
// Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
Cesium.JulianDate.now(),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
@@ -214,7 +213,7 @@ export function createMotion({
const history = flightState._positionHistory.get(flightState._trackedIcao);
if (!history || history.length === 0) return true;
const renderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
Cesium.JulianDate.now(),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
+73 -20
View File
@@ -28,6 +28,29 @@ function services() {
};
}
// The application's own scene services, as src/app/layers/militaryFlights.js
// composes them; only the surface samplers and registry are stubbed.
async function sceneServices() {
return {
picking: await import('../../data/pickRegistry.js'),
sprites: await import('../../data/spriteOrder.js'),
trails: await import('../../data/trailRenderer.js'),
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
camera: await import('../../data/trackedCamera.js'),
labels: await import('../../data/detectionDraw.js'),
geoid: await import('../../data/geoid.js'),
focus: await import('../../data/focusDeemphasis.js'),
readout: await import('../../data/trackedReadout.js'),
context: await import('../../data/contextStore.js'),
render: await import('../../renderGovernor.js'),
recession: await import('../../data/aircraftRecession.js'),
groundFloor: await import('../../data/groundFloor.js'),
groundSnap: await import('../../data/groundSnap.js'),
meshFloor: { sampleMeshFloorCells: () => {} },
militaryRegistry: { registerMilitaryIcaos: () => {} },
};
}
test('military layer instances isolate policy and restoration state without requesting a source', async () => {
let requests = 0;
const source = {
@@ -122,26 +145,7 @@ test('a newly seen military contact appears at its delayed position, not ahead o
// the next fleet tick jumped it back.
const Cesium = await import('cesium');
const { RENDER_DELAY_SEC } = await import('./policy.js');
// The application's own scene services, as src/app/layers/militaryFlights.js
// composes them; only the surface samplers and registry are stubbed.
const supplied = {
picking: await import('../../data/pickRegistry.js'),
sprites: await import('../../data/spriteOrder.js'),
trails: await import('../../data/trailRenderer.js'),
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
camera: await import('../../data/trackedCamera.js'),
labels: await import('../../data/detectionDraw.js'),
geoid: await import('../../data/geoid.js'),
focus: await import('../../data/focusDeemphasis.js'),
readout: await import('../../data/trackedReadout.js'),
context: await import('../../data/contextStore.js'),
render: await import('../../renderGovernor.js'),
recession: await import('../../data/aircraftRecession.js'),
groundFloor: await import('../../data/groundFloor.js'),
groundSnap: await import('../../data/groundSnap.js'),
meshFloor: { sampleMeshFloorCells: () => {} },
militaryRegistry: { registerMilitaryIcaos: () => {} },
};
const supplied = await sceneServices();
const fixLon = -97.6;
const fixLat = 30.3;
const speedMps = 200;
@@ -213,6 +217,55 @@ test('a newly seen military contact appears at its delayed position, not ahead o
);
});
test('a vanished military contact fades while position-less rows keep theirs', async () => {
// About a third of a live adsb.lol /v2/mil fetch is contacts without a
// position. Those rows marked every snapshot partial, so an aircraft that
// left the feed stayed at full opacity for five minutes.
const Cesium = await import('cesium');
const { readsbSnapshot } = await import('../../sources/live/aircraft.js');
const nowMs = Date.now();
const row = (hex, lon) => ({
hex,
lat: 30.3,
lon,
alt_baro: 20_000,
gs: 300,
track: 90,
seen_pos: 1,
});
const polls = [
[row('ae0001', -97.6), row('ae0002', -96.6), row('ae0003', -95.6)],
[row('ae0001', -97.5), { hex: 'ae0003' }],
];
// Every poll also lists a contact that never has a position.
for (const poll of polls) poll.push({ hex: 'ae0009', type: 'mode_s' });
const layer = createMilitaryFlightLayer({
services: await sceneServices(),
source: {
label: 'Fixture aircraft',
getSnapshot: async () =>
readsbSnapshot(
{ ac: polls.shift() },
{ observedAtMs: nowMs, now: nowMs },
),
},
});
const viewer = { camera: { positionCartographic: null }, scene: {} };
layer.testing._setTrackedMilitaryRefreshStateForTest({
icao24: 'seed00',
entity: null,
meta: { rawLat: 0, rawLon: 0, onGround: false },
billboard: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0) },
billboardCollection: { show: true, add: (options) => ({ ...options }) },
viewer,
tracked: false,
});
await layer.update(viewer);
await layer.update(viewer);
assert.equal(layer.findByQuery('ae0002')?.stale, true, 'vanished: fading');
assert.equal(layer.findByQuery('ae0003')?.stale, false, 'still listed');
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const stubs = services();
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
+5 -2
View File
@@ -187,9 +187,12 @@ export class MilitaryFlightRecords {
fixEpochMs: aircraft.positionTimeMs ?? observedAtMs,
};
}
absence(id, { complete, likelyLanded }) {
absence(id, { complete, retainIds, likelyLanded }) {
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
if (
!complete &&
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
+24
View File
@@ -99,6 +99,30 @@ test('military records keep bounded partial retention and release metadata only
);
});
test('complete military snapshots age out vanished aircraft and retain position-less ones unless landed', () => {
const records = store();
for (const id of ['aaa001', 'aaa002', 'aaa003'])
records.receive(observation({ id, reference: id }), context());
// aaa001 and aaa003 are still listed without a position; aaa002 vanished.
const absent = (id, likelyLanded = false) =>
records.absence(id, {
complete: true,
retainIds: new Set(['aaa001', 'aaa003']),
likelyLanded,
});
assert.deepEqual(
[absent('aaa002'), absent('aaa002'), absent('aaa002')],
['stale', 'stale', 'remove'],
);
assert.equal(absent('aaa001'), 'retain');
assert.equal(records.missingPolls.has('aaa001'), false);
// A landed contact that lost its position takes the landed fast-cull.
assert.equal(absent('aaa003', true), 'remove');
// Position-less retention stays bounded like partial admission.
records.data.get('aaa001').observedReceiptMs = Date.now() - 300001;
assert.equal(absent('aaa001'), 'stale');
});
test('military emergency status follows the latest poll instead of sticking', () => {
const records = store();
const first = records.receive(
+1
View File
@@ -259,6 +259,7 @@ export function createMilitarySnapshotRenderer({
if (currentIcaos.has(icao24)) continue;
const absence = records.absence(icao24, {
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
});
if (absence === 'retain') continue;
-3
View File
@@ -1,7 +1,6 @@
import { MilitaryFlightRecords } from './records.js';
import { createMilitaryFeed } from './ingestion.js';
import * as Cesium from 'cesium';
import { worldTimeFor } from '../../time/worldClock.js';
export function createFlightState({ source, services }) {
const { createGroundSnap } = services.groundSnap;
@@ -12,8 +11,6 @@ export function createFlightState({ source, services }) {
...services.groundFloor,
});
flightState._cullPositions = new Map();
/** The time contacts are drawn behind by the render delay, in epoch ms. */
flightState.now = worldTimeFor('military');
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
* military contacts should read as their WEIGHT CLASS, always in this layer's
+1 -2
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { t } from '../../i18n/index.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
@@ -501,7 +500,7 @@ export function createTracking({
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
// would draw the trail in front of the plane. They join via _appendTrailFix as they age.
const seedRenderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
Cesium.JulianDate.now(),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
+6 -3
View File
@@ -344,14 +344,17 @@ export function createPlayback({ state: layerState, services, parts, source }) {
}
if (!parts.interaction.radioPresentationAllowed())
return Promise.resolve(false);
if (!layerState._selectedId) {
if (layerState._selectedId) {
// Drop a fallback left by a voice step whose playback never started.
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
} else {
const ranked = parts.queries.rankedVisibleStations();
if (!ranked.length) return Promise.resolve(false);
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
parts.selection.selectRadioStation(ranked[0].id, {
autoplay: false,
focus: false,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
}
return playSelectedRadio({ origin });
+46 -16
View File
@@ -73,6 +73,16 @@ export function createSelection({
return { filter: layerState._filter, volume: layerState._userVolume };
}
// A paused or failed stream of another station would outlive its selection.
function releaseStaleRadioStream(stationId) {
if (
layerState._audioStationId &&
layerState._audioStationId !== stationId &&
['paused', 'error'].includes(layerState._audioState)
)
parts.playback.stopRadioPlayback();
}
/** Select a station. Playback occurs only when autoplay is explicitly true. */
function selectRadioStation(
@@ -83,11 +93,18 @@ export function createSelection({
origin = 'programmatic',
attemptId = null,
cameraNavigation = null,
playFallback = null,
} = {},
) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const station = layerState._stationById.get(String(id));
if (!station) return false;
if (!autoplay) releaseStaleRadioStream(station.id);
// Set after the release, which clears fallbacks.
if (playFallback) {
layerState._playFallbackId = playFallback.id;
layerState._playFallbackFocus = playFallback.focus;
}
layerState._tuningUnavailableStationId = null;
if (
!parts.navigation.radioCameraNavigationOwnsSelection(
@@ -104,6 +121,7 @@ export function createSelection({
)
parts.tuning.endRadioTuning();
layerState._cancelledTuningPresentationStation = null;
const changed = layerState._selectedId !== station.id;
layerState._selectedId = station.id;
const generation = ++layerState._selectionGeneration;
const sessionGeneration = layerState._sessionGeneration;
@@ -122,6 +140,9 @@ export function createSelection({
if (focus) parts.navigation.focusStation(station);
parts.presentation.emitState();
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
// A user step to another station cancels a playback voice prepared.
else if (origin === 'user' && changed)
parts.presentation.emitPlaybackControl('select', origin);
return true;
}
@@ -133,6 +154,7 @@ export function createSelection({
rotate = false,
stationIds = null,
autoplay = true,
fallback = autoplay,
origin = 'programmatic',
} = {},
) {
@@ -159,25 +181,29 @@ export function createSelection({
current < 0
? 0
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
layerState._playFallbackId =
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
// Voice defers playback, so its silent step keeps a fallback; silent UI steps do not.
const fallbackId =
fallback && ranked.length > 1
? ranked[(nextIndex + 1) % ranked.length].id
: null;
const rotationCameraState = rotate
? parts.navigation.radioCameraState()
: null;
const rotationNavigation = rotate
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
: null;
layerState._playFallbackFocus = rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
fallbackStation,
0.65,
rotationCameraState,
rotationNavigation,
);
return rotationNavigation;
}
: false;
const fallbackFocus =
fallback && rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
fallbackStation,
0.65,
rotationCameraState,
rotationNavigation,
);
return rotationNavigation;
}
: false;
const station = ranked[nextIndex];
if (rotate)
parts.navigation.rotateRadioStationIntoView(
@@ -191,6 +217,7 @@ export function createSelection({
focus: false,
origin,
cameraNavigation: rotationNavigation,
playFallback: { id: fallbackId, focus: fallbackFocus },
});
}
@@ -217,9 +244,12 @@ export function createSelection({
},
);
if (!ranked.length) return null;
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
selectRadioStation(ranked[0].id, {
autoplay,
focus: false,
origin,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
return ranked[0];
}
+2
View File
@@ -78,6 +78,8 @@ export function createState({ services }) {
layerState._tuningStartStationId = null;
layerState._tuningResumesPlayback = false;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
+17 -5
View File
@@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._cancelledTuningPresentationStation = null;
layerState._tuningStartStationId = layerState._selectedId;
layerState._tuningPreviewId = layerState._selectedId;
// Like Previous/Next, the release plays only if a stream was active.
layerState._tuningResumesPlayback = [
'loading',
'buffering',
'playing',
].includes(layerState._audioState);
parts.tuningNoise.installTuningNoise();
parts.playback.pauseRadioPlayback({ origin: 'user' });
parts.tuningNoise.syncTuningNoiseGain();
@@ -179,7 +185,9 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._selectionGeneration += 1;
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._tuningUnavailableStationId = stationId;
parts.presentation.emitState();
// The drag paused the stream; with no selection, Resume would start the
// nearest station. Stop also publishes the state and cancels a voice handoff.
parts.playback.stopRadioPlayback({ origin });
return Object.freeze({
ok: false,
reason: 'station-unavailable',
@@ -187,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) {
generation,
});
}
const autoplay = layerState._tuningResumesPlayback;
layerState._tuningResumesPlayback = false;
layerState._tuningActive = false;
layerState._tuningStatic = true;
layerState._tuningAwaitingStationId = frozenStation.id;
layerState._tuningStatic = autoplay;
layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null;
layerState._tuningUnavailableStationId = null;
@@ -204,10 +214,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
parts.tuningNoise.syncTuningNoiseGain();
// Muting alone would leave the noise graph running after a silent release.
if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
parts.presentation.emitState();
const selected = parts.selection.selectRadioStation(frozenStation.id, {
autoplay: true,
autoplay,
focus: false,
origin,
cameraNavigation,
+22 -4
View File
@@ -50,6 +50,11 @@ export function createTuningNoise({
layerState._tuningNoiseGain = null;
}
function suspendTuningNoiseContext() {
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
}
function syncTuningNoiseGain() {
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
const audible = radioTuningStaticShouldPlay({
@@ -79,8 +84,21 @@ export function createTuningNoise({
if (!AudioContextClass) return false;
if (!layerState._tuningNoiseContext)
layerState._tuningNoiseContext = new AudioContextClass();
const resumed = layerState._tuningNoiseContext.resume?.();
if (resumed?.catch) void resumed.catch(() => {});
const context = layerState._tuningNoiseContext;
const resumed = context.resume?.();
// A release before the device starts finds the context still suspended,
// so its suspend() is a no-op; suspend again once resume() settles.
if (resumed?.then)
void resumed.then(
() => {
if (
layerState._tuningNoiseContext === context &&
!layerState._tuningNoiseSource
)
suspendTuningNoiseContext();
},
() => {},
);
if (layerState._tuningNoiseSource) return true;
const frameCount = Math.max(
@@ -120,6 +138,7 @@ export function createTuningNoise({
if (layerState._tuningCameraNavigation)
parts.navigation.invalidateRadioCameraNavigation();
layerState._tuningActive = false;
layerState._tuningResumesPlayback = false;
layerState._tuningStatic = false;
layerState._tuningAwaitingStationId = null;
layerState._tuningPreviewId = null;
@@ -131,8 +150,7 @@ export function createTuningNoise({
parts.rendering.updateSelectionEntity();
syncTuningNoiseGain();
stopTuningNoiseSource();
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
suspendTuningNoiseContext();
if (emit) parts.presentation.emitState();
}
return {
+2 -4
View File
@@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
1,
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
);
const now = new Date(layerState.now());
const now = new Date();
for (let i = 0; i < perFrame; i++) {
if (layerState._denseCursor >= layerState._denseIds.length)
layerState._denseCursor = 0;
@@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
const entries = parts.orbits.parseTLE(text);
const style = POINT_STYLES.dense;
const now = new Date(layerState.now());
const now = new Date();
let added = 0;
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
@@ -165,7 +165,6 @@ export function createCatalog({ state: layerState, services, parts, source }) {
layerState._count = layerState._points.size;
layerState._catalogRevision++;
layerState.coverageChanged();
layerState._denseStatus = 'ready';
console.log(
`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`,
@@ -233,7 +232,6 @@ export function createCatalog({ state: layerState, services, parts, source }) {
layerState._denseCursor = 0;
layerState._count = layerState._points.size;
layerState._catalogRevision++;
layerState.coverageChanged();
}
return {
_abortActiveUpdates,
+1 -4
View File
@@ -190,10 +190,7 @@ export function createControls({ state: layerState, services, parts, source }) {
if (noradId === null) return null;
const sat = layerState._catalog.get(noradId);
const pos = parts.orbits.propagatePosition(
sat.satrec,
new Date(layerState.now()),
);
const pos = parts.orbits.propagatePosition(sat.satrec, new Date());
if (!pos) return null;
return {
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more