Compare commits

...
Author SHA1 Message Date
Claude 5ce1320fe8 Put the official site first in the README and in package.json
Moves the official-site line directly under the README title, and points
package.json's homepage at godseyeview.ai instead of the README anchor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011hsCV6Ap9bk7WtxUbbbxnT
2026-11-01 18:24:50 +00:00
Claude 99370c29da Link the official site, godseyeview.ai, from the README
Adds an official-site line under the tagline and in the closing links so the
repository points readers and search engines at godseyeview.ai. Merge after
the site is live.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011hsCV6Ap9bk7WtxUbbbxnT
2026-11-01 18:22:03 +00:00
Sameh Khamis 0d1a68160e Merge pull request #1494 from bilawalsidhu/sameh/weather-product-metadata
Let a weather source describe its own products
2026-10-31 23:07:08 -07:00
Sameh Khamis 70494698bc Keep a refreshed weather credit through frame installs and host switches
A refreshed manifest could lose its credit in two ways. A frame still
loading when the refresh arrived had no credit yet, so the refresh was
skipped and the frame installed with the credit of the snapshot it was
staged from. And a host switch restaged the shown frame, then re-applied
the refreshed manifest captured when the restage began, overwriting any
newer refresh that arrived while it loaded.

Each frame now remembers the latest refreshed manifest of its product,
whether or not its imagery is installed yet, and takes its credit from it,
falling back to the snapshot it was staged from. That snapshot still
supplies the time and bounds. A restage passes the newest refreshed
manifest to the new frame up front instead of re-applying a captured one
afterwards, and a refresh that arrives while a frame loads is kept for a
later restage.
2026-10-31 22:58:06 -07:00
Sameh Khamis 903f59302b Add the changelog entry for weather product display metadata 2026-10-31 22:42:45 -07:00
Sameh Khamis 039b0828b7 Let a weather source describe its own products
A weather source can describe its own products (credit, units, coverage,
legend) through an optional `display` object in each product manifest:
source, credit, units, coverage, info, legend and extent. The weather
layer uses each valid field in the row summary, legend, info text,
coverage navigation, outside-coverage status, layer stats and imagery
credit, and keeps today's built-in value for any field that is absent or
invalid. Units and legend describe one scale, so they apply only as a
valid pair; otherwise both keep their built-in values, and the radar
info heading names the units in use.

Text is single-line and length-capped. It rejects control characters
(U+0000-U+001F, U+007F-U+009F), line and paragraph separators, and
invisible or format characters: soft hyphen (U+00AD), combining grapheme
joiner (U+034F), Arabic letter mark (U+061C), Mongolian vowel separator
(U+180E), zero-width and direction marks (U+200B-U+200F), bidi embedding
and override (U+202A-U+202E), word joiner, invisible operators and bidi
isolates (U+2060-U+2069), variation selectors (U+FE00-U+FE0F), the
byte-order mark (U+FEFF) and tag characters (U+E0000-U+E007F).

Legends need 2-16 stops whose labels are plain decimal numbers or short
text and whose colours are #rrggbb. A number label must read back
exactly as the legend shows it: within the safe integer range, with no
leading zeros and no digits lost to rounding. Extents must be finite, in
range and non-empty. Supplied source and units text is data: the layer
marks it so the panels show it untranslated, while built-in values are
translated as before.

The imagery credit is escaped as plain text and follows a refreshed
manifest even when the shown frame stays the same. A refresh changes
only the credit: the shown frame keeps the snapshot it was staged from,
so a later host switch restages it with its own time and bounds and the
refreshed credit. Manifests without `display`, including the bundled
nowCOAST ones, render exactly as before.
2026-10-31 22:42:01 -07:00
Said Turksever 3cd7d0fe15 Merge pull request #1461 from quanticsoul4772/upstream/sky-test-windows-path
fix(sky): read the Milky Way faces by file path in the paint-out test
2026-11-01 01:36:44 +00:00
Said Turksever 544027053d Merge branch 'main' into upstream/sky-test-windows-path 2026-11-01 01:15:25 +00:00
Sameh Khamis 3824a47df8 Merge pull request #1493 from bilawalsidhu/sameh/firms-test-localhost
test(firms): stop the all-addresses connect test depending on how localhost resolves
2026-10-31 17:58:29 -07:00
Said Turksever f52e2e62ee Merge branch 'main' into upstream/sky-test-windows-path 2026-11-01 00:55:14 +00:00
Sameh Khamis 20ad658f6c Resolve localhost to both loopbacks in the real connect-failure test
The FIRMS test that checks a real all-addresses connect failure relied on
the system resolving localhost to both ::1 and 127.0.0.1. On a host whose
localhost resolves only to 127.0.0.1, fetch makes a single attempt, gets a
plain ECONNREFUSED instead of an AggregateError, and the test fails.

Mock dns.lookup for that fetch to return both loopback addresses, so the
real fetch still attempts both families and the error shape check stays
deterministic. Assert the mock was used, so a runtime that stops resolving
through dns.lookup fails loudly instead of silently testing something else.
2026-10-31 17:50:16 -07:00
Said Turksever b4c75749e8 Merge pull request #1446 from Rohilalala/fix/e-draw-area-unit
fix(draw): show a 9,999.6 m² area as 1.0 ha, not 10000 m²
2026-11-01 00:10:52 +00:00
Said Turksever 684dff3b55 Merge branch 'main' into fix/e-draw-area-unit 2026-11-01 00:04:22 +00:00
Sameh Khamis 8978801dfd Merge pull request #1491 from bilawalsidhu/sameh/gnss-source-window
Let a GNSS integrity source set the layer's evidence window
2026-10-31 16:48:51 -07:00
Sameh Khamis 691f27387b Add the changelog entry for the GNSS source window 2026-10-31 16:41:46 -07:00
Said Turksever cb6fd7568a test(draw): pin the shoelace area in the km² and ha rounding cases 2026-10-31 23:18:42 +00:00
Said Turksever 84028f71fe Merge branch 'main' into fix/e-draw-area-unit 2026-10-31 23:18:42 +00:00
Sameh Khamis e1bbbcec5a Let a GNSS integrity source set the layer's evidence window
The catalog now passes the gnss-integrity source's windowMs to the layer.
A missing, non-finite or non-positive value keeps the 30-minute default.
The row text now names the window the layer uses instead of a fixed 30 min.
2026-10-31 14:33:48 -07:00
Said Turksever a725178e0f Merge pull request #1472 from bilawalsidhu/authoring-panel
Rename the Scenes panel to Authoring and lay it out in steps
2026-10-30 17:00:57 +00:00
Said Turksever 3119b2cf9c Merge branch 'main' into authoring-panel 2026-10-30 16:40:50 +00:00
Said Turksever bb6f501261 Merge pull request #1466 from asturksever/panel-chrome-consistency
Give the side panels one title style, hover and toggle inset
2026-10-30 16:30:58 +00:00
Said Turksever b442cfdb54 Merge branch 'main' into panel-chrome-consistency 2026-10-30 16:09:12 +00:00
Said Turksever ab1239d3ed Cap rail title size and keep compact type to launchers
The rail titles took the dock's rem type inside fixed 176px launchers, so a
larger browser font cut collapsed titles such as Recent Imagery short. Cap
the rail type at its 16px-root size.

The compact type for translated titles now skips Chinese and Japanese,
which fit at full size and keep their 9px floor, and reaches only the rail's
own launchers, so Radio inside Context keeps its type.

Tests now check the launcher divider, gap, toggle margin and flex-shrink
rules by their declarations, not only their selectors.
2026-10-30 15:26:21 +00:00
Said Turksever a16eb07618 Merge pull request #1078 from jeffblakely/fix/firms-ipv4-fallback
fix(firms): retry over IPv4 when every address fails to connect
2026-10-30 15:20:44 +00:00
Said Turksever 3a681accfa Merge branch 'main' into fix/firms-ipv4-fallback 2026-10-30 15:08:35 +00:00
Said Turksever a79d8a1d20 Merge pull request #956 from jibraaan/feat/cctv-a22-autobrennero
feat(cctv): add the Autostrada del Brennero (A22) motorway webcams
2026-10-30 15:07:20 +00:00
Said Turksever 47f3377d2b Merge branch 'main' into panel-chrome-consistency 2026-10-30 15:00:57 +00:00
Said Turksever d6c60f94b6 fix(cctv): pin A22 frame redirects to WebCamImg/ and refresh on the highway cadence
- Add A22_IMAGE_ORIGIN to the pinned frame directories, so a same-host
  redirect from www.autobrennero.it/WebCamImg/ cannot leave that directory,
  as for the other directory-pinned packs.
- Give Autostrada del Brennero a 3-minute ambient refresh in cctvLod.js,
  like the other highway packs, instead of the 5-minute default.
- DATA_SOURCES: the Open Data Hub now tags every A22 record CC0; note that
  this is the aggregator's metadata, not Autobrennero's own terms.
- Tests for the redirect pin and the refresh cadence.
2026-10-30 14:50:53 +00:00
Said Turksever 38302a27a4 Merge upstream/main into feat/cctv-a22-autobrennero
Resolve conflicts with the NYC, Toronto, Lithuania, Florida, Taiwan and
Kartverket work on main. The CHANGELOG entry moves to
changelog.d/956-a22-brennero-webcams.md, as main now asks.
2026-10-30 14:50:41 +00:00
Said Turksever df01964171 fix(firms): keep the IPv4 retry from crashing the server; guard the call sites
- fetchIpv4 builds its Response inside the https response callback, outside
  the promise. A 204, 205 or 304, a status above 599 or an invalid reason
  phrase made the Response constructor throw there, an uncaught exception
  that stops the dev server. Null-body statuses now get a null body and
  anything else that cannot become a Response rejects, as the Radio Browser
  transport does.
- A non-OK FIRMS or mapkey-status response cancels its body so the IPv4
  socket closes at once instead of at the deadline.
- Tests: the crash cases (fail on the old code), a source check that every
  upstream request goes through fetchFirms and that fetchSource's body read
  shares its deadline (fails if either call reverts to fetch), a real-fetch
  contract test for the address-selection error shape, and an explicit skip
  in the IPv4-only socket test where the host cannot tell the families apart.
- Changelog entry moves to changelog.d/1078-firms-ipv4-fallback.md per
  CONTRIBUTING; CURRENT-STATE says which timeouts are retried and that the
  retry does not follow redirects.
2026-10-30 14:50:27 +00:00
Said Turksever bae3433b24 Merge upstream/main into fix/firms-ipv4-fallback
Resolve fetchSource onto main's shared deadline: one AbortSignal covers the
fetchFirms request and the capped body read. The CHANGELOG entry moves to a
changelog.d fragment in the next commit.
2026-10-30 14:50:27 +00:00
Said Turksever 44f9138d1e Keep panel titles inside their cards and scope the hover
Collapsed Recent Imagery (English) and Data Layers (Portuguese) pushed
their toggles out of the card. Collapsed rail launchers now drop the
divider and pack closer, translated collapsed titles use compact type,
and any title that still does not fit ends in an ellipsis.

The hover now matches the dock trays' hover rather than their pinned
look, only on pointers that can hover, and recolours only the hovered
panel's own header, so Radio and body labels keep their colours.

The cyber theme keeps main's gutters and Display toggle size, the dock
and rails share --dock-label-* tokens, and the dimmed open Context title
rule is gone.
2026-10-30 13:05:33 +00:00
Sameh Khamis 423ca3ca5f Merge pull request #1485 from bilawalsidhu/sameh/cctv-view-lod
perf(cctv): draw only the cameras in view, thinned when zoomed out
2026-10-29 21:42:23 -07:00
Sameh Khamis 2640f6378c Add the changelog entry for the CCTV view thinning 2026-10-29 21:32:45 -07:00
Sameh Khamis 93942d0dbe Show only the CCTV cameras in view, thinned to one per ground cell when zoomed out
The CCTV layer kept every loaded camera live: each view change touched every
icon, every notification rebuilt every camera's state, and the picker listed
the whole catalog. With the default 4,000 cameras, a continental drag ran at
152-179 fps with 18-24 frames over 30 ms (worst 112 ms). It now runs at
235 fps with none. The layer now:

- shows icons only on or near the screen, and when zoomed out keeps one camera
  per world-anchored cell (healthy feeds first), so panning keeps the same
  cameras;
- grounds and draws coverage only for the cameras in view, the active camera
  and its coverage neighbours, retrying a camera at most twice per view regime;
- reruns the view pass while the camera moves, paced by its own cost, and
  projects with one matrix per pass instead of a Cesium call per camera;
- keeps the billboard buffer-usage hint fixed, so showing or hiding a few
  icons no longer rewrites the vertex data of every billboard;
- lists the cameras in view (active camera first) in the picker, closing with
  the count of cameras outside the view, and builds the full camera list in
  the UI state only when it is read.
2026-10-29 21:31:59 -07:00
Said Turksever a4820315d9 Translate Authoring for the six new languages and fit long words
Adds the Authoring labels and tooltips to the de, fr, it, ja, pt and uk
catalogs that arrived from main, and prunes the retired SCENES-era
entries there. The file row's tighter padding and letter spacing keep
one long translated word per line in the narrow Cyber panel
(IMPORTIEREN, ESECUZIONE, ІМПОРТУВАТИ, EJECUCIÓN).
2026-10-29 18:47:56 +00:00
Said Turksever 2b05461232 Merge remote-tracking branch 'origin/main' into authoring-panel
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-29 18:40:02 +00:00
Said Turksever 5a80facf8d Address review: playable PLAY/NEXT, visible rename editor, accurate copy
- PLAY and NEXT continue into the following scenes, so they stay
  enabled while any scene in the project has a shot, not only the
  selected one.
- Renaming a shot drops the number and ellipsis so the editor is no
  longer hidden; qa-scene-controls checks a long rename stays visible.
- PLAY, NEXT, EDIT DETAILS and SHARE SCENE tooltips describe what the
  buttons do, translated in every shipped language.
- The primary actions and the file row keep a hover state.
- Docs: the EXPORT rename in DIRECTOR-SHARING and the playback button
  states in CURRENT-STATE are accurate.
- Test the voice "authoring" alias through set_panel_open.
2026-10-29 18:39:53 +00:00
Said Turksever 1252dacf07 Merge branch 'main' into panel-chrome-consistency 2026-10-29 17:35:23 +00:00
Said Turksever f13c60c477 Merge pull request #1471 from asturksever/ux/context-panel-cleanup
Drop the Radio icon from Context and make the panel easier to read
2026-10-29 16:57:38 +00:00
Said Turksever 16c888806a Prune the removed Radio strings from the catalogs main added
main gained de, fr, it, ja, pt and uk catalogs that still carried the 12
strings this change retires, so their catalog-matches-code tests failed
once the branch met main.
2026-10-29 16:44:46 +00:00
Said Turksever a370742e25 Merge remote-tracking branch 'origin/main' into ux/context-panel-cleanup 2026-10-29 16:42:17 +00:00
Said Turksever 23a0cc0eb7 Merge pull request #1467 from asturksever/fix/remove-operator-hud-layout
Remove the Operator HUD layout
2026-10-29 16:41:30 +00:00
Said Turksever 9520238bfb Move the changelog entry to changelog.d and clear review leftovers
The entry now lives in changelog.d/1471-context-panel-cleanup.md with
credit, as CONTRIBUTING asks, and CHANGELOG.md is back to main.

CURRENT-STATE no longer lists a compact Radio power control and scopes
the readability description to what Cyber keeps. The responsive glow
comment no longer cites the removed Radio popover, and qa-radio's
pick-buffer comment is back above settleRadioFrames.

Four test fixtures stop stubbing the removed launcher sync, and
PanelChrome drops the _syncRailPanelCollapseButton pass-through, wiring
the layout controller straight to _syncPanelCollapseButton.
2026-10-29 16:35:44 +00:00
Said Turksever a8127c968b Drop the Operator string from the Japanese catalog
The catalog merged from main still carried the retired layout's label, so the catalog test failed. The Japanese glossary drops it too.
2026-10-29 16:29:42 +00:00
Said Turksever c227f89852 Merge branch 'main' into fix/remove-operator-hud-layout 2026-10-29 16:23:27 +00:00
German KhokhlovandEnzo Abyie bcabdff88d Merge pull request #1478 from bilawalsidhu/german/hal-642-ja
Japanese in the language picker: the full catalog, counters, unit words joined
to their numbers (5分前, 1時間5分), lower-case HUD units as in Chinese, and the
CJK label rules plus strict line breaking for Japanese. Based on translations
by @abiyeenzo (#913).

Co-authored-by: Enzo Abyie <[email protected]>
2026-10-29 19:58:17 +04:00
Said Turksever 151e7ad5fd Drop the Operator string from the German and French catalogs
The catalogs merged from main still carried the retired layout's label, so the catalog tests failed.
2026-10-29 15:54:40 +00:00
German Khokhlov 29692ac7a1 changelog: Japanese (#1478) 2026-10-29 19:48:33 +04:00
German Khokhlov 32d6ce4ea5 i18n(ja): translate strings new in main, drop Snow 2026-10-29 19:47:54 +04:00
German Khokhlov 812308d0ee Merge remote-tracking branch 'origin/main' into german/hal-642-ja
# Conflicts:
#	docs/CURRENT-STATE.md
#	docs/TRANSLATORS.md
#	scripts/package-boundaries.json
#	src/i18n/i18n.test.mjs
#	src/i18n/index.js
#	src/i18n/locale.js
#	src/i18n/same-as-english.json
#	src/layers/cctv/presentation.js
#	src/ui/localePicker.test.mjs
2026-10-29 19:47:54 +04:00
Said Turksever d0059575d4 Merge branch 'main' into fix/remove-operator-hud-layout 2026-10-29 15:45:26 +00:00
German KhokhlovandEnzo Abyie 32ca07b70a Merge pull request #1477 from bilawalsidhu/german/hal-639-de
German in the language picker: the full catalog, neutral infinitive wording,
one/other plurals and the de style guide. Directions keep German nouns
capitalized, and layer names break at word hyphens but not inside codes.
Based on translations by @abiyeenzo (#908).

Co-authored-by: Enzo Abyie <[email protected]>
2026-10-29 19:42:40 +04:00
Said Turksever 8348402a56 Merge branch 'main' into fix/remove-operator-hud-layout 2026-10-29 15:39:45 +00:00
German Khokhlov 56ca340258 i18n: keep Upper Trishuli as a place name; drop duplicate cyclone keys 2026-10-29 19:36:02 +04:00
German Khokhlov 6ab9432a2a changelog: German (#1477) 2026-10-29 19:01:00 +04:00
German Khokhlov aaadf708ba i18n: Upper Trishuli in fr, it, pt and uk; drop Snow from de 2026-10-29 19:00:19 +04:00
German Khokhlov a092933ad6 Merge remote-tracking branch 'origin/main' into german/hal-639-de
# Conflicts:
#	docs/CURRENT-STATE.md
#	docs/TRANSLATORS.md
#	src/i18n/i18n.test.mjs
#	src/i18n/index.js
#	src/i18n/locale.js
#	src/layers/cyclones/source.js
#	src/scenes/recipes.js
#	src/scenes/sceneTitleDisplay.test.mjs
#	src/ui/localePicker.test.mjs
2026-10-29 19:00:19 +04:00
aa2e5ba181 Merge pull request #1476 from bilawalsidhu/german/hal-638-fr
French in the language picker: the full catalog, French typography, one/many/
other plurals and the fr style guide. Voice plan steps join through a catalog
string so French spaces its colon, and with nine languages the picker shows
its search field. Based on the translation by @abiyeenzo (#907), checked
against @meirm's French catalog (#312).

Co-authored-by: Enzo Abyie <[email protected]>
Co-authored-by: Meir Michanie <[email protected]>
2026-10-29 18:56:49 +04:00
German Khokhlov 48a2cc0690 i18n(fr): drop the Snow strings removed in main 2026-10-29 18:36:29 +04:00
Said Turksever ce47af6f0d Restore the Snow retirement in tests the main merge overwrote
The merge from main kept this branch's side of four conflicts, which dropped the Snow PR's changes: tests expected Snow again, the share-link table lost its retired mark, and the voice pins lost the Snow restore. Keep both retirements, and re-derive the full-payload digest for both enum removals.
2026-10-29 14:33:17 +00:00
German Khokhlov b84f7f346c Merge branch 'main' into german/hal-638-fr 2026-10-29 18:26:36 +04:00
Said Turksever adb21d098d Merge branch 'main' into fix/remove-operator-hud-layout 2026-10-29 14:22:01 +00:00
Said Turksever 5fdae282cd Merge pull request #1468 from bilawalsidhu/fix/remove-snow-preset
Remove the Snow visual preset
2026-10-29 14:18:31 +00:00
Said Turksever aa0f6d4e52 Remove the HUD top bar that no layout shows
Operator was the only layout that displayed the top banner bar. Its markup, styles and keep-out selectors are removed, and the Cyber style test now checks that none remain.
2026-10-29 14:04:28 +00:00
Said Turksever 607cd7708c Drop the Operator string from the Italian, Portuguese and Ukrainian catalogs
The catalogs merged from main still carried the retired layout's label, so the catalog tests failed. The Italian glossary drops it too.
2026-10-29 14:04:28 +00:00
Said Turksever 32125384fe Drop Snow from the new catalogs and migrate saved Snow shots
The it, pt and uk catalogs merged from main still carried the two Snow
button strings, which the catalog tests report as stale. They are
pruned with npm run i18n -- --prune.

A Scene Director shot saved with Snow now normalizes to Normal when the
project loads, so the shot list shows NORMAL rather than an untranslated
SNOW for a shot that plays on Normal. The README's style keys read 1-6.
2026-10-29 14:04:11 +00:00
Said Turksever 3bcdc16dde Merge branch 'main' into fix/remove-operator-hud-layout 2026-10-29 13:55:54 +00:00
German KhokhlovandClaude Opus 5.5 e44d15cd34 changelog: French (#1476)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:55:31 +04:00
German KhokhlovandClaude Opus 5.5 f9af9e758c i18n(fr): translate strings new in main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:55:01 +04:00
German Khokhlov 727825b576 Merge remote-tracking branch 'origin/main' into german/hal-638-fr
# Conflicts:
#	docs/CURRENT-STATE.md
#	docs/TRANSLATORS.md
#	scripts/package-boundaries.json
#	src/i18n/i18n.test.mjs
#	src/i18n/index.js
#	src/i18n/locale.js
#	src/i18n/same-as-english.json
#	src/scenes/recipes.js
#	src/ui/localePicker.test.mjs
#	src/ui/templates/context.html
2026-10-29 17:55:01 +04:00
Said Turksever 1990228889 Merge branch 'main' into fix/remove-snow-preset 2026-10-29 13:53:35 +00:00
German KhokhlovandEnzo Abyie 613099a34e Merge pull request #1475 from bilawalsidhu/german/hal-640-pt
Brazilian Portuguese in the language picker: 2732 strings, Portuguese plural
forms and the pt style guide. The CCTV summary line names its calibration,
source and view in the page language, markup can give text a context with
data-i18n-context, and the cyclone source errors are translated. Based on
translations by @abiyeenzo (#909).

Co-authored-by: Enzo Abyie <[email protected]>
2026-10-29 17:51:05 +04:00
German Khokhlov 4ed06fac22 Merge branch 'main' into fix/remove-operator-hud-layout 2026-10-29 17:45:40 +04:00
German Khokhlov 4877be7f9b Merge branch 'main' into fix/remove-snow-preset 2026-10-29 17:45:25 +04:00
German KhokhlovandClaude Opus 5.5 01a097caae changelog: Brazilian Portuguese (#1475)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:42:57 +04:00
German KhokhlovandClaude Opus 5.5 b0f0837c10 i18n(it, uk): translate the cyclone source errors
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:42:05 +04:00
German Khokhlov 95ded4c6b9 Merge remote-tracking branch 'origin/main' into german/hal-640-pt
# Conflicts:
#	docs/CURRENT-STATE.md
#	docs/TRANSLATORS.md
#	scripts/package-boundaries.json
#	src/i18n/i18n.test.mjs
#	src/i18n/index.js
#	src/i18n/locale.js
#	src/i18n/same-as-english.json
#	src/scenes/recipes.js
#	src/scenes/sceneTitleDisplay.test.mjs
#	src/ui/localePicker.test.mjs
2026-10-29 17:42:05 +04:00
German KhokhlovandEnzo Abyie 15da935b8f Merge pull request #1473 from bilawalsidhu/german/hal-641-it
Italian in the language picker: 2730 strings, Italian plural forms, labels
shortened to fit the panels, and the it style guide. Based on the translation
by @abiyeenzo (#912).

Co-authored-by: Enzo Abyie <[email protected]>
2026-10-29 17:38:34 +04:00
German KhokhlovandClaude Opus 5.5 cf1ef368d0 changelog: Italian (#1473)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:06:00 +04:00
German Khokhlov 59f06c9918 Merge remote-tracking branch 'origin/main' into german/hal-641-it
# Conflicts:
#	docs/CURRENT-STATE.md
#	docs/TRANSLATORS.md
#	src/i18n/index.js
#	src/i18n/locale.js
#	src/ui/localePicker.test.mjs
2026-10-29 17:05:28 +04:00
c434664549 Merge pull request #1470 from bilawalsidhu/german/hal-645-uk
Ukrainian in the language picker: 2730 strings, four plural forms, Cyrillic
HUD units and the uk style guide. Based on the Ukrainian translation by
@meirm (#312).

Co-authored-by: Meir Michanie <[email protected]>
Co-authored-by: Meir Michanie <[email protected]>
2026-10-29 16:59:48 +04:00
Said Turksever 97b7307b14 Fix the format gate and the Cyber Context styling, and pin the redesign
CI failed at format:check: the format scope still listed the deleted
panelChrome.radioOwnership test, and panelRails.test.mjs needed Prettier
after its glow list shrank.

The new Context title colour outranked Cyber's red panel titles, and the
collapsed Context launcher came out brighter than its siblings; the rule
now applies only to the expanded panel outside Cyber. Cyber's layout-focus
standby card had relied on the old dashed border and centring, so it
drew a 3px frame with its text off the radar art; it now names its own
1px frame, padding and centring. Cyber also drops the shared segmented
track, which drew a third frame around its already framed mode buttons.

Tests now pin the Context header to its title and collapse control, the
segmented control, 10px type, the missing halo and the scoped title
colour, plus the Cyber standby and track overrides. Each fails on the
previous CSS or markup.

qa-radio enables Radio and starts the first Play from the visible
controls, expanding Context and Radio the way a person must, then puts
the panels back. The Escape test and its mutation describe what they
still protect. CURRENT-STATE no longer describes the removed Radio icon
or compact player.
2026-10-29 12:55:47 +00:00
Said Turksever 8de75b172a Keep two shot rows visible in Authoring on shorter windows
The step headings make the panel taller, so the left stack squeezed the
shot list down to one row at about 1000px of window height. Its minimum
now holds two rows; the panel scrolls when space is short.
2026-10-29 12:29:11 +00:00
Said Turksever 4192a6f87d Keep Authoring within its stack and theme, and cover its wiring
- Keep the documented 72px / flex-grow 1 action sizing and the 360px
  panel width; the larger type applies to the Authoring panel only, so
  CCTV and Street Level buttons are unchanged.
- Scope the primary and file-row styles to the panel so Cyber's shared
  button surface no longer overrides them.
- Shorten the scene PLAY in Spanish and Russian so it does not break
  mid-word, and fix two translation inconsistencies.
- Map the spoken name "authoring" to the panel.
- Clear the has-shots marker when the controls are destroyed.
- Test the idle button states through published changes, the PLAY
  translation context, and where the sharing row is placed.
2026-10-29 11:48:52 +00:00
Said Turksever 3b6111be06 Open views naming the retired Snow style on Normal
createView threw on style 'snow', so an embed host or a
show_in_gods_eye_view call holding a view from before Snow was retired
was refused outright, camera and layers included. viewFromParams read
style=snow as the recipient's own style, while the app opened the same
link on Normal.

Both now read a retired style as Normal; truly unknown names still
throw. The retired list moves to a browser-free module shared by the
view API, the share codec and visual settings.
2026-10-29 11:34:19 +00:00
Said Turksever 452292001a Add the changelog fragment for the Authoring panel 2026-10-29 11:32:27 +00:00
Said Turksever 2b1129c10e Rename the Scenes panel to Authoring and lay it out in steps
The panel reads AUTHORING and groups its controls under SCENE, SHOTS,
PREVIEW and SAVE & SHARE headings, with the EDIT DETAILS / SHARE SCENE
row moved beside EXPORT / IMPORT. Text is larger with more contrast,
shots are numbered, every action has a tooltip and the status line is a
live region. Outside playback, buttons that would do nothing are
disabled. New labels are translated in every shipped language; PLAY
uses its own catalog context apart from the radio's PLAY.
2026-10-29 11:31:53 +00:00
Said Turksever e12614a13a Pin the retired Operator code and test the live HUD fallback
qa-radio cycles Minimal, off, on, then Tactical, so the HUD toggle must
restore a non-default layout, and expects that sequence instead of the
removed Operator step.

The share-link test pins the exact hudVariant codes, so dropping or
moving operator's reserved code fails it. A live IntelHUD test checks
that a retired Operator layout falls back to Tactical, and the Cyber
test stub themes from the normalized value.

The share-link table marks operator as retired, and the zh-CN glossary
drops the removed string.
2026-10-29 11:29:20 +00:00
Said Turksever 66e19f0c50 Drop the Radio icon from Context and make the panel easier to read
The Context header carried a Radio icon that opened a second, compact
Radio player on top of the panel, duplicating the Radio section that
already lives inside Context. Remove the icon, the compact player and
everything that kept the two in step. Radio stays in Context as its own
section with one Enable control; Cockpit's compact Radio is unchanged.

Context itself was hard to read: 7-8 px labels at low contrast, a dashed
placeholder box, and a glow halo. The two modes are now one segmented
control, labels and guidance are 10 px at higher contrast, the standby
hint is plain text, section titles use --text-secondary, and the panel
draws no glow.

Unused translations are pruned, tests and QA harnesses that pinned the
compact player are updated or retired, and CURRENT-STATE and the
changelog describe the new header.
2026-10-29 11:28:56 +00:00
German KhokhlovandClaude Opus 5.5 1abe8cbb51 changelog: Ukrainian (#1470)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:22:15 +04:00
Said Turksever 2b5770c6a2 Add the changelog entry for the Snow removal 2026-10-29 11:21:37 +00:00
Said Turksever 39a60b4034 Remove the Snow visual preset
Snow is no longer offered anywhere a style can be chosen: the Visual
Presets tray, keyboard shortcut 7, the Cockpit vision carousel, and the
set_visual_style voice action. The shader and its labels go with it;
weather snowfall and the SNOW weather condition are unaffected.

Share links keep working. Snow's enum code (6) stays reserved, so a
compact or long-form link that names it opens on Normal and drops the
Snow shader parameters without reporting them as unknown. A Scene
Director shot saved with Snow plays on Normal too. Snow's frozen share
defaults matched Normal's apart from those parameters, so no other
setting in an old link changes meaning.

The legacy voice-schema pins restore Snow before hashing, so the
unchanged pins still prove nothing else moved; the complete Realtime
payload digest is re-derived.
2026-10-29 11:20:49 +00:00
German KhokhlovandClaude Opus 5.5 f8e96e1484 i18n(it): address code review findings
Localize a bootstrap shot title without a source pack only when the
Nepal pack marker binds that title to the shot's id, so an operator
shot renamed to a bootstrap title in the built-in scene stays as typed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:54:44 +04:00
German KhokhlovandClaude Opus 5.5 0e45bcdf14 i18n(de): address code review findings
Localize a legacy Nepal base shot only when the applied pack marker binds
that title to this exact shot id, so a user shot renamed to a built-in
title stays as typed in every locale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:52:45 +04:00
German KhokhlovandClaude Opus 5.5 eab345a465 i18n(pt): address code review findings
A pack marker with shotBindings now vouches only for the shot bound to a
legacy bootstrap title, so a user shot typed with the same title keeps its
text instead of showing the localized built-in name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:50:08 +04:00
German KhokhlovandClaude Opus 5.5 d034e7355d i18n(fr): address code review findings
Localize a pack shot title only for the shot the pack marker binds to that
title, so an operator's shot named like a pack shot shows as typed. Markers
saved without shotBindings keep the title-only check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:48:09 +04:00
German KhokhlovandClaude Opus 5.5 a1d9847976 i18n(ja): address code review findings
Japanese has no letter case, so the HUD summary keeps units as the
catalog writes them (13 km), matching the cockpit and the ja HUD CSS.
The ja '{value} KM', '{value} KM/H' and '{value} KM/S' strings are now
lower-case like zh-CN.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:47:58 +04:00
Said Turksever e693e665a6 Add the changelog entry for removing the Operator HUD layout 2026-10-29 10:42:38 +00:00
Said Turksever 8b2c699370 Remove the Operator HUD layout
Display > HUD > Layout and the set_hud voice action now offer Tactical,
Minimal and Cyber. The Operator option, its CSS and its QA coverage are
removed, and the unused "Operator" translations are pruned.

Share-link codes are append-only, so operator keeps its hudVariant code.
A link, scene or saved state that names it restores the default Tactical
layout. The voice schema pins keep their legacy digests by restoring the
retired value before comparing; the full-payload digest is re-derived.
2026-10-29 10:41:58 +00:00
Said Turksever 361f7de3bd Add the changelog entry for the panel chrome change 2026-10-29 10:40:43 +00:00
Said Turksever d2647f35ba Give the side panels one title style, hover and toggle inset
Every rail panel title uses the command dock's label type and colour,
collapsed or open, instead of three sizes at 30% opacity. Panels answer
hover as the dock trays do, with a blue edge, a soft glow and an accent
title. In the default theme each panel's title and collapse button sit
19px from its edge; Context's toggle no longer shrinks beside the radio
button. The cyber theme keeps its own colours and insets.
2026-10-29 10:40:14 +00:00
German KhokhlovandClaude Opus 5.5 d383f25bb3 i18n(it): translate strings new in main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:31:56 +04:00
German KhokhlovandClaude Opus 5.5 e451b496ae Merge remote-tracking branch 'origin/main' into german/hal-641-it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:31:56 +04:00
German KhokhlovandClaude Opus 5.5 6c524b229c i18n(pt): translate strings new in main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:24:50 +04:00
German KhokhlovandClaude Opus 5.5 eab9632738 i18n(ja): translate strings new in main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:23:04 +04:00
German KhokhlovandClaude Opus 5.5 eb76d5265b i18n(uk): translate strings new in main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:19:24 +04:00
German KhokhlovandClaude Opus 5.5 ec3932718e Merge remote-tracking branch 'origin/main' into german/hal-645-uk
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:19:01 +04:00
German KhokhlovandClaude Opus 5.5 ed26fa12d8 i18n(fr): translate strings new in main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:18:40 +04:00
Said Turksever aaa3ff46cf Merge pull request #871 from waar19/perf/vessels-idle-render
perf(vessels): let the scene idle with AIS vessels enabled
2026-10-29 10:14:01 +00:00
German KhokhlovandClaude Opus 5.5 a0c8b6f893 i18n(de): translate strings new in main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:12:10 +04:00
German KhokhlovandClaude Opus 5.5 2f12cbf5c8 Merge remote-tracking branch 'origin/main' into german/hal-639-de
Conflicts in es, ru, zh-CN and zh-Hant catalogs: both sides appended keys
at the end (cyclone errors from this branch, routing refusals from main);
kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:12:08 +04:00
German KhokhlovandClaude Opus 5.5 494c2b6029 Merge remote-tracking branch 'origin/main' into german/hal-640-pt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:11:56 +04:00
German KhokhlovandClaude Opus 5.5 c493eb0126 Merge remote-tracking branch 'origin/main' into german/hal-642-ja
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:11:56 +04:00
German KhokhlovandClaude Opus 5.5 a60ba928cc Merge remote-tracking branch 'origin/main' into german/hal-638-fr
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:11:35 +04:00
Said Turksever 53ca307bb6 Merge branch 'main' into perf/vessels-idle-render 2026-10-29 09:53:34 +00:00
Sameh Khamis 03201451c0 Merge pull request #1463 from bilawalsidhu/sameh/security-audit-fixes
chore(deps): update Vite and Puppeteer, and remove the unused screenshot helper
2026-10-28 22:44:07 -07:00
Sameh Khamis 391dba74eb Add the changelog entry 2026-10-28 22:36:18 -07:00
Sameh Khamis f69e9bba8b Update Vite and Puppeteer, and remove the unused screenshot helper 2026-10-28 22:35:08 -07:00
quanticsoul4772andClaude Opus 5.5 da2a3c3379 fix(sky): read the Milky Way faces by file path in the paint-out test
The test passed URL.pathname to sharp, which is /D:/... on Windows, so the
faces were not found and npm test failed on every Windows clone.
fileURLToPath gives a path that works on every platform.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:08:10 -07:00
Said Turksever 1c0a30adaf Merge branch 'main' into perf/vessels-idle-render 2026-10-29 01:18:39 +00:00
Sameh Khamis f2c6ff082d Merge pull request #1459 from bilawalsidhu/sameh/hud-google-lookups
Cut the Google requests the HUD summary makes on its own
2026-10-28 17:43:25 -07:00
Sameh Khamis 7914370b14 Merge branch 'main' into sameh/hud-google-lookups 2026-10-28 17:37:20 -07:00
Sameh Khamis d1e0ff4e06 Name the HUD lookups changelog entry after its pull request 2026-10-28 17:09:50 -07:00
Said Turksever 2bcdca1c9a Merge pull request #1405 from quanticsoul4772/upstream/traffic-layer-stale
fix(traffic): flag cached TomTom flow tiles as stale in the layer
2026-10-28 22:18:07 +00:00
Said Turksever 6847bc1433 Merge branch 'main' into upstream/traffic-layer-stale 2026-10-28 22:07:37 +00:00
Sameh Khamis c1f60cb7e7 Cut the Google requests the HUD summary makes on its own
The HUD builds place context for its AI summary whenever the camera
settles, and each build paid for up to four reverse geocodes plus a Nearby
Places search, even when no OpenAI key was configured to use them.

- Ask GET /api/openai/hud-summary/status once; with no summary configured,
  or after an unconfigured reply, make no place lookups for the page.
- Reverse-geocode at most two points: the view center and one viewport
  point.
- Reuse a lookup until the view center moves a quarter of the visible
  width, the camera's distance to it doubles or halves, or the camera
  crosses a lookup height. The reused points are asked again, so only a
  timeout, failure or empty answer spends.
- Repaint the last AI reply when a small pan leaves the context unchanged.

Cached-only hosts and voice context lookups are unchanged.
2026-10-28 14:59:33 -07:00
German KhokhlovandClaude Opus 5.5 8da577dab7 i18n(ja): apply review round 4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 00:04:33 +04:00
German KhokhlovandClaude Opus 5.5 d01be22b44 i18n(de): apply review round 5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:59:22 +04:00
German KhokhlovandClaude Opus 5.5 a312414d0b i18n(fr): apply review round 5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:59:04 +04:00
German KhokhlovandClaude Opus 5.5 d243177d11 i18n(uk): apply review round 4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:55:16 +04:00
German KhokhlovandClaude Opus 5.5 a96ad30c99 i18n(pt): apply review round 4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:47:45 +04:00
German KhokhlovandClaude Opus 5.5 423452095c i18n(de): apply review round 4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:46:55 +04:00
German KhokhlovandClaude Opus 5.5 3d32a0c45c i18n(fr): apply review round 4
Apply the accepted findings from the fourth three-model French review:
« Fermez d'abord cet outil » in the pointer-busy notices, the colon form
« Outil actif : {name} » for the directions notice, « FOV CONSTRUCTEUR »,
« RAPPORT » for the USGS trigger chip, « CONTEXTE GLOBAL DÉSACTIVÉ », and
wording fixes for the radio, recent imagery, bundle preview and draw label
strings. The guide gains glossary rows for the datasheet FOV and for
rapport versus relevé.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:43:23 +04:00
German KhokhlovandClaude Opus 5.5 4c80c38e2c i18n(it): apply review round 4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:43:22 +04:00
German KhokhlovandClaude Opus 5.5 226600a67a i18n(uk): apply review round 3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:13:44 +04:00
German KhokhlovandClaude Opus 5.5 7e55ee45fe i18n(fr): apply review round 3
Apply the accepted findings from the third three-model French review:
tool-name agreement (« Outil actif : {name} »), elision before ADS-B,
« GORGE AVAL », « CLASSIQUES », « EN SURVOL », one name for the VIIRS
daily overview, U+202F thousands separator, and wording fixes for the
scene editor, directions, radio tuner and status strings.

The CCTV HUD summary now shows its CAL badge, source kind and view band
through existing catalog entries, so French reads « CAL ESTIM. INITIALE ·
SRC AMONT · CONTEXTE RUE »; English output and unknown codes are
unchanged. presentation.js matches the pt branch's round-3 version byte
for byte, so the two branches merge cleanly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:52:51 +04:00
German KhokhlovandClaude Opus 5.5 6b3876b176 i18n(ja): apply review round 3
Catalog fixes from the third review round: booster flight count
({count}回目の飛行), 晴れ for PARTLY CLOUDY, 配信元 for upstream,
部分的 for the PARTIAL feed state, または between provider names,
POS kept as a HUD code, product names in their own case (YouTube,
Facebook, Radio Browser, ChatGPT OAuth, Googleストリートビュー), Esc,
bracketed button names, en-dash ranges, 土砂ダム湖 for both lake
labels and smaller wording fixes. Guide: range rule and new glossary
rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:48:00 +04:00
German KhokhlovandClaude Opus 5.5 bceeb37836 i18n(de): apply review round 3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:44:25 +04:00
German KhokhlovandClaude Opus 5.5 4048535921 i18n(pt): apply review round 3
Three-model review (astra, sol, fable), round 3: 17 of 18 findings
applied (some in own wording, one in part); "Missões espac." rejected.

- pt: layer cards «{label}: camada ativada/desativada», pointer and
  Directions toasts name «A ferramenta "{owner}"», counters after a
  colon where the count can be 1, traffic «sem dados de fluxo» and
  «Fluxo: {percent}% cob», «limite de requisições atingido», FECHAR,
  ANT., PREDEF. VISUAIS, quoted "Nível da rua", infinitive tooltips,
  and more (see docs/translations/pt.md).
- CCTV summary line localizes its CAL, SRC and CONTEXT values through
  existing catalog keys (all locales); codes stay underneath.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:40:38 +04:00
German KhokhlovandClaude Opus 5.5 beacb6d9d3 i18n(uk): apply review round 2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:38:32 +04:00
German KhokhlovandClaude Opus 5.5 6cf47c6ca8 i18n(it): apply review round 3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:35:44 +04:00
German KhokhlovandClaude Opus 5.5 cd1f573647 i18n(ja): apply review round 2
Catalog fixes from the second review round (squawk 7600/7700 names,
transit occupancy, radio category, cyclone advisory wording, Orbital Watch,
validation and hint texts), cockpit distances keep "km" in Japanese as in
Chinese, the CCTV context line uses the translated view band, and the
Japanese layer status line no longer splits "1分以内".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:06:29 +04:00
Said Turksever e608fee793 Merge pull request #1344 from nu-stats/pr/pause-hidden-polling
fix(embed): keep inline panels polling when their host reports them hidden; pause the HUD readout in hidden tabs
2026-10-28 17:00:32 +00:00
quanticsoul4772 9d75376307 Merge branch 'main' into upstream/traffic-layer-stale 2026-10-28 09:41:49 -07:00
Said Turksever 437f95c1ee Merge branch 'main' into pr/pause-hidden-polling 2026-10-28 16:35:38 +00:00
Said Turksever 3a6f27620f Merge pull request #1402 from quanticsoul4772/upstream/terrain-429-pause
fix(terrain): pause upstream calls after a 429
2026-10-28 16:24:03 +00:00
Said Turksever 404a86d551 Merge branch 'main' into upstream/terrain-429-pause 2026-10-28 16:11:51 +00:00
Said Turksever 6d16b479d7 Merge pull request #1401 from quanticsoul4772/upstream/geocode-place-region
fix(geocode): match place-and-region queries by location
2026-10-28 16:11:13 +00:00
quanticsoul4772 3e5edff884 Merge branch 'main' into upstream/terrain-429-pause 2026-10-28 09:05:56 -07:00
quanticsoul4772 065314c07c Merge branch 'main' into upstream/traffic-layer-stale 2026-10-28 09:05:08 -07:00
quanticsoul4772 b038f43739 Merge branch 'main' into upstream/geocode-place-region 2026-10-28 09:04:22 -07:00
Said Turksever 05f505b7e7 Merge branch 'main' into pr/pause-hidden-polling 2026-10-28 15:50:49 +00:00
Said Turksever 55dc94ab60 Merge pull request #1374 from oneill9/codex/fix-seek-layer-state
fix(director): restore layer visibility on same-shot seeks
2026-10-28 15:50:09 +00:00
Said Turksever db2744ca3b Merge remote-tracking branch 'upstream/main' into pr1405-fix 2026-10-28 15:39:16 +00:00
Said Turksever f5e65fbf9e Merge remote-tracking branch 'upstream/main' into pr871-fix 2026-10-28 15:32:26 +00:00
Said Turksever dbd604dfda Merge branch 'pr871-new' into pr871-fix
# Conflicts:
#	CHANGELOG.md
2026-10-28 15:32:26 +00:00
Said Turksever c95418ead7 Merge branch 'main' into perf/vessels-idle-render 2026-10-28 15:31:37 +00:00
Said Turksever fec7ec07cb Merge branch 'main' into codex/fix-seek-layer-state 2026-10-28 15:31:24 +00:00
Said Turksever f7efbb038d Merge pull request #1404 from quanticsoul4772/upstream/perimeters-stale
fix(perimeters): flag stale WFIGS snapshots
2026-10-28 15:26:18 +00:00
Said Turksever 6fa4235305 Merge branch 'main' into upstream/perimeters-stale 2026-10-28 15:18:40 +00:00
Said Turksever 4bbe175ba3 Merge branch 'codex/fix-seek-layer-state' of https://github.com/oneill9/gods-eye-view into pr1374-fix 2026-10-28 15:18:26 +00:00
Said Turksever b6c9c48cf0 Merge branch 'main' into codex/fix-seek-layer-state 2026-10-28 15:18:02 +00:00
Said Turksever 4730f85fe6 Merge pull request #1209 from daikaginza/fix/i18n-layer-errors-in-data
fix(layers): keep Directions, Local ADS-B and cohort errors English in data
2026-10-28 15:16:26 +00:00
Said Turksever e6d2babea0 fix(vessels): pin the idle-render contract and move the entry to a fragment
- The trailing visibility frame uses the layer's injectable runtime timer and
  is cancelled by resetState as well as disable and destroy.
- An empty-feed regular pass records the camera pose, so later in-cadence
  frames do not keep scheduling trailing frames.
- New renderGovernor.test.mjs: a parked camera schedules nothing, a stopped
  camera gets exactly one trailing frame and no self-wake, a forced pass
  requests its frame, destroy cancels the timer.
- The CHANGELOG entry moves to changelog.d/871-vessels-idle-render.md; docs
  say the hold also covers vessels dimmed under a live target.
2026-10-28 15:15:57 +00:00
Said Turksever 87133e8dbf Merge remote-tracking branch 'upstream/main' into pr871-fix
# Conflicts:
#	CHANGELOG.md
2026-10-28 15:15:50 +00:00
Said Turksever f7c058b97d Merge branch 'main' into fix/i18n-layer-errors-in-data 2026-10-28 15:09:37 +00:00
Said Turksever 71574179a2 test(qa): skip the seek visibility check outside a Vite dev server
__gevQaRegisterLayer exists only in dev builds; against vite preview the
check threw and the remaining director checks never ran.
2026-10-28 14:35:01 +00:00
Said Turksever d57bc0af3c test(locator): give the Stop test double the seek-generation helper
stopScene now supersedes seeks through _bumpSceneSeekGeneration; the
bhoteKoshiLocator Stop test calls stopScene on a plain object, so it
needs the method too. Without it four tests throw at the PR head.
2026-10-28 14:30:45 +00:00
Said Turksever b731adea49 Merge remote-tracking branch 'upstream/main' into pr1374-fix 2026-10-28 14:28:38 +00:00
Said Turksever 3b57299231 fix(director): keep rejected loads from cancelling LOAD; roll back seek only on refusal
- Superseding a seek aborts only the seek's own layer reconcile, so a
  loadShot that is later rejected (shot-not-found) no longer cancels an
  in-flight LOAD. Stop still aborts any LOAD.
- A refused seek rolls back to each layer's effective intent, so a manual
  toggle still in flight is kept.
- A cancelled seek leaves layers to whatever superseded it, as a cancelled
  LOAD does; only refusal rolls back.
- Move the _seekLoadedShot JSDoc back onto its method.
2026-10-28 14:28:38 +00:00
German KhokhlovandClaude Opus 5.5 b521f6a1fa i18n(de): apply review round 2
- Meaning fixes: the radio link opens the broadcaster's website
  (SENDER-WEBSITE), a straight lane is «geradeaus führend», not the middle
  one, IDLE is INAKTIV (BEREIT is READY), the Gemini errors name the send
  queue, traffic percent is live coverage (Live-Abdeckung), the voice radius
  is measured from the Bildmitte, the AIS voice error names the Live-Schiffe
  layer, and the Bhote landslide is «Erdrutsch» throughout (not «Abbruch»).
- Number-neutral status lines for feed and layer lists, and a payload count
  line that also reads correctly for +1.
- Retry wording follows the glossary: «neue Versuche in größeren Abständen».
- Weather history lines are shorter and fit the collapsed row: «Frame fehlt;
  letzter Stand bleibt erhalten», «Kein Frame in den {gap} min bis {time}»
  (the gap only looks back from the target time).
- Shorter search placeholder «Ort suchen...», which was clipped.
- Smaller wording: Himmelsring, Lagebild-HUD, SCHLEPPVERBAND, Minimum Fuel
  (Treibstoff knapp), Bloom / Leuchten, DOWNRANGE GESCHÄTZT, Places-Kontext,
  MELDUNG, Rückflug zur Weltübersicht, REALE BILDDATEN and others.
- Glossary and routing notes updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:21:14 +04:00
Said Turksever e37e8453dd Merge pull request #1014 from deepeshbind/fix/scene-director-callback-errors
fix: guard scene director callbacks
2026-10-28 14:19:41 +00:00
Said Turksever 8cedd159e3 fix(layers): translate every route proxy refusal and clear stale receiver errorText
The route proxy also refuses with 'routing busy — too many routes at once'
(429) and 'route proxy error'; both showed in English in every locale.
Mark them in ROUTING_REFUSALS and add es, ru, zh-CN and zh-Hant entries.

errorText is now cleared on a new connection, a mode switch and stop(), so
it never outlives the error it describes. Tests cover the two refusals,
'Routing failed: rate limited' (pattern order) and the stop() reset.
2026-10-28 14:13:39 +00:00
Said Turksever 69c5b32598 fix(traffic): date the STALE row by the oldest cached tile; add zh-Hant string 2026-10-28 14:10:18 +00:00
Said Turksever 7788168d73 fix(geocode): prefer the settlement when a POI also fits the region
When several candidates fit a "place, region" query, take a settlement or
administrative area over a POI of the same name, so a cafe called "Paris"
in Lyon cannot win "Paris, France" from the nearby pass.

Tests: region queries without a camera bias, whole-word and every-word
region matching, city and county regions, the nearby whole-name answer when
the unbiased request fails, and the settlement preference. The new tests sit
with the other Paris, Texas tests. The station fixture uses its real city.
CURRENT-STATE now describes the order the code applies.
2026-10-28 14:09:56 +00:00
Said Turksever c1820aea49 fix(terrain): end retries early on a long Retry-After; cover the 429 pause
A Retry-After longer than the 10 s retry budget used to sleep the whole
budget out and then give up, so the proxy's 429 pause started 10 s late
and every new-point poll in that window still went upstream and logged.
The retry loop now stops at once in that case.

Tests: the retry loop no longer sleeps for an unreachable Retry-After;
fully cached points, including stale ones, are served during the pause
with no upstream call; a 503 does not pause; a shorter concurrent
Retry-After never cuts the pause short. CURRENT-STATE now says which
requests are still served and which log.
2026-10-28 14:09:12 +00:00
Said Turksever 811eb7835d test(director): cover observer failures in session activate and clear 2026-10-28 14:08:49 +00:00
Said Turksever f7a7a76fb5 test(embed): pin the panel-aware visibility wiring in data setup 2026-10-28 14:06:53 +00:00
Said Turksever 5d5e6c6845 Merge remote-tracking branch 'upstream/main' into pr1014 2026-10-28 14:06:44 +00:00
Said Turksever 9659f38fe6 Merge remote-tracking branch 'upstream/main' into pr1405-fix 2026-10-28 14:06:40 +00:00
Said Turksever 11aad05dc9 Merge remote-tracking branch 'upstream/main' into pr1209-merge 2026-10-28 14:05:40 +00:00
Said Turksever d595e81edf Merge remote-tracking branch 'upstream/main' into pr1344 2026-10-28 14:04:56 +00:00
Said Turksever a75534e4d3 Merge remote-tracking branch 'upstream/main' into pr1402-merge 2026-10-28 14:01:42 +00:00
Said Turksever 81680c5a15 Merge remote-tracking branch 'upstream/main' into pr1401-review 2026-10-28 14:01:33 +00:00
German KhokhlovandClaude Opus 5.5 858ce827e5 i18n(pt): apply review round 2
Three-model review (astra, sol, fable), round 2: 29 of 30 findings
applied, one of them in part.

- pt: FIRMS «mais recente há», CCTV «cruzamentos», degraded radio
  directory, «{layers}: ativação recente» for any layer list, «Sem
  imagem», NIVELADO, AJUSTA-SE, quoted Provider Settings, V/S kept on
  the ADS-B card, no-break space in «(24 h)» / «(30 d)», and more
  (see docs/translations/pt.md).
- Malformed cyclone snapshot is now a catalog message (all locales).
- Markup supports data-i18n-context; the radio STATION TAG option uses
  "radio tag|All", so it no longer flips gender when the list loads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:57:51 +04:00
German KhokhlovandClaude Opus 5.5 e87001d0db i18n(fr): apply review round 2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:55:20 +04:00
German KhokhlovandClaude Opus 5.5 8ca7f52d19 i18n(ja): apply review round 1
Three-model review (astra, sol, Fable), round 1:
- Meaning fixes: post-tropical cyclone, partly cloudy, upload-date basis,
  ground track (対地針路), spec FOV, voice credentials, Activate → 押す,
  bikeshare counts, NHC/CPHC empty state, absence caveat, regional-news
  empty state, route steps (件の案内), minimum fuel, microphone access.
- Consistent terms: 範囲 for WINDOW, カバー範囲 for coverage, 封じ込め率
  for fire containment, サイクロン for storms, 交通流 for TomTom flow,
  シグナル tab, インポート / エクスポート for projects and presets,
  射撃場 for the RANGE class, か所 for sites, full-width colons.
- Short forms where the re-shoot showed a wrap: エクスポート (Scenes),
  提供元で操作 (embeds), 該当施設 (Contacts).
- cjk.css: the Recent Imagery slot line wraps at its separators in
  Japanese, never inside プレビュー.
- Glossary in docs/translations/ja.md updated to match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:42:25 +04:00
German KhokhlovandClaude Opus 5.5 b96c1dee15 i18n(it): apply review round 2
Three-model review (astra, sol, fable): 42 findings accepted, 3 deferred
(unmarked cyclone errors and the Chinese locale descriptors need new keys
in every catalog). The USGS callout now reads as an action (APRI LA FONTE)
because it opens the report. Layer, tool and card-title sentences agree with
any feature name («Il livello {layer} è attivo», «Lo strumento {name}»,
«{label}: attivato»). The store write error is grammatical for both
values. RADO/BILANCIATO agree with DENSO. Also fixes the meaning of
viewpoint, fire incident, trigger record, transit report, FIRMS cell age,
drizzle, widebody and fast-jet classes; Dissolvenza and CH replace the odd
abbreviations; cause/hint dashes become colons.

Fixes the Nepal scene showing shots 01-08 in English in every locale:
displayShotTitle() now also trusts the bootstrap keyframes of the append
recipe that upgrades a built-in scene (src/scenes/recipes.js, with a test).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:41:29 +04:00
German KhokhlovandClaude Opus 5.5 1eb9e06c49 i18n(de): apply review round 1
- Meaning fixes: playback connects to the broadcaster when it starts, browser
  keys are restricted at the provider, clearing data layers switches them off
  (ausschalten, not löschen), cockpit inputs are Quellen, Electronic is
  Elektronische Musik, AIS types name vessels (SEGLER, LOTSENBOOT,
  SANITÄTSDIENST, SCHNELLSCHIFF), contiguous US excludes Alaska/Hawaii,
  wind and weather readings are model values, nearest frames are nearest in
  time, cloud imagery and the brightness filter say what they are.
- NHC/CPHC advisories are Lageberichte, not Warnungen; cyclone strings use
  Wirbelsturm throughout. Glossary updated.
- Grammar: " oder " keeps its space as a join separator, "{x} konnte nicht
  sauber beendet werden", Then: no longer doubles Dann, scene save reasons
  continue the sentence in lower case, neutral "Warten auf".
- Spacing between numbers and units in the remaining nine readouts.
- Reroute tooltip: German mode nouns keep their capital (Auto, Fahrrad).
- Built-in Nepal shot titles localize in every language: the legacy scene
  upgrade rebuilds its base shots without sourcePackId, so the pack's own
  shots in the scene now vouch for them.
- Layout: the layer status column no longer shrinks under a wide chip, and
  a mixed-case compound (ALPR-Kameras) may break at its hyphen; only capital
  codes (ADS-B, RTL-SDR) stay unbroken. WIRD AKTIVIERT overlapped the name.
  The Bhote step meta line may take a third line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:30:21 +04:00
German KhokhlovandClaude Opus 5.5 3c0c10d3b1 i18n(pt): apply review round 1
Three-model review (astra, sol, fable): 51 of 52 findings applied, two
of them in part.

- pt: AQHI bands as health risk, NAVIO-TANQUE, CANDIDATO DO GOOGLE
  PLACES, Provider Settings agreement, retry/feed-down wording,
  NOTÍCIAS DA ÁREA (fits the briefing kicker), detection modes in the
  masculine, GS and REC kept on the HUD, EXPORTAR PREDEF., prepositions
  before times, and more (see docs/translations/pt.md).
- Cyclone request timeout is now a catalog message (all locales).
- Built-in Nepal base shots written by the legacy pack bootstrap now
  show their localized titles: displayShotTitle also trusts the legacy
  keyframes of an append pack the scene has applied.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:17:23 +04:00
German KhokhlovandClaude Opus 5.5 7a18e0093b i18n(uk): apply review round 1
- Fix meaning errors flagged by the three-model review: downed aircraft
  (Аварія літака), fast jet, the straight-ahead lane, road traffic,
  pilot vessel, nearby contact counts, the low-accuracy score.
- Terms: CRS/TRK → ШК (шляховий кут), ascent → виведення, booster
  flight → ПОЛІТ, session → сеанс, sequence → послідовність, place
  search → пошук місця, CCTV pitch → нахил, mapped sites →
  нанесені обʼєкти everywhere; glossary updated.
- Grammar: case agreement in the radio aria label and the key-store
  message, «прицілу», у/в euphony, payload count after a colon,
  FIRES as ПОЖ. so any count reads correctly.
- The cockpit current-style button widens to fit ЗВИЧАЙНИЙ; other
  languages keep the 58px minimum.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:08:36 +04:00
German KhokhlovandClaude Opus 5.5 5e47a2afbd i18n(it): apply review round 1
Three-model review (astra, sol, fable): 53 findings accepted, 14 rejected
or deferred. Fixes the meaning of "Downed aircraft", "Compilation", the
satellite ground-distance caveat and the browser-key restriction note;
agreement for Full, Provider Settings and Space Missions; fork directions
that compose with every modifier; a radio aria label that reads as a
phrase; imperative tooltips. Shortens the labels that clipped: ESP. A/B,
search placeholder, QUOTA · FT, PREC, Esri imagery note.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:02:55 +04:00
German KhokhlovandClaude Opus 5.5 4adb9113bc i18n(fr): apply review round 1
Apply the accepted findings from the three-model French review: ground
track reads « route », wildfire containment « circonscrit », downrange
« distance au sol », tankers « navire-citerne », plus wording fixes for
lanes, timestamps, wind sector, radio, CCTV and voice strings.

Shots rebuilt by a pack's legacy bootstrap carry no sourcePackId, so the
first Nepal shot titles stayed English; the scene's applied-pack marker
now vouches for the pack's required titles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:40:53 +04:00
German KhokhlovandClaude Opus 5.5 cdd90dac4b i18n(uk): fixes from the screenshot review
- Layer rows read «не оновлювалося» for a feed that never updated, not
  «ніколи».
- The dock voice toggle label fits on one line (ПЕРЕМК.), the cockpit
  briefing title (НОВИНИ РЕГІОНУ) no longer truncates, Celestial is «Небо».
- No-break spaces keep «24 год», «30 дн» and coordinate abbreviations
  (пн. ш., зх. д.) on one line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:26:34 +04:00
German KhokhlovandClaude Opus 5.5 0d28150e22 i18n(de): fixes from the screenshot review
- Labels that ran out of their buttons: EXPORT / EXPORT A stay English
  (EXPORTIEREN was cut to EXPORTI…), the imagery mode reads VGL. KARTE,
  the scene run log PROTOKOLL (it broke as LAUFPROTOKOL/L), the dock voice
  status SPRACHE N. V., and the Esri imagery note fits its line.
- The cyclone layer is Sturmwarnungen (no hyphenated break in the layer
  list); Erdbeben (24 h) matches the other hour readouts; retry countdowns
  get a space before s; the Mapillary hint names the Straßenebene layer.
- The cyclone source's timeout and malformed-snapshot errors were plain
  English in every language: mark them like the wind source does and
  translate them in de, es, ru, zh-CN and zh-Hant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:18:49 +04:00
German KhokhlovandClaude Opus 5.5 d6ecaca957 i18n(ja): fit labels found in the screenshot review
- Shorter layer and segment labels that wrapped or broke mid-word in the
  narrow panels: ALPRカメラ, 宇宙ミッション 30日, 地図と比較, 対象.
- Window readouts put the value after the label (空/海ウィンドウ 250 km),
  so a unit never runs into Japanese text.
- 情勢図 for the GhostMaps intel picture; Googleストリートビュー joined like
  any Latin product name in the guide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:13:52 +04:00
German KhokhlovandClaude Opus 5.5 e305447713 style(ja): CJK label rules for Japanese
cjk.css now covers Japanese as well as Chinese: tight tracking, the 9px
floor on dock voice labels and layer buttons, whole travel-mode and preset
labels, catalog-case units in the HUD and the taller Recent Imagery cards.
Japanese also gets strict line breaking (no line starts with ー or a small
kana) and unbroken segment labels (すべて, 平面). Chinese rendering is
unchanged: :is(:lang(zh), :lang(ja)) keeps the :lang(zh) specificity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:13:52 +04:00
German KhokhlovandClaude Opus 5.5 56a36df006 i18n(pt): fit cockpit labels found in the screenshot review
- The cockpit news kicker no longer cuts off: regional news reads «notícias
  locais», the cycle button «CICLO DESLIG.», the subtitle «ÚLTIMAS NOTÍCIAS
  DO LOCAL».
- Contact header fits in two lines («AR/MAR 250 KM · INSTALAÇÕES: ATÉ
  100 KM»); ground speed reads KT like the rest of the HUD.
- A space between a number and s/h/d («nova tentativa em 30 s», «há 5 h»),
  and «aproxime-se de uma rua» for zoom-to-street hints.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:10:32 +04:00
German KhokhlovandClaude Opus 5.5 d95bffb5fd i18n(fr): fit French labels found in the screenshot review
Shorter wording where the French text ran out of its button, chip or
collapsed card at 1600×1100: the DATA LAYERS launcher (COUCHES), Recent
Imagery buttons and hints (CETTE VUE, VS CARTE, EXP. A), the ENABLING /
DISABLING layer chip, the voice detail line and the location search
placeholder. Also the CCTV range readout gets a space before m, the radio
privacy note reads naturally, and the cockpit air/sea window is shorter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:09:45 +04:00
German KhokhlovandClaude Opus 5.5 9de9106883 i18n(it): shorten labels that wrapped or clipped on screen
From the screenshot review: the Recent Imagery buttons (SELEZIONA, USA
VISTA, VS MAPPA BASE) no longer clip or wrap, the layer state chip reads
AVVIO / ARRESTO so a layer name keeps two lines, the cockpit contact header
fits two lines (ARIA/MARE, SITI), and the Bhote Koshi play buttons fit on one
line (AVVIA). The post-event year follows the label, as the reference one does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:03:34 +04:00
Aditya 76a3d47e56 docs(changelog): add #1446 fragment 2026-10-28 16:24:42 +05:30
Aditya d1bbfdbca5 fix(draw): show a 9,999.6 m² area as 1.0 ha, not 10000 m²
The draw hint picked m², ha or km² from the raw area and then rounded,
so 9,999.5 m² up to a hectare read "10000 m²" and about 999,500 m² up
to a square kilometre read "100.0 ha". It now rounds first, as the line
measure already does.
2026-10-28 15:25:08 +05:30
German KhokhlovandClaude Opus 5.5 9f71a62d12 i18n(fr): ship French in the language picker
- Register fr: catalog in CATALOGS, LOCALES and LOCALE_NAMES (Français);
  any fr-* browser tag (fr-CA, fr-BE, fr-CH) maps to it.
- package-boundaries.json lists fr.json next to the other catalogs.
- Picker and locale tests cover the new row and its local names.
- docs/translations/fr.md: style, typography, plurals and glossary;
  TRANSLATORS.md and CURRENT-STATE.md list French as shipped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:00:14 +04:00
German KhokhlovandClaude Opus 5.5 402f56958f i18n(fr): translate the rest of the French catalog
- Translate the remaining 1,865 strings: aviation, maritime (AIS), weather
  and remote-sensing terms, radio, routing, scenes, voice and setup.
- Review the 862 entries from #907: LIVE becomes DIRECT / en direct, stale
  becomes obsolète, FIRMS detections are feux actifs, replay is rejeu, the
  AIS ship types use the French terms (PILOTINE, SERVITUDE, POLICE), keys
  stay Space / Esc / Enter as docs/TRANSLATORS.md asks, and NEXT/PREVIOUS
  chips match PREV (SUIV. / PRÉC.).
- French typography: narrow no-break space before : ; ? ! and %, inside
  « », and … for ellipsis; plural entries get the many form (exact
  millions, "de").
- same-as-english.json: codes, units, genre and product names that French
  spells the same as English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:00:07 +04:00
German KhokhlovandClaude Opus 5.5 ed40201481 i18n(ja): ship Japanese
- Register ja: CATALOGS, LOCALES, LOCALE_NAMES (日本語) and the package
  boundaries; ja and ja-JP tags select it.
- Japanese unit words follow the number directly, as in the catalog:
  formatQuantity writes 5分 and 1時間5分 (km and kt keep the space), and
  formatRelativeTime drops the space CLDR puts in 5 分前.
- Tests: locale tags, picker order, Intl output, durations and the
  ramp/exit/lane wording in Japanese.
- docs/translations/ja.md: style, units, plurals and glossary; ja added to
  TRANSLATORS.md and CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:59:32 +04:00
German KhokhlovandClaude Opus 5.5 7ad073aa9c i18n(ja): translate the remaining strings and review #913
- Translate the 1,875 strings #913 did not cover (2,727 in all), for a
  tactical HUD: aviation, maritime, weather and imagery terms, counters
  next to numbers (機, 隻, 基, 件), year-first dates.
- Review the contributor entries: full-width punctuation ():? and …,
  no space between Japanese and Latin, one katakana form per term
  (ブラウザー, ビューアー), and fixes where the meaning was off:
  NO PLACE LEFT BEHIND, LEVEL (horizon), CURATED / RAW PRIOR (CCTV
  calibration), STALE (更新遅延, not 古い), rocket stage (段), SAR vessels,
  T minus, POWER UP.
- List the codes, units and product names that stay as in English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:59:23 +04:00
German KhokhlovandClaude Opus 5.5 1146946207 i18n(uk): complete and ship Ukrainian
- Translate the remaining 1,895 strings for the current UI (Bhote Koshi
  event, weather, imagery, street level, directions, SDR, voice, scenes,
  import) and review meirm's 832 entries from #312: 134 edited for
  Ukrainian HUD terms (КУРС/ПЕЛ./ШЛЯХ/ПРИЗН., Л/П, ОНЛАЙН for LIVE,
  Cyrillic units КМ, ВУЗ, ФТ, МВт), vessel types, number agreement and
  the ʼ apostrophe throughout.
- Register uk: catalog, LOCALES, LOCALE_NAMES (Українська), package
  boundaries, same-as-English codes; picker and plural tests cover the
  four Ukrainian forms.
- docs/translations/uk.md: style, plurals, units and glossary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:57:54 +04:00
German KhokhlovandClaude Opus 5.5 dfa7ac98a7 i18n(de): complete and ship German
- Translate the remaining 1,868 strings in a neutral infinitive style (no
  Sie/du), with German compounds, decimal commas and CLDR one/other plurals.
- Review the #908 entries: knots are kn everywhere, the avionics codes BRG,
  CRS and DEST stay English like HDG and SPD, expand/collapse is
  ausklappen/einklappen, the radio toggle reads EIN/AUS, density SPARSE is
  DÜNN, the Thermal style is Wärmebild, German quotes in the shot prompt.
- Register de: catalog, LOCALES, LOCALE_NAMES (Deutsch), package boundaries,
  same-as-english (codes, product names, music genres, units).
- Tests: picker order and keyboard model with Deutsch, de/de-AT/de_CH
  normalization, browser de-DE resolves to German.
- docs/translations/de.md: style, plurals, units and glossary; TRANSLATORS.md
  and CURRENT-STATE.md list German.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:53:49 +04:00
German KhokhlovandClaude Opus 5.5 8269f0e4e6 i18n(pt): complete and ship Brazilian Portuguese
- Translate the remaining 1,868 strings (pt-BR, as in #909) and review the
  contributor entries: HUD codes BRG/SPD/RANGE stay as in the other
  languages, sentence-case layer and scene names, Space key named as on the
  keyboard, short PREV/NEXT/ON/OFF chips, and the CLDR many form (exact
  millions take «de») on every plural.
- Register pt: any pt tag (pt-BR, pt-PT) maps to it; the picker shows
  «Português (Brasil)».
- Codes, units and product names identical to English go to
  same-as-english.json.
- docs/translations/pt.md: style, plurals, units and glossary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:52:01 +04:00
German KhokhlovandClaude Opus 5.5 c4dfee6e5c i18n(it): ship Italian
- Register it: catalog in index.js, LOCALES and LOCALE_NAMES (Italiano);
  it-IT, it-CH and other it tags select it.
- List it.json with the other catalogs in package-boundaries.json.
- docs/translations/it.md: style, plurals and glossary; Italian in the
  shipped lists of TRANSLATORS.md and CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:51:02 +04:00
German KhokhlovandClaude Opus 5.5 2172183dc7 i18n(it): translate the remaining strings and review #912
- Translate the 1,868 strings #912 does not cover: directions, weather and
  wind, cyclones, Recent Imagery, Street Level, GNSS, the Bhote Koshi
  reconstruction, scenes and sharing, local SDR, voice and key setup.
- Add the Italian `many` plural form (exact millions) to every plural entry.
- Review the #912 entries: fix RIMORCHIATORE, POWER UP (ACCENDI), DENSO,
  minutes as "min", sentence case for layer and scene names, keyboard keys
  kept in English (Space, Enter), and the SIG/NOTIZIE/LOCALE tab names.
- List codes, product names and words Italian spells as English in
  same-as-english.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:51:02 +04:00
Enzo Abyie 7d7c535dc7 i18n(de): German strings from #908
The German locale by abiyeenzo (#908), converted from its namespaced JS
catalogs to the English-keyed de.json: 859 entries whose English still
matches the current UI.
2026-10-28 12:37:52 +04:00
Enzo Abyie 6167aae6e4 i18n(fr): French strings from #907
The French locale by abiyeenzo (#907), converted from its namespaced JS
catalogs to the English-keyed fr.json: 862 entries whose English still
matches the current UI.
2026-10-28 12:37:35 +04:00
Enzo Abyie ce17d0cade i18n(pt): Portuguese strings from #909
The Brazilian Portuguese locale by abiyeenzo (#909), converted from its
namespaced JS catalogs to the English-keyed pt.json: 859 entries whose
English still matches the current UI.
2026-10-28 12:37:19 +04:00
Meir Michanie e2acee4b0a i18n(uk): Ukrainian strings from #312
The Ukrainian locale by meirm (#312), converted from its namespaced JS
catalogs to the English-keyed uk.json: 832 entries whose English still
matches the current UI.
2026-10-28 12:37:13 +04:00
Enzo Abyie bdd24798db i18n(ja): Japanese strings from #913
The Japanese locale by abiyeenzo (#913), converted from its namespaced JS
catalogs to the English-keyed ja.json: 852 entries whose English still
matches the current UI.
2026-10-28 12:37:08 +04:00
Enzo Abyie 978cd72ac2 i18n(it): Italian strings from #912
The Italian locale by abiyeenzo (#912), converted from its namespaced JS
catalogs to the English-keyed it.json: 859 entries whose English still
matches the current UI.
2026-10-28 12:36:27 +04:00
a4d3701c3c Merge pull request #1430 from bilawalsidhu/codex/hal-646-zh-hant
i18n: add Chinese (Traditional) zh-Hant (#1430)

Co-authored-by: Enzo Abyie <[email protected]>
Co-authored-by: EDIT-Aeterna <[email protected]>
Co-authored-by: caxer <[email protected]>
Co-authored-by: tbdavid2019 <[email protected]>
2026-10-28 12:14:29 +04:00
German Khokhlov d1ba0a7b8a Merge remote-tracking branch 'origin/main' into codex/hal-646-zh-hant 2026-10-28 11:28:43 +04:00
German KhokhlovandClaude Opus 5.5 982edfd56d i18n: use 公布 in zh-Hant to match 發布
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:26:52 +04:00
German KhokhlovandClaude Opus 5.5 018b6d3f1b i18n: fix zh-Hant characters OpenCC converts wrongly
Correct 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道 and 發佈 → 發布,
and list these fixes for future reconversions from zh-CN.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 10:37:21 +04:00
quanticsoul4772 ea491e1603 Merge branch 'main' into upstream/perimeters-stale 2026-10-27 16:51:15 -07:00
quanticsoul4772 eef64cfb4f Merge branch 'main' into upstream/traffic-layer-stale 2026-10-27 16:51:08 -07:00
quanticsoul4772 0ba59112f9 Merge branch 'main' into upstream/terrain-429-pause 2026-10-27 16:51:00 -07:00
quanticsoul4772 702037f833 Merge branch 'main' into upstream/geocode-place-region 2026-10-27 16:50:51 -07:00
German KhokhlovandClaude Opus 5.5 51ee9ae914 i18n: write zh-Hant as zh-CN in Traditional characters
Rebuild the zh-Hant catalog from zh-CN with OpenCC s2tw glyph
conversion, so wording and terminology match zh-CN and only the
script differs. Drop Taiwan-specific vocabulary and wording notes;
zh-TW/HK/MO browser tags still select zh-Hant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 23:57:25 +04:00
German Khokhlov 8269facb5a i18n: address Traditional Chinese translation review 2026-10-27 19:41:07 +04:00
German Khokhlov 5654be5074 docs: point Traditional Chinese changelog to replacement PR 2026-10-27 19:36:35 +04:00
German Khokhlov ba9db7fc41 Merge origin/main and sync Traditional Chinese catalog 2026-10-27 10:25:07 +04:00
German Khokhlov 8f16cf263b i18n: use zh-Hant for Chinese (Traditional) 2026-10-27 10:20:54 +04:00
nu-statsandClaude Opus 5.5 48bfaa787a Merge main; give the HUD visibility test's camera a moveStart event
Main now listens to camera.moveStart in IntelHUD's constructor (#1384), so
the fake camera in src/hudPageVisibility.test.mjs needs that event too, as
hudAltitudeDatum.test.mjs already has. No change to the fix itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:56:44 -04:00
Alex O'Neill 01c35a2c11 Merge branch 'main' into codex/fix-seek-layer-state 2026-10-26 13:06:49 +00:00
quanticsoul4772 c53928ab06 Merge branch 'main' into upstream/perimeters-stale 2026-10-25 19:45:35 -07:00
quanticsoul4772 64c2af6758 Merge branch 'main' into upstream/traffic-layer-stale 2026-10-25 19:44:48 -07:00
quanticsoul4772andClaude Opus 5.5 f6244e8fef refactor(perimeters): share getSnapshotDetail without this
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:42:51 -07:00
quanticsoul4772andClaude Opus 5.5 4c1ebfb877 fix(traffic): pass fetchFlowDetail through the layer's source slot
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:42:49 -07:00
quanticsoul4772andClaude Opus 5.5 2c16dd6601 fix(geocode): match the nearby whole-name fallback exactly
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:55:35 -07:00
quanticsoul4772andClaude Opus 5.5 5410c97307 test(terrain): fail the first 429 test instead of hanging if its request never settles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:55:17 -07:00
quanticsoul4772andClaude Opus 5.5 ae4b382262 docs(changelog): note #1405
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:06:10 -07:00
quanticsoul4772andClaude Opus 5.5 f3faf373f0 docs(changelog): note #1404
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:06:07 -07:00
quanticsoul4772andClaude Opus 5.5 7981a179ea fix(traffic): the traffic layer reads STALE, not LIVE, on cached flow tiles
Once the TomTom tile budget is spent, or TomTom fails, the proxy serves
cached tiles marked STALE-BUDGET / STALE-ERROR. flowSource works out
`stale`, but the layer fetched flow through fetchFlowForBounds, which
returns only the segments, so getStats() and the sync chip still read
"LIVE · Roads: …". #1239 fixed the get_traffic_flow tool the same way; the
layer now uses fetchFlowDetail too, reports stale and oldestTileAt in its
stats, and the chip reads "STALE · Roads: <source> · Flow: cached TomTom
tiles". A source without fetchFlowDetail keeps the previous behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:01:19 -07:00
quanticsoul4772andClaude Opus 5.5 25973ade2c fix(perimeters): report a stale WFIGS snapshot as stale, and stop serving it after a day
When WFIGS was unreachable the proxy served its last snapshot, marked stale
(X-Data-Stale, body.stale), with no age limit, and the client source kept
only the rows. The layer then read healthy with a fresh update time and
get_fire_perimeters reported the perimeters as current; three days into an
outage it still said "1 mapped wildfire perimeter in the requested box."
The source now offers getSnapshotDetail ({rows, stale, fetchedAt}); the
layer reports stale with the snapshot's fetch time, the tool adds stale,
retrieved_at and "(data may be stale)", and the proxy serves a stale
snapshot for at most 24 hours, as the GNSS proxy caps its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:01:19 -07:00
quanticsoul4772 734c74c325 Merge branch 'main' into upstream/geocode-place-region 2026-10-25 17:38:58 -07:00
quanticsoul4772 c13c2fc1f0 Merge remote-tracking branch 'origin/main' into upstream/geocode-place-region
# Conflicts:
#	docs/CURRENT-STATE.md
#	src/keylessGeocoder.test.mjs
2026-10-25 17:32:07 -07:00
Said Turksever 9546dba1c7 Merge branch 'main' into upstream/terrain-429-pause 2026-10-26 00:28:06 +00:00
quanticsoul4772 392c9989eb Merge branch 'main' into upstream/terrain-429-pause 2026-10-25 17:21:03 -07:00
quanticsoul4772andClaude Opus 5.5 923211c24a fix(terrain): key the 429 pause on status, never shorten it, and document it
Review follow-ups: the upstream error now carries its HTTP status and the
pause checks error.status instead of the message text; concurrent 429s
extend the pause with Math.max rather than a shorter Retry-After cutting a
longer one short; the per-chunk re-check says it is for multi-chunk
requests; the first 429 test runs its backoff on mocked timers.
CURRENT-STATE describes the pause.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:08:26 -07:00
quanticsoul4772andClaude Opus 5.5 91769d74b8 fix(geocode): keep a nearby whole-name match as the last choice, and document the region rule
A query with a comma counted as "place, region", so a nearby place whose
own name has a comma ("Bed, Bath & Beyond") no longer won the nearby pass
and the unbiased pass could fly to a namesake far away. Its whole-name
match is now kept as the last choice, after the region checks of both
passes. CURRENT-STATE describes the place-region rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:07:46 -07:00
quanticsoul4772andClaude Opus 5.5 3ac986882c test(terrain): bound the 429 pause test's settle loop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:56:47 -07:00
quanticsoul4772andClaude Opus 5.5 7775f6007d fix(geocode): a region match takes the place itself, not a name starting with it
A name that only started with the place word also matched, so from Lyon
"Paris, France" picked "Paris Gare de Lyon" in the biased pass, where main
fell through to the unbiased answer and reached Paris. Only an exact name
matches now. Adds a test for the UK alias.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:56:33 -07:00
quanticsoul4772 3acfcf94f5 Merge branch 'main' into upstream/terrain-429-pause 2026-10-25 16:39:15 -07:00
quanticsoul4772andClaude Opus 5.5 5ef78b975c docs(changelog): note #1401
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:38:42 -07:00
quanticsoul4772andClaude Opus 5.5 d1bdae20d3 docs(changelog): note #1402
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:38:20 -07:00
quanticsoul4772 52c6401483 Merge branch 'main' into upstream/geocode-place-region 2026-10-25 16:37:25 -07:00
quanticsoul4772andClaude Opus 5.5 d7986309b6 fix(terrain): pause upstream calls after a 429
A rate-limited upstream got asked again by every page request for new
points (about once a second while the camera moves), each with its own
retries, which kept the limit in force and logged "refresh incomplete" each
time. After a 429 the proxy now leaves the upstream alone for its
Retry-After (at most 10 minutes), or 60 s when it sends none, serving
cached points meanwhile. The warning that starts the pause says
"(HTTP 429; pausing upstream for N s)"; requests refused during the pause
don't log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:31:08 -07:00
quanticsoul4772andClaude Opus 5.5 1a61d02b7d fix(geocode): a 'place, region' query lands in the region, not on a namesake
From Denver, 'Paris, Texas' flew to a pub in Budapest at rooftop range. The
camera-biased Photon answer listed the town first, but the pub's name equals
the whole query, and the biased path matched names only. A query with a
region now takes a candidate named for the place whose city, county, state,
country or country code (or USA/UK for US/GB) contains every word of the
region, in both the biased and the unbiased pass; with no such candidate
the existing name rules decide. A query with no region after its comma
keeps the biased name match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:31:08 -07:00
Alex O'Neill fdda5b7a1a Abort superseded same-shot seeks and roll back partial layer restores 2026-10-25 20:53:54 +00:00
Alex O'Neill 458def08a5 fix(director): cover queued and uncertain layer states on same-shot seeks 2026-10-25 20:20:09 +00:00
Alex O'Neill b918939b99 Merge remote-tracking branch 'fork/codex/fix-seek-layer-state' into codex/fix-seek-layer-state 2026-10-25 19:55:02 +00:00
Alex O'NeillandClaude Opus 5.5 b900fb8410 fix(director): reconcile only stale layers on same-shot seeks
Compare declared visibility with the manager's effective intent, and treat
in-flight transitions as stale so a toggle or superseded LOAD rollback that
is still settling cannot slip past the check. Reconcile only those layers
inside the lightweight seek instead of rebuilding the shot, and report a
refusal without resetting the seek position or camera.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:54:51 +00:00
Alex O'Neill cdb6bd0716 Merge branch 'main' into codex/fix-seek-layer-state 2026-10-25 19:39:36 +00:00
nu-statsandClaude Opus 5.5 1161899aea Merge main; keep inline panels polling while their host reports them hidden
Main now has #1282, which pauses periodic layer polling in a hidden page
with its own catch-up and listener cleanup, so this PR drops its duplicate
lifecycle.js change and test in favor of main's.

Review fixes:
- data.js passes panelAwareVisibility(document) (new in app/embed.js) as
  the lifecycle's visibility target, so an inline panel whose host reports
  it hidden keeps polling. The HUD's 250 ms readout follows the same
  document.hidden && !isEmbeddedInline() rule as tools.js.
- Tests: embed.test.mjs (layers poll in an inline panel reported hidden,
  pause without the flag) and hudPageVisibility.test.mjs (HUD readout).
  Both use mock timers and clean up in finally.
- changelog.d/1344-inline-panel-visibility.md and a note in
  docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 19:47:18 -04:00
Alex O'Neill 8553b3d5f7 docs: number changelog fragment for PR 1374 2026-10-24 23:14:15 +01:00
Alex O'Neill 1644acd2a3 fix(director): restore layer visibility on same-shot seeks 2026-10-24 23:05:56 +01:00
nu-statsandClaude Opus 5.5 952671964d perf(layers): pause polled refreshes while the tab is hidden
A hidden tab shows nobody the result, so polled layers (flights, quakes,
weather...) skip their refresh while the page is hidden and catch up with
one refresh when it is shown again. The lifecycle takes an optional
pageVisibility service ({ isHidden, subscribe }) so src/data/lifecycle.js
stays free of browser globals; src/app/data.js supplies the document-backed
one. Without the service, refreshes run as before. Every place that stopped
a layer's interval now goes through one helper that also drops the
catch-up listener.

The HUD's 250 ms camera readout also skips hidden tabs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 21:41:33 -04:00
German Khokhlov ddae593bb7 docs: credit Traditional Chinese translation sources 2026-10-23 22:53:15 +04:00
6b3e8bada3 i18n: add Traditional Chinese (zh-TW)
Add a complete Taiwan Traditional Chinese catalog, locale selection,
Chinese readout formatting, translation notes and regression coverage.
Seed translations from #724 and #652 and adapt the zh-CN catalog.

Co-authored-by: caxer <[email protected]>
Co-authored-by: tbdavid2019 <[email protected]>
Co-authored-by: Enzo Abyie <[email protected]>
Co-authored-by: EDIT-Aeterna <[email protected]>
2026-10-23 22:52:48 +04:00
Said TurkseverandClaude Opus 5.5 6a5374435f docs(current-state): move the #1014 note into its topical section
Four open PRs prepended their notes at the top of CURRENT-STATE.md, so
merging any one of them made the others conflict. Place this note in its
own section instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:52:46 +01:00
Said TurkseverandClaude Opus 5.5 76199c07ee fix(director): keep a falsy phase error over a release failure
runError doubled as the failure flag, so a phase that threw a falsy
value was still masked by a final release error (ianblenke's nit).
Track failure with a boolean and cover it with a test. Make the
changelog fragment a wrapped bullet like the other fragments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:27:25 +01:00
Said Turksever d64cd1dc6f Merge upstream/main into fix/scene-director-callback-errors 2026-10-19 14:26:05 +01:00
daikaginza 38d16f0a81 docs(sdr): record the message / errorText split for receiver errors 2026-10-18 15:23:10 +07:00
Deepesh Bind 47b75cf554 Merge branch 'main' into fix/scene-director-callback-errors 2026-10-18 13:49:39 +05:30
dufferdeepu 3109ddabb8 add trailing newline to playback test 2026-10-18 13:37:27 +05:30
daikaginza c0356341b0 docs(changelog): add the #1209 entry 2026-10-18 14:35:07 +07:00
dufferdeepu abf2ce1c9a fix: guard scene director callbacks 2026-10-17 19:40:12 +05:30
dufferdeepu d2de6258ae docs: document director playback error handling 2026-10-17 19:24:32 +05:30
dufferdeepu d16620a29f fix: guard scene director callbacks 2026-10-17 19:17:52 +05:30
daikaginza e82f6d5c9c fix(awareness): keep cohort reasons English in data
summarizeAwarenessCohort() translated `reason`, which is retained in the
awareness context snapshot. Mark the four reasons with msg() instead; the
awareness panel already shows them through translateKnown().
2026-10-17 07:32:21 +07:00
daikaginza 3d64a54a21 fix(layers): keep the Local ADS-B receiver error in English
The Local ADS-B layer copied the SDR controller's `message`, which is
Radio card text in the UI language, into its `error` stat. The controller
now publishes an English `errorText` next to the translated `message` for
every receiver error, and the layer reports that. A receiver is any
object with getState(); one that does not publish errorText still shows
its error from `message`, as before. The Radio card text is unchanged,
and the Layers panel still shows the error translated.
2026-10-18 13:57:48 +07:00
daikaginza 4d2a71c1b2 fix(layers): report Directions errors as English data
The Directions row errors (pointer blocked, no route, routing timed out,
no response from the routing service) and the route request failures
("Routing failed: {error}", "{error} — try again in a moment",
"Routing unavailable (HTTP {status})", the rate-limit and fallback
texts) were translated inside getStats(), so the layer snapshot that
voice tools and the analyst read carried Spanish or Russian text.

Fixed texts are marked with msg() and the interpolated ones are built
in English. The Layers panel translates them where it shows them,
through a ROUTING_ERROR_PATTERNS block in statusText.js that the
proxy's refusal texts move into. English reads as before. In other
locales two things differ slightly: a proxy text inside "Routing
failed: X" that is not one of the known refusals is now looked up in
the catalog instead of shown as given, and the "— try again in a
moment" shape is recognised on every layer's error, not only on
Directions.
2026-10-18 13:57:48 +07:00
jeffblakely faa3503bfd Merge remote-tracking branch 'upstream/main' into fix/firms-ipv4-fallback
# Conflicts:
#	CHANGELOG.md
2026-10-14 10:08:28 -04:00
Jeff Blakely 166aef58db fix(firms): retry over IPv4 when every address fails to connect
Node's network-family auto-selection gives each resolved address a short
attempt timeout. On hosts where IPv6 is unreachable, a slow IPv4 handshake
is abandoned for the IPv6 address, which fails at once, and the request
reports only `fetch failed`. Retry such failures once with a single IPv4
connection within the same deadline, and log the per-address connection
errors (never the keyed URL). Node 26 wraps the AggregateError one level
deeper, so both shapes are recognised.

Fixes #68
2026-10-12 11:53:33 -04:00
jibraaan 1490f37e1b Merge remote-tracking branch 'upstream/main' into feat/cctv-a22-autobrennero
# Conflicts:
#	CHANGELOG.md
2026-10-09 02:12:25 +05:00
jibraaan faced3b651 Merge remote-tracking branch 'upstream/main' into feat/cctv-a22-autobrennero
# Conflicts:
#	CHANGELOG.md
2026-10-08 12:24:43 +05:00
jibraaan 9000b3e174 Merge remote-tracking branch 'upstream/main' into feat/cctv-a22-autobrennero
# Conflicts:
#	CHANGELOG.md
2026-10-07 23:39:52 +05:00
jibraaanandClaude Opus 5.5 b11390404a feat(cctv): add the Autostrada del Brennero (A22) motorway webcams
Closes bilawalsidhu/gods-eye-view#155. The first Italian cameras: the 13
webcams along the A22 from the Brenner Pass to Modena.

The camera list is one keyless request to the Open Data Hub (NOI Techpark)
tourism API, which republishes Autobrennero's own list with coordinates
and kilometre markers. Frames are the operator's stills, refreshed about
once a minute, and only each camera's own WebCamImg/km<N>.jpg is
registered. The catalog fetch refuses redirects and is size-capped.

The road climbs from ~20 m in the Po valley to ~1,370 m at the Brenner
Pass, so ground elevation comes from a per-kilometre table sampled from
the Copernicus GLO-90 DEM instead of one flat prior. Headings use the
id-hash fallback at low confidence. CCTV_A22_ENABLED=0 disables the pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:19:45 +05:00
Wilmer AlzateandClaude Opus 5.5 367827e742 perf(vessels): let the scene idle with AIS vessels enabled
Vessels move only when a poll lands, yet enabling the layer held the
continuous render loop for its lifetime, so a parked vessels-only scene
rendered every frame. The layer now:

- requests a frame for every forced visibility pass (new data, selection);
- leaves one trailing frame when the camera moved inside the 800 ms
  visibility cadence, so the view the camera stopped on is evaluated. It is
  keyed to camera motion alone: the pass's own label publication requests a
  frame, and scheduling on that looped at ~1.2 Hz;
- holds 'ais-vessels' only while focus emphasis is animating.

The 80 ms focus pass, which runs while anything is tracked, also skips hidden
sprites whose focus state is at rest (new spriteFocusAtRest) and whose alpha
is already 1: advancing them changes nothing, and with a 12k worldwide feed
they are most of the fleet.

Measured: a parked vessels-only scene (headless SwiftShader) went from
continuous mode and 28 frames in 5 s to idle and 0; horizon visibility had no
mismatches after the camera moved and settled. The focus pass over 12,000
vessels (11,353 hidden) went from ~4.0 ms to ~1.2 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 13:01:45 -05:00
288 changed files with 32189 additions and 2525 deletions

No files matched your search

+2
View File
@@ -256,6 +256,8 @@ AISSTREAM_API_KEY=
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras # CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required # CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL # CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
+2
View File
@@ -69,6 +69,7 @@ How to read this:
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) | | **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" | | **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) |
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | | **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" | | **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
@@ -176,6 +177,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover. - **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
- **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. - **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. - **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 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.
+7 -5
View File
@@ -2,6 +2,8 @@
# 🌐 God's Eye View # 🌐 God's Eye View
**Official site: [godseyeview.ai](https://godseyeview.ai)** · Official source: this repository · Hosted version coming soon
[![CI](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml) [![Reached #1 on GitHub Trending](https://img.shields.io/badge/%231_GitHub_Trending-thank_you!-F0A63C?style=flat-square&logo=github)](https://x.com/bilawalsidhu/status/2093798887815348521) [![CI](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml) [![Reached #1 on GitHub Trending](https://img.shields.io/badge/%231_GitHub_Trending-thank_you!-F0A63C?style=flat-square&logo=github)](https://x.com/bilawalsidhu/status/2093798887815348521)
### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real. ### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real.
@@ -61,7 +63,7 @@ Start with the included data sources, then add your own. Each layer is a separat
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click. - **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes. - **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
- **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in. - **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in.
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir, Snow. - **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view. - **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry. - **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
- **🌐 Global Context:** Stage the full situational picture with one switch — and get your exact view back when you leave. - **🌐 Global Context:** Stage the full situational picture with one switch — and get your exact view back when you leave.
@@ -294,14 +296,14 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
![Tracking the ISS along its orbital path as it crosses over Ukraine](docs/media/14-iss-over-ukraine.gif) ![Tracking the ISS along its orbital path as it crosses over Ukraine](docs/media/14-iss-over-ukraine.gif)
6. **Switch the optics.** Tap `1`–`7` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor. 6. **Switch the optics.** Tap `1`–`6` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor.
![Cycling a dense live globe through CRT, FLIR, and NVG in one continuous view](docs/media/01-style-sweep.gif) ![Cycling a dense live globe through CRT, FLIR, and NVG in one continuous view](docs/media/01-style-sweep.gif)
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_ 7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_ 8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. **Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
--- ---
@@ -399,7 +401,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
@@ -681,7 +683,7 @@ One heads-up from the inside: build in this space for a week and you learn that
<div align="center"> <div align="center">
▶️ [Watch the God's Eye View series](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · 📬 [Map the World](https://maptheworld.ai/) — the newsletter behind the project 🌐 [godseyeview.ai](https://godseyeview.ai), the official site · ▶️ [Watch the God's Eye View series](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · 📬 [Map the World](https://maptheworld.ai/) — the newsletter behind the project
**🌐 God's Eye View. No place left behind.** **🌐 God's Eye View. No place left behind.**
@@ -0,0 +1,4 @@
- Director clock listeners, interaction observers and scene release failures
no longer leave the director inconsistent: each scene is released once, and
a phase error is kept (with the release failure logged) when cleanup also
fails (deepeshbind, #1014).
+4
View File
@@ -0,0 +1,4 @@
- FIRMS fire data loads on hosts where IPv6 is unreachable and IPv4 is slow to
connect: a request whose every address fails to connect retries once over
IPv4, and FIRMS warnings name the underlying connection errors instead of
only `fetch failed` (jeffblakely, #1078).
@@ -0,0 +1,5 @@
- Keep Directions route errors, the Local ADS-B receiver error and Global
Context cohort reasons English in layer data; the Layers panel, Radio card
and awareness panel still show them in the interface language. The route
proxy's "routing busy" and "route proxy error" refusals are now translated
too (daikaginza, #1209).
@@ -0,0 +1,4 @@
- Pause the HUD's camera readout while the page is hidden, as polled layers
already do. An app embedded inline in a panel keeps both updating, because the
panel's host can report the page as hidden while it is on screen (nu-stats,
#1344).
@@ -0,0 +1,4 @@
- Restore a shot's declared layer visibility when seeking within it after manual
layer changes, including toggles still in flight. Only changed layers are
reconciled; refused restoration reports a failed seek and keeps the last
position, while undeclared operator layers are preserved (@oneill9, #1374).
+2
View File
@@ -0,0 +1,2 @@
- A "place, region" search such as "Paris, Texas" now lands on the place in
that region instead of a namesake elsewhere (quanticsoul4772, #1401).
+5
View File
@@ -0,0 +1,5 @@
- The terrain proxy stops asking the upstream for a while after a 429
(its Retry-After, at most 10 minutes, or 60 s) instead of retrying on every
page request, and a Retry-After longer than its 10 s retry budget now ends
the retries at once instead of sleeping the budget out (quanticsoul4772,
#1402).
+3
View File
@@ -0,0 +1,3 @@
- Fire Perimeters and `get_fire_perimeters` say when WFIGS is unreachable and
the last snapshot is being shown (STALE, its age, "data may be stale"), and
that snapshot is served for at most 24 hours (quanticsoul4772, #1404).
+3
View File
@@ -0,0 +1,3 @@
- The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves
cached flow tiles (budget spent or TomTom failing), and the row dates them by
the oldest cached tile (quanticsoul4772, #1405).
+4
View File
@@ -0,0 +1,4 @@
- Add Traditional Chinese (zh-Hant) with language selection and localized
Chinese readouts: the Simplified Chinese catalog written in Traditional
characters, based on translations by @abiyeenzo (#914) and @EDIT-Aeterna
(#979) (German, @gekh, #1430).
+3
View File
@@ -0,0 +1,3 @@
- Show a drawn area of 9,999.5 m² or more as 1.0 ha, and of 999,500 m² or
more as 1.00 km². The draw hint picked the unit before rounding, so it read
"10000 m²" and "100.0 ha" (Aditya, @Rohilalala, #1446).
+5
View File
@@ -0,0 +1,5 @@
- Cut the Google requests the HUD's AI summary makes on its own. It no longer
looks up place names when no OpenAI key is configured, reverse-geocodes at
most two points instead of up to four, and looks up again only once the view
shows a different area, not after every small pan, so a small pan no longer
asks OpenAI for a new summary either (samehkhamis, #0000).
+4
View File
@@ -0,0 +1,4 @@
- Update the development tools to Vite 6.4.4 and Puppeteer 25.16.0, which
clears the open npm advisories for the dev server and the browser test
tooling, and remove the unused local screenshot helper `scripts/shot-sink.mjs`
(samehkhamis, #1463).
@@ -0,0 +1,5 @@
- Give the side panels one look. Every panel title now uses the command dock's
label type in a brighter colour; a panel's edge and title light up on hover,
as the dock's trays do; and in the default theme each collapse button sits
19px from its panel's edge (plus the scrollbar's width in an open, scrolling
panel where the system always shows scrollbars) (asturksever, #1466).
@@ -0,0 +1,3 @@
- Remove the Operator HUD layout. Display > HUD > Layout and the HUD voice
action now offer Tactical, Minimal and Cyber; share links, scenes and saved
views that name Operator open in Tactical (asturksever, #1467).
+4
View File
@@ -0,0 +1,4 @@
- Remove the Snow visual preset. Visual Presets, keyboard shortcuts, the
Cockpit vision carousel and the visual-style voice action now offer Normal,
CRT, NVG, FLIR, Anime and Noir; share links and scene shots that name Snow
open on Normal (asturksever, #1468).
+3
View File
@@ -0,0 +1,3 @@
- Add Ukrainian (uk) to the language picker: the whole interface in Ukrainian,
with its four plural forms and Cyrillic HUD units. Based on the Ukrainian
translation by @meirm (#312) (German, @gekh, #1470).
@@ -0,0 +1,7 @@
- Remove the Radio icon from the Context panel header, along with the compact
Radio player it opened; Radio stays in Context as its own section, and
Cockpit's compact Radio is unchanged. Context is also easier to read: the
Contacts / Space Missions modes are one segmented control, labels and
guidance are larger and higher contrast, the standby hint is plain text
instead of a dashed box, and the panel draws no glow halo. Cyber keeps its
own framed buttons and red titles (asturksever, #1471).
+4
View File
@@ -0,0 +1,4 @@
- Rename the Scenes panel to Authoring and lay it out in steps (Scene, Shots,
Preview, Save & share) with larger, higher-contrast text, numbered shots, a
tooltip on every action and buttons that are disabled when they would do
nothing (asturksever, #1472).
+3
View File
@@ -0,0 +1,3 @@
- Add Italian (it) to the language picker: the whole interface in Italian,
with Italian plural forms and labels shortened to fit the panels. Based on
the translation by @abiyeenzo (#912) (German, @gekh, #1473).
+4
View File
@@ -0,0 +1,4 @@
- Add Brazilian Portuguese (pt) to the language picker, with every screen
translated and Portuguese plural forms. The CCTV summary line now names its
calibration, source and view in the page language. Based on translations by
@abiyeenzo (#909) (German, @gekh, #1475).
+4
View File
@@ -0,0 +1,4 @@
- Add French (fr) to the language picker, with French typography and plural
forms. With nine languages the picker now shows its search field. Based on
the translation by @abiyeenzo (#907), checked against @meirm's French
catalog (#312) (German, @gekh, #1476).
+3
View File
@@ -0,0 +1,3 @@
- Add German (de) to the language picker, with a full translation of the
interface and a German style guide in docs/translations/de.md. Based on
translations by @abiyeenzo (#908) (German, @gekh, #1477).
+4
View File
@@ -0,0 +1,4 @@
- Add Japanese (ja) to the language picker, with year-first dates, Japanese
counters, unit words joined to their numbers (5分前, 1時間5分) and CJK label
rules for Japanese text. Based on translations by @abiyeenzo (#913)
(German, @gekh, #1478).
+4
View File
@@ -0,0 +1,4 @@
- Draw only the CCTV cameras in view, and thin them to one per ground cell when
zoomed out. Dragging the globe with the default 4,000-camera catalog no
longer stalls, and the camera picker lists the cameras in view with a count
of the ones outside it (samehkhamis, #1485).
+3
View File
@@ -0,0 +1,3 @@
- Let a GNSS integrity source set the layer's evidence window (`windowMs`). A
missing or invalid value keeps the 30-minute default, and the layer row now
names the window in use instead of a fixed "30 min" (samehkhamis, #1491).
@@ -0,0 +1,4 @@
- Let a weather source describe its own products through an optional
`display` object in each manifest (source, credit, units, coverage, info,
legend and extent). Each valid field replaces the built-in value, and
manifests without it render as before (samehkhamis, #1494).
+7
View File
@@ -0,0 +1,7 @@
- Let the scene idle with AIS vessels enabled. Vessels no longer hold the
continuous render loop; they request frames for new data and selection, one
trailing frame after the camera stops, and hold the loop only while focus
emphasis animates or a vessel stays dimmed under a tracked target. A parked
vessels-only scene went from continuous rendering to 0 frames in 5 s. While
anything is tracked, the vessel focus pass skips hidden sprites at rest:
about 4 ms to 1.2 ms per pass over 12,000 vessels (waar19, #871).
+8
View File
@@ -0,0 +1,8 @@
- Add the Autostrada del Brennero (A22, Brenner–Modena) motorway webcams as a
CCTV pack, the first Italian cameras. The 13-camera list is one keyless
request to the Open Data Hub (NOI Techpark) tourism API, which republishes
Autobrennero's own list with coordinates; frames are the operator's stills,
refreshed about once a minute and pinned to each camera's own `km<N>.jpg`.
Each camera's ground elevation comes from a per-kilometre DEM table, since
the road climbs from the Po valley to the Brenner Pass. `CCTV_A22_ENABLED=0`
disables the pack (Jibran Tahir, @jibraaan, #956). Closes #155.
+178 -53
View File
@@ -4,6 +4,24 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so
containers can read it on enforcing SELinux hosts. The separate image-owned containers can read it on enforcing SELinux hosts. The separate image-owned
`node_modules` volume and loopback-only port publishing remain unchanged. `node_modules` volume and loopback-only port publishing remain unchanged.
The HUD's AI summary line looks up place names for the view only when the
summary can use them. The page asks `GET /api/openai/hud-summary/status` once;
a server without an OpenAI key answers `{ configured: false }`, and the HUD then
makes no reverse-geocode or Nearby Places request until the page reloads. A
server without the route is asked once, and an unconfigured summary reply
stops the lookups the same way. Each lookup reverse-geocodes at most two
points: the view center alone below 10 km, the center and one viewport point
up to 750 km, and two viewport points up to 3000 km; Nearby Places is still
added below 25 km. A lookup is reused until the view center moves a quarter of
the visible width, the camera's distance to the center doubles or halves, or
the camera crosses one of those heights. The width is measured from the
camera's distance to the view center, so terrain and tilt scale it. A reused
lookup asks again only for what it did not get (a timeout, a failure, an empty
answer); everything else is a cache hit. A small pan over a reused lookup
repaints the last AI reply instead of asking for a new one. A host passing
`cachedOnly` keeps reading every cached sample. Voice context lookups are
unchanged.
The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to
hundredths of a second, and then splits it. A value within 0.005" of the next hundredths of a second, and then splits it. A value within 0.005" of the next
whole minute carries into the minutes and degrees, so `-122.35` reads whole minute carries into the minutes and degrees, so `-122.35` reads
@@ -203,7 +221,7 @@ from 0–200%. OFF disables the slider while retaining its value. HUD layout
changes preserve both settings; share links and scenes restore them. The changes preserve both settings; share links and scenes restore them. The
historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge. historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge.
With Scope OFF, Tactical, Operator and Minimal add text-sized black backplates With Scope OFF, Tactical and Minimal add text-sized black backplates
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline
and Active Style label with the shared secondary text color. The title plate and Active Style label with the shared secondary text color. The title plate
@@ -213,6 +231,15 @@ the original HUD presentation; Cyber retains its existing skin.
`node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence, `node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence,
restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots. restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots.
## HUD layouts — October 29, 2026
Display > HUD > Layout offers Tactical, Minimal and Cyber; the `set_hud` voice
action accepts the same three. The Operator layout is retired. Share links
keep its enum code so older links still parse, and a link, scene or saved state
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.
## Cyber HUD — September 30, 2026 ## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -228,8 +255,8 @@ red hover border in both map and cockpit, so clipping does not hide the highligh
Cyber's map-side right rail opens one panel at a time while keeping collapsed Cyber's map-side right rail opens one panel at a time while keeping collapsed
launchers available. Display, CCTV and Context scroll their contents inside fixed launchers available. Display, CCTV and Context scroll their contents inside fixed
headers and decorative frames. The narrow-screen rail remains scrollable to reach headers and decorative frames. The narrow-screen rail remains scrollable to reach
each panel. Radio retains the shared nested Context player and compact each panel. Radio retains the shared nested Context player without relocating
header disclosure, without relocating playback controls on theme changes. playback controls on theme changes.
Voice help/error popups and Location/Visual Presets pins extend beyond their Voice help/error popups and Location/Visual Presets pins extend beyond their
Cyber frames without being clipped: the shapes decorate non-interactive Cyber frames without being clipped: the shapes decorate non-interactive
@@ -448,6 +475,33 @@ not worldwide detections; units are strikes/km²/min ×10³. It is neither raw G
flashes nor a live strike counter. Weather imagery orders scalar context below flashes nor a live strike counter. Weather imagery orders scalar context below
satellite, radar and lightning consistently across enable and history order. satellite, radar and lightning consistently across enable and history order.
A weather source can describe its own products (credit, units, coverage,
legend) through an optional `display` object in each product manifest:
`source` (≤60 characters, the row's source name), `credit` (≤200, the imagery
credit in Data attribution, shown as plain text and updated when a refreshed
manifest changes it), `units` (≤40), `coverage` (≤48, the coverage badge and
info line), `info` (≤600, the info tooltip), `legend` (2–16 `{ label, color }`
stops; label a plain decimal number or text, at most 16 characters either way,
a number reading back exactly as the legend shows it: within ±2^53−1, with
no leading zeros and no digits lost to rounding; color `#rrggbb`) and `extent`
(`{ west, south, east, north }` degrees, non-empty; west greater than east
crosses the antimeridian; used for coverage navigation and the
outside-coverage status). `units` and `legend` describe one scale and
apply only together: if either is absent or invalid, both keep the built-in
values, and the radar info heading names the units in use. Text must be
single-line, without control characters (U+0000–U+001F, U+007F–U+009F), line
or paragraph separators, or invisible and format characters: soft hyphen
(U+00AD), combining grapheme joiner (U+034F), Arabic letter mark (U+061C),
Mongolian vowel separator (U+180E), zero-width and direction marks
(U+200B–U+200F), bidi embedding and override (U+202A–U+202E), word joiner,
invisible operators and bidi isolates (U+2060–U+2069), variation selectors
(U+FE00–U+FE0F), byte-order mark (U+FEFF) and tag characters
(U+E0000–U+E007F). Each other field is validated on its own; an absent or
invalid field keeps the built-in value, so the bundled nowCOAST manifests,
which carry no `display`, look exactly as before. Accepted source
and units text is source data and is shown untranslated. The static Data
attribution entries remain the application's `credits` list.
Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bounded Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bounded
same-origin provider. The status endpoint lacks browser CORS; each installation same-origin provider. The status endpoint lacks browser CORS; each installation
fetches keyless public sources at runtime, with no centralized ingestion service. fetches keyless public sources at runtime, with no centralized ingestion service.
@@ -517,7 +571,7 @@ Native `<select>` controls declare a dark colour scheme and explicit option
colours, so the browser-painted option list matches the panel it drops out of. colours, so the browser-painted option list matches the panel it drops out of.
The scheme is declared on the controls rather than on the document root, so The scheme is declared on the controls rather than on the document root, so
hand-tuned scrollbar and text-input skins are unchanged. Applies to the HUD hand-tuned scrollbar and text-input skins are unchanged. Applies to the HUD
layout, Scenes, CCTV camera, Radio filter and Draw colour menus. layout, Authoring, CCTV camera, Radio filter and Draw colour menus.
Vessel snapshot completeness is separate from freshness. A current snapshot with Vessel snapshot completeness is separate from freshness. A current snapshot with
rejected or duplicate records shows PARTIAL with accepted/received counts; stale rejected or duplicate records shows PARTIAL with accepted/received counts; stale
@@ -560,6 +614,11 @@ dateline while preserving both authored endpoint longitudes, and turns heading
and roll the short way for any authored angles. and roll the short way for any authored angles.
A seek to a shot's start, as a shot action makes, selects that shot rather than A seek to a shot's start, as a shot action makes, selects that shot rather than
the end of the previous one. the end of the previous one.
Same-shot seeking restores declared layer visibility after a manual toggle,
including one still in flight, by reconciling only the changed layers through
the cancellable layer admission path. Refused restoration reports a failed
seek and keeps the last settled position. Undeclared operator layers remain
untouched, and the shot is not rebuilt.
See [camera directions](DIRECTOR-CAMERA.md). See [camera directions](DIRECTOR-CAMERA.md).
Realtime voice composes separate connection, response/tool, Radio handoff, input Realtime voice composes separate connection, response/tool, Radio handoff, input
@@ -583,7 +642,7 @@ from revealing its UI. Ambiguous requests to open settings ask whether the user
means Voice Settings or Provider Settings before opening either panel. Named means Voice Settings or Provider Settings before opening either panel. Named
Voice Settings applies directly; Provider Settings opens the existing local Voice Settings applies directly; Provider Settings opens the existing local
provider dialog when available. Visual Presets targets the visual controls tray, provider dialog when available. Visual Presets targets the visual controls tray,
while Scenes targets saved scenes. Selecting Preview does not enable a disabled while Scenes (the Authoring panel) targets saved scenes. Selecting Preview does not enable a disabled
Preview preference. Unverified speech captions use the localized Recent Preview preference. Unverified speech captions use the localized Recent
speech label; ordinary user captions retain YOU. Caption attribution does not speech label; ordinary user captions retain YOU. Caption attribution does not
control tool dispatch. control tool dispatch.
@@ -716,7 +775,7 @@ Nepal scene ownership follows the parameters prepared by the public layer manage
Animated scene cards sample their optional scale, opacity and leader callbacks once per projected frame. A failed callback suppresses that frame and can recover on the next one; ordinary layers keep the shared projection path without animation evaluation. Animated scene cards sample their optional scale, opacity and leader callbacks once per projected frame. A failed callback suppresses that frame and can recover on the next one; ordinary layers keep the shared projection path without animation evaluation.
Nepal Flood Incident is accessed through Scenes. Its flood and locator components remain registered for scene playback but are hidden from the Data Layers menu. Closing the event stops the active scene, including pending loads and media. The default catalog does not install a separate reconstruction scene; existing saved scenes remain untouched. Nepal Flood Incident is accessed through Authoring. Its flood and locator components remain registered for scene playback but are hidden from the Data Layers menu. Closing the event stops the active scene, including pending loads and media. The default catalog does not install a separate reconstruction scene; existing saved scenes remain untouched.
Double-click a shot name to edit it inline. Enter or moving focus saves a nonempty name, Escape cancels, and saved names persist with the scene project. Double-click a shot name to edit it inline. Enter or moving focus saves a nonempty name, Escape cancels, and saved names persist with the scene project.
@@ -730,7 +789,7 @@ Persistent media-led Nepal shots preserve their completed upstream flood path du
Repeated map-stack requests retain the live imagery layer when its provider is unchanged. Scene handoffs keep loaded tiles while real source changes, switch cancellation, and tile-error fallbacks retain their existing behavior. Repeated map-stack requests retain the live imagery layer when its provider is unchanged. Scene handoffs keep loaded tiles while real source changes, switch cancellation, and tile-error fallbacks retain their existing behavior.
Nepal Flood Incident is available in Scenes, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms. Nepal Flood Incident is available in Authoring, with a 25-shot sequence, geographic labels, an event panel, scene scrubbing and shot playback. Comparison shots use Esri beneath Vantor imagery. Other Nepal shots use Google 3D when its tileset is available and fall back to Esri otherwise; without an ion token, the existing map stack uses Re:Earth terrain. Saved shots retain their authored camera and map preferences across keyed and keyless runtimes. The bundled pack contains historical 2021 and post-event 2026 imagery, not an isolated before/after measurement; the animated flood path is schematic, not a hazard model. Witness media opens or embeds original sources without bundled clips. See `public/events/bhote-koshi-2026/README.md` for provenance and non-commercial data terms.
Transit snapshot and selected-history reads use an explicit source interface. The standalone source selects the existing routes; rendering, playback and selection retain their existing owners. A portable request service owns registered-feed admission, bounded decoding, cache/revalidation, backoff and history; development and preview use the same thin adapter. Compatibility exports and source attribution are preserved. Transit snapshot and selected-history reads use an explicit source interface. The standalone source selects the existing routes; rendering, playback and selection retain their existing owners. A portable request service owns registered-feed admission, bounded decoding, cache/revalidation, backoff and history; development and preview use the same thin adapter. Compatibility exports and source attribution are preserved.
@@ -793,6 +852,11 @@ rendered detection set. The legacy manager retains panel helper compatibility;
registration sealing, restoration order and failed-transition rollback remain registration sealing, restoration order and failed-transition rollback remain
unchanged. unchanged.
Polled layer refreshes and the HUD's camera readout pause while the page is
hidden. An app embedded inline in a panel keeps both running, because the
panel's host can report the page as hidden while it is on screen. This is the
same rule the render loop follows (`keepPanelRendering` in `app/embed.js`).
Application scene setup owns request services, terrain/floor caches and annotation Application scene setup owns request services, terrain/floor caches and annotation
lookup state. Controls, layers and voice share those owners, and cancellation lookup state. Controls, layers and voice share those owners, and cancellation
clears caches and removes map-stack listeners. Standalone setup supplies the clears caches and removes map-stack listeners. Standalone setup supplies the
@@ -941,6 +1005,15 @@ directory. Every `/api/cctv` route refuses cross-site browser requests but
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
request skips the key-bearing Street View fallback and gets the placeholder. request skips the key-bearing Street View fallback and gets the placeholder.
The CCTV layer draws only the cameras on or near the screen. Zoomed out, it
keeps one camera per ground cell sized to the view, preferring a healthy feed,
and the cells are anchored to the ground so a pan keeps the same cameras. The
active camera always shows. Ground placement and coverage geometry load only
for the cameras in view, with at most two attempts per camera and view regime.
The view pass reruns while the camera moves, paced by its own cost. The camera
picker lists the cameras in view, active camera first, and closes with the
count of cameras outside the view.
Traffic owns its rendered-surface observer only while enabled, removes it on disable/destroy, and caches surface identity without allocating on unchanged frames. Traffic owns its rendered-surface observer only while enabled, removes it on disable/destroy, and caches surface identity without allocating on unchanged frames.
Traffic and bikeshare expose factories through `./layers/traffic` and Traffic and bikeshare expose factories through `./layers/traffic` and
@@ -1144,6 +1217,23 @@ Shot selection updates the highlight without replacing the row, preserving
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately; native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
late file and failed-action completions cannot update removed presentation. late file and failed-action completions cannot update removed presentation.
The panel is titled Authoring and is laid out in four labelled steps: SCENE
(picker on its own row, NEW SCENE, DELETE), SHOTS (+ CAPTURE SHOT, UPDATE SHOT,
the numbered shot list and a select/rename hint), PREVIEW (PLAY, STOP, NEXT) and
SAVE & SHARE (EXPORT, IMPORT, RUN LOG, then the EDIT DETAILS / SHARE SCENE row).
Every button in the step rows has a tooltip, and the status line is a polite
live region. Outside playback a button that would do nothing is disabled:
DELETE and CAPTURE SHOT without a scene, UPDATE SHOT without a selected shot,
PLAY and NEXT when no scene in the project has a shot (both continue into the
following scenes). During playback the authoring and file actions stay disabled
and STOP is enabled, as before; RUN LOG follows the last run, and EDIT DETAILS /
SHARE SCENE keep their own checks. PLAY uses the `scene|PLAY` catalog entry,
apart from the radio's PLAY.
Director clock listeners, interaction observers and scene release failures no
longer leave the director inconsistent, and a phase error is kept when cleanup
also fails.
## Cockpit component ownership ## Cockpit component ownership
Cockpit presentation is separated from its camera/controller behavior. Existing Cockpit presentation is separated from its camera/controller behavior. Existing
@@ -1360,8 +1450,10 @@ and sharpen. Display supplies actions and renders settings; effect execution has
no DOM dependency. Existing presets and the 500 ms transition remain unchanged. no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
Stopping the controller revokes animation before asynchronous UI teardown; final Stopping the controller revokes animation before asynchronous UI teardown; final
destruction removes its stages and restores the borrowed bloom configuration. destruction removes its stages and restores the borrowed bloom configuration.
Snow's sideways sway stays within a fixed range at any session age; Wind scales Snow is no longer a visual preset. A share link that names it, in either the
its amplitude. long or compact form, opens on Normal and drops the Snow shader parameters. A
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
that names Snow also open on Normal.
Sharpen runs only when enabled with intensity above 0.001. At slider zero its Sharpen runs only when enabled with intensity above 0.001. At slider zero its
stage is disabled; raising the slider resumes it when sharpen is enabled. stage is disabled; raising the slider resumes it when sharpen is enabled.
@@ -1476,10 +1568,6 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
panel, and honors keyboard focus on the right. Mobile layout still releases panel, and honors keyboard focus on the right. Mobile layout still releases
desktop allocation styles. Stable Display allocation avoids unnecessary style desktop allocation styles. Stable Display allocation avoids unnecessary style
writes. This extraction does not change panel positions or layout defaults. writes. This extraction does not change panel positions or layout defaults.
Context expansion through the panel owner or adaptive right-rail restoration
closes an open compact Radio disclosure before Context/Space Missions becomes
visible. The mini remains available while Context is collapsed. This fixes the
Radio-overlap path reported in #804 without changing z-index or layout policy.
The right rail skips panels hidden by other CSS (computed `display: none`), but The right rail skips panels hidden by other CSS (computed `display: none`), but
keeps the collapsed panels its own exclusive presentation hides, so automatic keeps the collapsed panels its own exclusive presentation hides, so automatic
collapse can still be undone. collapse can still be undone.
@@ -1487,7 +1575,7 @@ collapse can still be undone.
## Phone layout ## Phone layout
At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and
shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below), shows a bottom tab bar: Layers (Data Layers, with Authoring collapsed below),
Cameras (CCTV and Street Level), Context (Global Context, plus Weather and Cameras (CCTV and Street Level), Context (Global Context, plus Weather and
Recent Imagery when shown), Search (Location) and More (Visual Presets and Map Recent Imagery when shown), Search (Location) and More (Visual Presets and Map
Source above Display). A tab opens one bottom sheet; the same tab, the backdrop Source above Display). A tab opens one bottom sheet; the same tab, the backdrop
@@ -1518,7 +1606,7 @@ panel the user changed inside the sheet stays as they left it. Escape closes the
sheet before a panel's own Escape handling, and the dock's hover auto-close does sheet before a panel's own Escape handling, and the dock's hover auto-close does
not apply inside a sheet. not apply inside a sheet.
Cockpit, recording and clean view close the sheet and hide the tab bar; scene Cockpit, recording and clean view close the sheet and hide the tab bar; scene
playback keeps both so its STOP control in the Scenes panel stays reachable. The playback keeps both so its STOP control in the Authoring panel stays reachable. The
right rail neither hides collapsed siblings nor applies Cyber's one-panel right rail neither hides collapsed siblings nor applies Cyber's one-panel
accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the
voice control, and above an open sheet. The page draws under the notch and voice control, and above an open sheet. The page draws under the notch and
@@ -1968,7 +2056,7 @@ they do not reduce the materialized entity count or establish an FPS gain.
rotation and mission-marker highlight as pointer hover. Focus alone does not rotation and mission-marker highlight as pointer hover. Focus alone does not
select the mission; Enter or Space performs selection. Keyboard and pointer select the mission; Enter or Space performs selection. Keyboard and pointer
preview ownership remain independent when the pointer is parked over the list. preview ownership remain independent when the pointer is parked over the list.
- Radio power controls in the full, compact, and Cockpit surfaces, Search Nearby - Radio power controls in the full and Cockpit surfaces, Search Nearby
Sites, and Clear Selected Layers remain focusable while lifecycle work is in Sites, and Clear Selected Layers remain focusable while lifecycle work is in
progress. They announce busy/disabled semantics and ignore repeat activation progress. They announce busy/disabled semantics and ignore repeat activation
until the operation settles, so async work cannot drop the keyboard ring. until the operation settles, so async work cannot drop the keyboard ring.
@@ -2066,6 +2154,19 @@ throws, that source reports failure without a contradictory success entry.
The existing per-record append continues to support large feeds; sequential The existing per-record append continues to support large feeds; sequential
fetching, trailing-24-hour filtering and partial-success caching are unchanged. fetching, trailing-24-hour filtering and partial-success caching are unchanged.
FIRMS requests retry once over IPv4 when every resolved address fails to
connect. Node's network-family auto-selection gives each address a short
attempt timeout, so on hosts where IPv6 is unreachable a slow IPv4 handshake
was abandoned and the request reported only `fetch failed` (#68). The retry
makes a single IPv4 connection within the same deadline and the same body
cap, and does not follow redirects. HTTP errors, an expired request deadline
and DNS failures are not retried; a per-address connect timeout, which Node 26
reports as `ConnectTimeoutError`, is. A retry answered with a null-body status
(204, 205, 304) or a status above 599 fails that source instead of crashing
the server, and a non-OK response releases its connection at once. FIRMS
warnings include the per-address connection errors and never the request URL,
which embeds the MAP_KEY.
## Installations and map-source guidance ## Installations and map-source guidance
- Keyless mapped installations use OpenFreeMap `landuse` military polygons, - Keyless mapped installations use OpenFreeMap `landuse` military polygons,
@@ -2214,8 +2315,9 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
> to yield to: whoever handles ESC first calls `preventDefault()` **and** > to yield to: whoever handles ESC first calls `preventDefault()` **and**
> `stopImmediatePropagation()`, and the launcher skips `defaultPrevented` events. > `stopImmediatePropagation()`, and the launcher skips `defaultPrevented` events.
> `stopPropagation()` alone does **not** stop later listeners on the same > `stopPropagation()` alone does **not** stop later listeners on the same
> `document` — that is exactly how the compact Radio disclosure made one key > `document` — that is exactly how a disclosure that called it alone made one
> close the disclosure _and_ dismiss the launcher. > key close the disclosure _and_ dismiss the launcher. Cockpit's utility
> disclosures (Display, Radio) claim the key the immediate way.
> >
> **Accepted:** a surface class that never clears means no launcher for that page > **Accepted:** a surface class that never clears means no launcher for that page
> load, with no timeout. None of the four classes is restored at startup, so an > load, with no timeout. None of the four classes is restored at startup, so an
@@ -2254,6 +2356,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
> SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the > SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the
> six zero-intensity style stages anymore (those are disabled; see the > six zero-intensity style stages anymore (those are disabled; see the
> history note in `_initStages`). Hidden tabs stop the render loop. > history note in `_initStages`). Hidden tabs stop the render loop.
> AIS vessels no longer hold the loop while enabled (2026-09-30): vessels move
> only when a poll lands, so the layer requests a frame for every forced pass
> (new data, selection), leaves one trailing frame when the camera moved inside
> the 800 ms visibility cadence, and holds `ais-vessels` only while focus
> emphasis animates or any vessel stays dimmed under a live focus target. The
> trailing frame goes through the layer's injectable runtime clock and is
> cancelled on disable, destroy and reset (`renderGovernor.test.mjs` pins the
> no-self-wake invariant). The 80 ms focus pass also skips hidden sprites whose
> focus state is at rest (`spriteFocusAtRest`), since advancing them changes
> nothing; with a 12k worldwide feed that is most of the fleet.
> The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94 > The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94
> at/above **10 Mm**, so > at/above **10 Mm**, so
> faint stars survive in the corners of a TRUE full-globe view, fading quickly > faint stars survive in the corners of a TRUE full-globe view, fading quickly
@@ -2789,7 +2901,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> failed searches leave the current camera owner untouched, and Context Focus > failed searches leave the current camera owner untouched, and Context Focus
> can return to the preserved Contact. Focus also restores the selected > can return to the preserved Contact. Focus also restores the selected
> aircraft's canonical follow frame after a manual zoom-away. > aircraft's canonical follow frame after a manual zoom-away.
> Visual presets retain the order Normal, CRT, NVG, FLIR, Anime, Noir, Snow. > Visual presets retain the order Normal, CRT, NVG, FLIR, Anime, Noir.
> Configurable preset selection—including same-style reselection—opens the > Configurable preset selection—including same-style reselection—opens the
> shared Parameters surface directly below Detection and scrolls it into > shared Parameters surface directly below Detection and scrolls it into
> view; share-link restoration > view; share-link restoration
@@ -2841,7 +2953,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> Inside Cockpit, the focused summary card is titled Contact in both visible > Inside Cockpit, the focused summary card is titled Contact in both visible
> copy and its accessible control labels. > copy and its accessible control labels.
> The top-center Cockpit vision cycle is the fixed, duplicate-free sequence > The top-center Cockpit vision cycle is the fixed, duplicate-free sequence
> Normal, CRT, NVG, FLIR, Anime, Noir, and Snow. Entry selects the map preset > Normal, CRT, NVG, FLIR, Anime, and Noir. Entry selects the map preset
> already in use, so a FLIR map opens on the existing FLIR item while > already in use, so a FLIR map opens on the existing FLIR item while
> Normal remains available exactly once. NONE is not offered. Cockpit choices > Normal remains available exactly once. NONE is not offered. Cockpit choices
> temporarily override the map preset, while Exit Cockpit and Reset both restore > temporarily override the map preset, while Exit Cockpit and Reset both restore
@@ -2885,7 +2997,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> corridor). Its minimal Display popover exposes Intel HUD, > corridor). Its minimal Display popover exposes Intel HUD,
> Detection, Parameters, and 3D aircraft. During Cockpit, those existing > Detection, Parameters, and 3D aircraft. During Cockpit, those existing
> standard Display controls move into the Cockpit popover and retain their normal > standard Display controls move into the Cockpit popover and retain their normal
> nested interaction: HUD plus Tactical/Operator/Minimal layout, Detection plus > nested interaction: HUD plus Tactical/Minimal/Cyber layout, Detection plus
> Density/Allocation/Fade/Outside tuning, and 3D plus Proximity/All mode. The > Density/Allocation/Fade/Outside tuning, and 3D plus Proximity/All mode. The
> same nodes and state return to the map Display on exit, so Cockpit does not > same nodes and state return to the map Display on exit, so Cockpit does not
> maintain a second control state. **Detection is owned by the CONTACTS > maintain a second control state. **Detection is owned by the CONTACTS
@@ -2940,22 +3052,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> sibling is also removed from the accessibility tree, and focus transfers to > sibling is also removed from the accessibility tree, and focus transfers to
> the expanded utility if a layout transition hides the focused launcher. The > the expanded utility if a layout transition hides the focused launcher. The
> shared map Display, Radio detail, > shared map Display, Radio detail,
> ordinary Global Context, and Scenes surfaces stay hidden in Cockpit. Data > ordinary Global Context, and Authoring surfaces stay hidden in Cockpit. Data
> Layers remains available. Its Cockpit-only stack paints > Layers remains available. Its Cockpit-only stack paints
> above the curved speed and altitude rulers, matching the existing Context, > above the curved speed and altitude rulers, matching the existing Context,
> Signals, Display, and Radio surface ordering without changing map-view > Signals, Display, and Radio surface ordering without changing map-view
> stacking. Narrow mobile Cockpit > stacking. Narrow mobile Cockpit
> viewports suppress the legacy layer stack, peripheral HUD, and secondary > viewports suppress the legacy layer stack, peripheral HUD, and secondary
> Context/briefing panes so the flight instruments and primary controls remain > Context/briefing panes so the flight instruments and primary controls remain
> unobstructed. Outside Cockpit, Radio starts collapsed while off. When Context > unobstructed. Outside Cockpit, Radio starts collapsed while off and lives
> is collapsed, its Radio header icon opens compact controls whose explicit > only in its section inside Context; the detailed panel's normal accordion
> Enable/Disable action owns power; stable close and full-panel buttons own > control owns its disclosure and its Enable/Disable action owns power.
> compact dismissal and one-way detailed expansion independently. When Context > Radio volume in the full and Cockpit surfaces, plus Space Mission
> is already expanded, that same icon skips the floating compact card, expands
> the embedded Radio section, scrolls it into view, and moves keyboard focus to
> its disclosure control. The detailed panel's normal accordion control owns
> its collapse. Compact/detailed state and playback continuity remain shared.
> Radio volume in the full, compact, and Cockpit surfaces, plus Space Mission
> replay speed, use Display / Sharpen's muted 3px rail, circular cyan thumb, > replay speed, use Display / Sharpen's muted 3px rail, circular cyan thumb,
> glow, and mono value treatment. Their larger transparent hit areas, keyboard > glow, and mono value treatment. Their larger transparent hit areas, keyboard
> focus indication, ranges, disabled states, and mission speed scale remain > focus indication, ranges, disabled states, and mission speed scale remain
@@ -3078,7 +3185,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> usable per point, while an uncached absent height still returns 502 rather > usable per point, while an uncached absent height still returns 502 rather
> than becoming a fabricated ground value. A position the upstream answers > than becoming a fabricated ground value. A position the upstream answers
> with a null ellipsoid is reported as an absent height rather than a refresh > with a null ellipsoid is reported as an absent height rather than a refresh
> failure, and is left uncached so a later poll re-asks it. Client geoid > failure, and is left uncached so a later poll re-asks it. A `Retry-After`
> longer than the 10 s retry budget ends the retries at once. After a 429 that
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
> (60 seconds when none, at most 10 minutes). A request whose points are all
> cached, fresh or stale, is still served; a request with any uncached point
> gets 502 and the page uses its geoid fallback for that chunk. Each request
> that was waiting on the upstream when the 429 came back logs one warning;
> requests refused during the pause do not log. Client geoid
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the > fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
> first later successful fetch. Smaller chunks reduce timeout risk; complete > first later successful fetch. Smaller chunks reduce timeout risk; complete
> camera batches still wait for their sequential requests, and unresolved > camera batches still wait for their sequential requests, and unresolved
@@ -3733,7 +3847,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026) ### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in Shipped locales: **en** (source and fallback), **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and **zh-Hant**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md). [`docs/TRANSLATORS.md`](./TRANSLATORS.md).
@@ -3744,8 +3858,8 @@ Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The
value). English has no catalog; every other locale is value). English has no catalog; every other locale is
`src/i18n/locales/<code>.json`, and a missing entry falls back to English. `src/i18n/locales/<code>.json`, and a missing entry falls back to English.
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
`navigator.languages` → English. Chinese tags map to `zh-CN`; tags containing `navigator.languages` → English. Any `pt` tag (`pt-BR`, `pt-PT`) selects `pt`. Chinese tags default to `zh-CN`; Traditional Chinese tags
`Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop
it, so a recipient keeps their own language. The globe picker in the it, so a recipient keeps their own language. The globe picker in the
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
`?lang=` (keeps it only when storage is blocked) and reloads. It asks first `?lang=` (keeps it only when storage is blocked) and reloads. It asks first
@@ -3811,7 +3925,7 @@ its criteria cannot be silently ignored.
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | 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 | | 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 | | 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) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | 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 | | 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) | | 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 | | 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 |
@@ -3851,7 +3965,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`, Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
@@ -4724,13 +4838,6 @@ synchronous playback-control observers run, so reentrant voice cleanup cannot
overwrite a released stream with a stale paused state. Layer overwrite a released stream with a stale paused state. Layer
destruction likewise retains an enabled manager entry when module disable or destruction likewise retains an enabled manager entry when module disable or
destroy fails semantically, preventing an active orphan and allowing retry. destroy fails semantically, preventing an active orphan and allowing retry.
When Context is collapsed, explicitly activating its Radio header icon reveals
the compact transport; hover and focus alone do not open it. When Context is
expanded, the icon instead expands and scrolls to the embedded Radio section.
Its accessible label, controlled region, and expanded state keep describing the
current route throughout enabling, enabled, disabling, and uncertain lifecycle
renders. Both routes preserve Radio power, playback, station, filter, and volume
state.
Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojsonCore.js` (Vite `?url` assets) when toggled on. Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojsonCore.js` (Vite `?url` assets) when toggled on.
@@ -4751,6 +4858,8 @@ silently demoting every later lookup for the session.
- The expanded `CONTEXT` view offers mutually exclusive `CONTACTS` and `SPACE MISSIONS` modes. Selecting `CONTACTS` enables the context-owned Flights, Military Flights, AIS Vessels, and Mapped Installations dependencies only when they are not already user-enabled; selecting `SPACE MISSIONS` enables the recent-launch layer and its Satellite dependency. `CONTACTS` cycles the nearest supported contact of whatever type is selected. Satellites are deliberately excluded from those Awareness cohorts and keep their own tracking UX. Selecting the active mode again returns to the neutral chooser and releases only mode-owned dependencies. - The expanded `CONTEXT` view offers mutually exclusive `CONTACTS` and `SPACE MISSIONS` modes. Selecting `CONTACTS` enables the context-owned Flights, Military Flights, AIS Vessels, and Mapped Installations dependencies only when they are not already user-enabled; selecting `SPACE MISSIONS` enables the recent-launch layer and its Satellite dependency. `CONTACTS` cycles the nearest supported contact of whatever type is selected. Satellites are deliberately excluded from those Awareness cohorts and keep their own tracking UX. Selecting the active mode again returns to the neutral chooser and releases only mode-owned dependencies.
- If a civilian or military aircraft is already tracked when `CONTACTS` becomes operational, that source-owned track is adopted as the Context subject before nearest-contact autofocus. Context rechecks the tracker after its dependencies settle, so a newer selection wins, while an explicit clear during activation prevents fallback from silently selecting a replacement. Cockpit entry remains unavailable until that Context transaction has settled, so its camera takeover cannot clear Cesium tracking before adoption. Adoption does not recreate tracking or transfer camera ownership; it initializes the normal 250 km ring, history, proximity results, and Cockpit Previous/Next state for the original aircraft. - If a civilian or military aircraft is already tracked when `CONTACTS` becomes operational, that source-owned track is adopted as the Context subject before nearest-contact autofocus. Context rechecks the tracker after its dependencies settle, so a newer selection wins, while an explicit clear during activation prevents fallback from silently selecting a replacement. Cockpit entry remains unavailable until that Context transaction has settled, so its camera takeover cannot clear Cesium tracking before adoption. Adoption does not recreate tracking or transfer camera ownership; it initializes the normal 250 km ring, history, proximity results, and Cockpit Previous/Next state for the original aircraft.
- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. A view across the antimeridian is searched around its own centre, with a radius from its real width. - `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. A view across the antimeridian is searched around its own centre, with a radius from its real width.
- The Context header holds only its title and collapse control. It no longer carries a Radio shortcut or a compact Radio player: Radio is the `RADIO` section inside the panel, with its own disclosure and the single `ENABLE` power control. Cockpit keeps its own compact Radio utility.
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
@@ -4768,7 +4877,7 @@ silently demoting every later lookup for the session.
- Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct. - Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct.
- With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip. - With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip.
- The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence. - The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence.
- The right rail's collapsed Display, CCTV, and Context controls use the same compact sizing language as the left rail's collapsed Data Layers and Scenes controls. These compact-state widths do not constrain expanded panel or child-content widths. - The right rail's collapsed Display, CCTV, and Context controls use the same compact sizing language as the left rail's collapsed Data Layers and Authoring controls. These compact-state widths do not constrain expanded panel or child-content widths.
### Motion & Symbology Correctness (June 10, 2026) ### Motion & Symbology Correctness (June 10, 2026)
@@ -5123,8 +5232,8 @@ and unreachable upstream (502/504) separately from road geometry.
first, activates the Contacts sub-view, and returns the settled 250 km window. first, activates the Contacts sub-view, and returns the settled 250 km window.
Its `aircraft` count is the exact civilian-plus-military total when both feeds Its `aircraft` count is the exact civilian-plus-military total when both feeds
can answer, or `unknown` when either component is unavailable. can answer, or `unknown` when either component is unavailable.
- Cockpit's top vision switch cycles all seven map looks: Normal, CRT, NVG, - Cockpit's top vision switch cycles all six map looks: Normal, CRT, NVG,
FLIR, Anime, Noir, and Snow. Entry selects the matching map style without adding FLIR, Anime, and Noir. Entry selects the matching map style without adding
a duplicate carousel item. There is no empty `NONE` entry. a duplicate carousel item. There is no empty `NONE` entry.
### Live AIS Vessels (June 2026) ### Live AIS Vessels (June 2026)
@@ -5267,7 +5376,13 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
deadline, with Photon requests capped at six seconds each. Caller/application deadline, with Photon requests capped at six seconds each. Caller/application
cancellation stops retries and late cache writes. Replacing a location search cancellation stops retries and late cache writes. Replacing a location search
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers candidates, and an unbiased retry for name mismatches. A "place, region" query
prefers a candidate named exactly for the place whose city, county, state,
country or country code (or USA/UK) contains every word of the region, and a
settlement or administrative area over a POI when both fit. Nearby results that
fail that check are retried unbiased. After the region checks of both passes, a
nearby place whose whole name is the query (one with a comma in it) comes next,
then the unbiased name match. Invalid or wrapped bounds returned by providers
are omitted rather than framing the wrong part of the globe. are omitted rather than framing the wrong part of the globe.
A location search that finds nothing, or fails, leaves the field open with the A location search that finds nothing, or fails, leaves the field open with the
@@ -5350,6 +5465,7 @@ request viewbox and continues searching without that geographic preference.
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. - Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`.
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing. - **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed. - **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
@@ -5535,7 +5651,7 @@ apply to `public/` assets or built-preview serving.
- The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored. - The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored.
- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded). - The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded).
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. DISPLAY may remain open beside one contextual panel; CCTV and Context are mutually exclusive. In Tactical HUD, expanding CCTV or Context hides the other contextual launcher while DISPLAY remains independently available. The most recently opened right-rail panel owns the constrained lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. Minimal and other HUD layouts retain the collapsed launchers; when their active panel exceeds the measured corridor, the rail reserves sibling heights and gaps and scrolls the active panel internally. - Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. DISPLAY may remain open beside one contextual panel; CCTV and Context are mutually exclusive. In Tactical HUD, expanding CCTV or Context hides the other contextual launcher while DISPLAY remains independently available. The most recently opened right-rail panel owns the constrained lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. Minimal and other HUD layouts retain the collapsed launchers; when their active panel exceeds the measured corridor, the rail reserves sibling heights and gaps and scrolls the active panel internally.
- At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Scenes was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would also clip the Context radio popover and would not remove the vertical scrollbar. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet. - At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Authoring was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would not remove the vertical scrollbar. Context draws no glow at all (`display: none`), so it is not part of that pin. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet.
- `STYLE PRESETS` and `LOCATIONS` start collapsed, expand on intentional hover/click, and auto-collapse after hover leave delay. - `STYLE PRESETS` and `LOCATIONS` start collapsed, expand on intentional hover/click, and auto-collapse after hover leave delay.
- Collapsed mini-status indicators show active style and active location/landmark. - Collapsed mini-status indicators show active style and active location/landmark.
- Detection mode is user-controlled and should persist when switching styles. Since 2026-08-22 it also STARTS on — Dense @ 75% for every style on a first run, Normal included — as a `GLOBAL_POST_DEFAULTS` baseline that does NOT set `_detectionUserOverridden`. Exception (unchanged): selecting a military style (CRT/NVG/FLIR) auto-enables the same Dense preset, but only until the user manually changes detection this session (`_detectionUserOverridden` gate), after which style switches never touch it. - Detection mode is user-controlled and should persist when switching styles. Since 2026-08-22 it also STARTS on — Dense @ 75% for every style on a first run, Normal included — as a `GLOBAL_POST_DEFAULTS` baseline that does NOT set `_detectionUserOverridden`. Exception (unchanged): selecting a military style (CRT/NVG/FLIR) auto-enables the same Dense preset, but only until the user manually changes detection this session (`_detectionUserOverridden` gate), after which style switches never touch it.
@@ -5600,11 +5716,11 @@ apply to `public/` assets or built-preview serving.
be enabled, and a civilian or military aircraft must be tracked. The visible be enabled, and a civilian or military aircraft must be tracked. The visible
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
flight-layer selection cannot enter a context-dependent cockpit. flight-layer selection cannot enter a context-dependent cockpit.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Scenes and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. - **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Scenes keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor. - **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
@@ -5758,7 +5874,11 @@ easier to meet (detection is now on more often), but does not create it.
coverage number. `stats.loading` covers outstanding flow work as well as the coverage number. `stats.loading` covers outstanding flow work as well as the
road fetch, so a flow failure landing after the first paint still ends the road fetch, so a flow failure landing after the first paint still ends the
shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`, shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`,
never on `mode === 'live'` alone. never on `mode === 'live'` alone. When the drawn flow came from the proxy's
cached STALE-BUDGET / STALE-ERROR tiles, `getStats()` reports `stale: true`
and `oldestTileAt`, the row reads STALE with the oldest tile's age, and the
sync chip reads
`STALE · Roads: <source> · Flow: cached TomTom tiles` instead of LIVE.
- Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY` - Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY`
is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode: is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode:
TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy
@@ -6061,6 +6181,11 @@ reception does not interrupt internet radio.
Changes apply to the open receiver without reconnecting. Changes apply to the open receiver without reconnecting.
- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard, - **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard,
aircraft with a fresh position and the IQ level. aircraft with a fresh position and the IQ level.
- **Error text.** A receiver error sets two fields (`errorState()` in
`src/sdr/controller.js`): `message` is the UI language for the Radio card,
`errorText` is the English text the Local ADS-B layer reports as its stats
`error` (`localAdsbStatus()`, falling back to `message` when a receiver sets
none); the Layers panel translates it where it shows it.
- **Devices.** An already-authorized receiver opens without the WebUSB - **Devices.** An already-authorized receiver opens without the WebUSB
picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the
1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels. 1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels.
+2 -2
View File
@@ -1,6 +1,6 @@
# Director authoring and file sharing # Director authoring and file sharing
Open Scenes and select a scene and shot. Existing capture, update, playback and Open Authoring and select a scene and shot. Existing capture, update, playback and
whole-project export controls remain available. Authored scene content, source whole-project export controls remain available. Authored scene content, source
links, assets and contributor credit are preserved. links, assets and contributor credit are preserved.
@@ -42,7 +42,7 @@ field definitions. This is a basic draft editor, not a node graph or path editor
SHARE SCENE exports only the selected scene. Download scene JSON preserves its SHARE SCENE exports only the selected scene. Download scene JSON preserves its
camera, timing, layers, anchors, manifests, interactions and attribution, but camera, timing, layers, anchors, manifests, interactions and attribution, but
contains no asset bytes. The recipient needs the referenced source registrations contains no asset bytes. The recipient needs the referenced source registrations
and files. Existing EXPORT PRESETS continues to export the entire project. and files. EXPORT (formerly EXPORT PRESETS) continues to export the entire project.
For a bundle, select the declared pack files or a folder, then Download asset For a bundle, select the declared pack files or a folder, then Download asset
bundle. Folder paths match a manifest path either directly or after removing the bundle. Folder paths match a manifest path either directly or after removing the
+1 -1
View File
@@ -35,7 +35,7 @@ A module the server, tools or other portable code also imports must not load the
<input placeholder="Search any location..." data-i18n-placeholder /> <input placeholder="Search any location..." data-i18n-placeholder />
``` ```
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading. `data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading.
## Rules ## Rules
+2 -1
View File
@@ -97,7 +97,8 @@ operational sample contained 3,909 observations and two selected labels, but its
live aircraft and traffic rows were empty, so it remains a limited stress case. live aircraft and traffic rows were empty, so it remains a limited stress case.
Snow, Noir, dense detection, and text-heavy combined layers are the clearest Snow, Noir, dense detection, and text-heavy combined layers are the clearest
controlled comparison points for later optimization work. controlled comparison points for later optimization work. Snow has since been
retired as a visual preset; its row records the measurement as taken.
## Keyed live sources ## Keyed live sources
+1 -1
View File
@@ -1,6 +1,6 @@
# Scene document format # Scene document format
Scenes exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md) Authoring exports a UTF-8 JSON project. Version 5 adds [data packs](DIRECTOR-DATA-PACKS.md)
and is the write format. Versions 1, 2, 3, 4 and unversioned legacy files migrate on and is the write format. Versions 1, 2, 3, 4 and unversioned legacy files migrate on
import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with
a field path before the current project, selection or saved bytes change. a field path before the current project, selection or saved bytes change.
+36 -31
View File
@@ -90,35 +90,35 @@ probability of about 255 in 256 (many of them already fail as base64url).
## Field registry (top level) ## Field registry (top level)
| # | State key | Type | Default | Encoding | | # | State key | Type | Default | Encoding |
| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- | | --- | ---------------------------- | ----- | ----------- | ----------------------------------------------------------------- |
| 1 | `view.kind` | enum | see below | 0 place, 1 thing | | 1 | `view.kind` | enum | see below | 0 place, 1 thing |
| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ | | 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ |
| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ | | 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ |
| 4 | `alt` | sint | 800 | whole metres | | 4 | `alt` | sint | 800 | whole metres |
| 5 | `heading` | angle | 0 | whole degrees, see angles | | 5 | `heading` | angle | 0 | whole degrees, see angles |
| 6 | `pitch` | sint | −35 | whole degrees | | 6 | `pitch` | sint | −35 | whole degrees |
| 7 | `roll` | angle | 0 | whole degrees | | 7 | `roll` | angle | 0 | whole degrees |
| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z | | 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z |
| 9 | booleans | flags | 0 | bit set when the value differs from default | | 9 | booleans | flags | 0 | bit set when the value differs from default |
| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers | | 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers |
| 11 | `layerState.options` | LEN | none | packed option triples, see layers | | 11 | `layerState.options` | LEN | none | packed option triples, see layers |
| 12 | `panelState` | LEN | none | packed panel entries, see panels | | 12 | `panelState` | LEN | none | packed panel entries, see panels |
| 13 | thing | LEN | none | sub-message, see thing views | | 13 | thing | LEN | none | sub-message, see thing views |
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow | | 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) |
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | | 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber | | 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
| 17 | `bloomIntensity` | uint | style | | | 17 | `bloomIntensity` | uint | style | |
| 18 | `bloomVersion` | uint | 2 | | | 18 | `bloomVersion` | uint | 2 | |
| 19 | `sharpenIntensity` | uint | style | | | 19 | `sharpenIntensity` | uint | style | |
| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE | | 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE |
| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 | | 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 |
| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED | | 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED |
| 23 | `detectionFadePct` | uint | style | 0–40 | | 23 | `detectionFadePct` | uint | style | 0–40 |
| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 | | 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 |
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 | | 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | | 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | | 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
Field 1 absent means `thing` when field 13 is present and `place` otherwise, 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. so v1 never writes it; a later view kind writes its code.
@@ -129,6 +129,9 @@ table learns its code. A new style's settings are encoded against its own
defaults, while a decoder that does not know the style uses Normal's; adding a defaults, while a decoder that does not know the style uses Normal's; adding a
style also extends the pinned style-defaults table. 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.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
@@ -163,7 +166,7 @@ for it.) In v1:
| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 | | thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 |
| anime | saturation 1, edgeThick 0.5 | | anime | saturation 1, edgeThick 0.5 |
| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 | | noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 |
| snow | density 0.6, wind 0.5 | | snow | retired: decodes as normal; its parameters are dropped |
These are frozen like every other default: the codec reads them from the These are frozen like every other default: the codec reads them from the
app's tables today, and a test pins them, so if a table moves the old values app's tables today, and a test pins them, so if a table moves the old values
@@ -342,7 +345,9 @@ the app that can open it.
values in the codec (see Style defaults). The same applies values in the codec (see Style defaults). The same applies
to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults. to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults.
4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel 4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel
numbers and style parameter tokens only grow. numbers and style parameter tokens only grow. A style the app no longer
offers keeps its code: `snow` (6) is retired, so a link that names it
opens as Normal and the parameters written for it are dropped.
5. **Add, don't change.** A new setting takes the next free field number 5. **Add, don't change.** A new setting takes the next free field number
(28 onwards; fields 1–15 have one-byte tags and are all taken). A new (28 onwards; fields 1–15 have one-byte tags and are all taken). A new
boolean takes the next flag bit instead, which costs nothing when it is at boolean takes the next flag bit instead, which costs nothing when it is at
+2 -1
View File
@@ -92,7 +92,8 @@ pitch), data layers, visual style, map imagery, and an aircraft, military
aircraft or satellite to follow. `createView` builds and bounds one, aircraft or satellite to follow. `createView` builds and bounds one,
`viewToParams` and `viewFromParams` write and read it in the share-link `viewToParams` and `viewFromParams` write and read it in the share-link
format the app restores, and `viewUrl` gives the address that opens it. The format the app restores, and `viewUrl` gives the address that opens it. The
style names are the ones share links use. Ships cannot be followed from a style names are the ones share links use; a retired style (`snow`) is read
as `normal`. Ships cannot be followed from a
link yet. An aircraft's `follow` may ask for its cockpit view link yet. An aircraft's `follow` may ask for its cockpit view
(`cockpit: true`); links cannot carry that and open the app following it. (`cockpit: true`); links cannot carry that and open the app following it.
+2 -2
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View # Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`). Shipped languages: **English** (source and fallback), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
## How it works ## How it works
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## Language notes ## Language notes
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md). Read it before translating, and add a term there when you settle on one. Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
+108
View File
@@ -0,0 +1,108 @@
# German (`de`)
Catalog: `src/i18n/locales/de.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#908).
## Style
- Standard German that reads naturally in Germany, Austria and Switzerland; ß where it belongs (Straße, schließen), no Swiss ss.
- No direct address: neither «Sie» nor «du». Actions and hints use the neutral infinitive: «Erneut versuchen», «Kamera anklicken zum Aktivieren», «Zuerst eine Aufnahme wählen», «Mikrofonzugriff erlauben und erneut versuchen». "Your" becomes the article or «eigene»: «Eigener RTL-SDR-Empfänger», «Die IP-Adresse ist für diesen Sender sichtbar».
- Nouns are capitalized, as German requires; otherwise sentence case, not Title Case: "Live Flights" → «Live-Flüge», "Provider Settings" → «Anbieter-Einstellungen». UPPERCASE English stays UPPERCASE: «LIVE-DATEN AUS», «KEINE ENTWARNUNG». In UPPERCASE, ß is written SS: «SCHLIESSEN», «STRASSENEBENE».
- Compounds are written as one word or with a hyphen next to a name, code or English term: «Datenebene», «Wetterkacheln», «TomTom-Schlüssel», «AIS-Schiffe», «Street-View-Fallback». Do not split them with spaces.
- Short labels, chips and HUD readouts stay short. German runs about a third longer than English, so prefer a short word or a standard abbreviation with a period over a wrapped line: REICHW., GESCHW., NIEDERSCHL., SONST., NACHR., VERST., VGL., KAL., KOMM. (a bare «KOMM» reads as the imperative)
- The avionics codes HDG, BRG, CRS, DEST, SPD, ALT, GS, TRK, V/S, FOV and SQUAWK stay in English, as in the cockpit. Words used in a sentence are translated: heading → «Kurs», bearing → «Peilung», range → «Reichweite».
- A dash between clauses is a spaced en dash « – »; a colon is fine where it explains: «Feed ausgefallen – neue Versuche in größeren Abständen», «SIMULIERT: Verkehrsdienst nicht erreichbar». Quotes are „…“.
- Status and waiting messages use the neutral infinitive too: «Warten auf Aktualisierung», not «Warte auf …». Reasons inserted after a dash stay lower case: «Szene nicht gespeichert – das Projekt ist größer als 5 MiB».
- A layer named in a sentence takes its panel name in quotes: «Die Ebene „Live-Flüge“ ist nicht aktiviert», «die Ebene „Live-Flüge“ lädt Daten für den jeweiligen Bildausschnitt».
- Rolling time windows say their length, so they cannot read as the previous calendar period: LAST YEAR → «LETZTE 12 MONATE», LAST MONTH → «LETZTE 30 TAGE».
- Keyboard keys are named as on German keyboards where they differ: Leertaste, Rücktaste, Umschalt; Esc, Enter, Tab and letter keys stay.
- Decimal comma in fixed text: «M4,4», «28,2710° N». Thousands separator is a period: «1.000 km». Formatted numbers come from `Intl` and are already German.
- Units: km, m, km/h, m/s, kn (knots, also in the cockpit), ft (feet), hPa, MW, h, min, s, T (days, in short readouts); in UPPERCASE text KM, KM/H, KN, FT, MIN, H, T («VOR {count} H»). A space between number and unit, in compact readouts too: «{value} m», «{count} min», «50 %», «{speed} KN», «{count} / 30 T». `°` sticks to the number.
- "ago" goes before the time: «vor {count} min», «vor {value}». When a placeholder already holds a localized "ago" text (`{age}` in `STALE · cached {age}`, `{ago}`), do not add «vor» again.
- Compass points use O for Ost: N, NNO, NO, ONO, O, OSO, SO, SSO, S, SSW, SW, WSW, W, WNW, NW, NNW; hemisphere letters N, S, O, W.
- Routing: direction words are lowercase adverbs (links, leicht rechts, scharf links, geradeaus), so a sentence never starts with one: «Auf {road} {direction} abbiegen», «Hier {direction} abbiegen», «Weiter {direction}». «Hier», not «Dann»: the step card already says «dann {leg}» and «Nächster Schritt: …». At a fork the verb is «weiter», which also works with «geradeaus»: «An der Gabelung {direction} weiter». Roundabout exits are ordinals with a period: «die {exit} Ausfahrt» → «die 2. Ausfahrt». A lane is named by where it leads, not by its position: «Die geradeaus führende Spur benutzen», «die leicht nach links führende», «die scharf nach rechts führende» (not «halblinke» or «äußerst linke»).
## Plurals
A `tn()` entry needs two forms. `Intl.PluralRules('de')` picks:
| Form | Counts | Example |
| ------- | --------------------------- | --------- |
| `one` | 1 | 1 Kamera |
| `other` | everything else: 0, 2, 1,5… | 3 Kameras |
Keep the verb in agreement: «{count} Station meldet» / «{count} Stationen melden». Abbreviated units and codes do not change: «{count} NACHR.», «{count} EMPFANGEN».
## Glossary
| English | Deutsch |
| ---------------------------------------------- | ---------------------------------------------------------------------- |
| layer / data layer | Ebene / Datenebene |
| feed / source / data source / input (cockpit) | Feed / Quelle / Datenquelle / Quelle |
| LIVE (status) / live data | LIVE / Live-Daten |
| SIMULATED | SIMULIERT |
| stale / degraded / unavailable | veraltet / eingeschränkt / nicht verfügbar |
| retry / retrying / retrying slowly | neuer Versuch / erneut versuchen / neue Versuche in größeren Abständen |
| fallback | Fallback |
| key (API key) / POWER UP / POWERED UP | Schlüssel / STARTKLAR / HOCHGEFAHREN |
| Provider Settings | Anbieter-Einstellungen |
| ON / OFF (state chips) | EIN / AUS |
| enable / disable | aktivieren / deaktivieren |
| globe / map / map source / map stack / basemap | Globus / Karte / Kartenquelle / Kartenstapel / Basiskarte |
| aircraft / flight / military flights | Luftfahrzeug (HUD and short labels: Flugzeug) / Flug / Militärflüge |
| vessel, ship | Schiff |
| satellite / shell (Starlink) / DENSE | Satellit / Schale / DICHT |
| launch (rocket) / launch site / space mission | Start / Startplatz / Raummission |
| ascent / replay / payload / stage / downrange | Aufstieg / Replay, Wiedergabe / Nutzlast / Stufe / Downrange |
| contact / tracking | Kontakt / Verfolgung |
| installation / site | Anlage / Standort |
| mapped / observed | kartiert / beobachtet (Bhote chip OBSERVED IMAGERY: REALE BILDDATEN) |
| camera / CCTV camera / camera badge | Kamera / CCTV-Kamera / Kamera-Markierung |
| earthquake / active fire / fire perimeter | Erdbeben / aktiver Brand / Brandgrenze |
| cyclone / advisory (NHC/CPHC) / cone | Wirbelsturm / Lagebericht (not «Warnung») / Kegel |
| issued (advisory, forecast) | ausgegeben |
| radio station | Sender |
| broadcast FM | UKW |
| road / flow (traffic) / flow coverage | Straße / Verkehrsfluss / Live-Abdeckung |
| transit / bike share | ÖPNV / Bikesharing |
| scene / shot / scene recipe | Szene / Aufnahme / Szenenrezept |
| capture shot / camera move | Aufnahme speichern / Kameraflug |
| anchor / data pack / bundle | Anker / Datenpaket / Bundle |
| story beat / evidence beat | Story-Schritt / Beweisschritt |
| briefing / panel / dock | Briefing / Bereich / Dock |
| cockpit | Cockpit |
| preset / visual preset | Preset / visuelles Preset |
| Scope (circular viewport mask) / keyhole | Scope / Schlüsselloch |
| feather / fade | Weichzeichnung / Ausblenden |
| overlay / detection overlay | Overlay / Erkennungs-Overlay |
| Clean UI | Reine Ansicht |
| Night vision / Thermal / Snow (cockpit styles) | Nachtsicht / Wärmebild / Schnee |
| heading / altitude / ground speed | Kurs / Höhe / Geschwindigkeit über Grund (short: GESCHW. ÜBER GRUND) |
| HDG / BRG / CRS / DEST / SPD / FOV | unchanged |
| RANGE / PITCH / HGT (CCTV calibration chips) | REICHW. / NEIGUNG / HÖHE |
| PREV / NEXT | ZURÜCK / WEITER |
| RESUME (radio transport button) | FORTSETZEN (not «WEITER», which is NEXT in the same row) |
| nearest (spatial) | nächstgelegen (always; «nächste» is NEXT) |
| expand / collapse (sections) | ausklappen / einklappen |
| L / R (relative bearing) | L / R |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NEWS / LOKAL |
| NOT AN ALL-CLEAR | KEINE ENTWARNUNG |
| Street Level / Street View (Google) | Straßenebene / Street View |
| Recent Imagery / box / pin (a day) | Aktuelle Bilddaten / Bereich / fixieren |
| frame (bring into view) | ins Bild nehmen |
| mark (map annotation) / outline | Markierung / Umriss |
| voice (control) / voice session | Sprachsteuerung / Sprachsitzung |
| YOU / THIS / HERE (voice card labels) | EINGABE / DIES / HIER |
| click / click to … | anklicken / … klicken |
| tap (touch) | Antippen (not «Tipp», which is a hint) |
| press / tap a key | Taste drücken / kurz drücken (never «tippen» for keys) |
| tilted view | Schrägansicht |
| card (host card) / voice card | Eintrag (in hints), Infokarte / Sprachkarte |
| label (on the map) / clear (switch off layers) | Beschriftung / ausschalten (not «löschen») |
| station tag (radio filter) | Kategorie |
| DIRECTORY BAND / {category} BAND (radio dial) | SENDERSKALA / SENDERSKALA · {category} (not «-BAND»: «ROCK-BAND») |
| MIC (dock button) | MIKRO |
| frame (weather, video) / image | Frame / Bild |
| collapse / landslide (Bhote Koshi) | Erdrutsch (not «Abbruch», which reads as "cancel") |
| website (radio station) | Website (SENDER-WEBSITE; «Standort» is a physical site) |
+105
View File
@@ -0,0 +1,105 @@
# French (`fr`)
Catalog: `src/i18n/locales/fr.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#907); the French part of meirm's #312 was used for comparison.
## Style
- Standard French that reads naturally in France, Belgium, Switzerland and Canada; avoid regionalisms.
- Address the user with "vous" imperatives: « Cliquez », « Activez », « Réessayez ». Tooltips that name an action use the infinitive: « Réduire le panneau », « Réinitialiser la caméra ». UPPERCASE HUD instructions also use the infinitive: « CLIQUER SUR UNE CAMÉRA POUR L'ACTIVER ».
- Sentence case, not Title Case: "Live Flights" → « Vols en direct ». UPPERCASE English stays UPPERCASE, accents included: « ÉTAT », « PRÉC. ».
- Typography: a narrow no-break space (U+202F) goes before `:` `;` `?` `!` and `%`, and inside « » quotes: `Supprimer le plan « {shot} » ?`. It keeps the sign on the same line as the word before it. Do not type a plain space there. The same narrow no-break space separates thousands in a number written into the text: « 1 000 km ». Ellipsis is `…`, not `...`. Dashes keep their spaces: « flux indisponible — réessayez ».
- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: PRÉC., SUIV., RÉINIT., ENREG., INDISP., DIST.
- The HUD codes HDG, BRG, CRS, FOV, DEST, SPD and ALT stay in English everywhere, as in cockpit avionics. Words are translated: RANGE → « PORTÉE », heading in a sentence → « cap », ground-track course → « route » (« ROUTE 270° »).
- ON and OFF stay as they are on HUD toggles and chips (« CCTV ON », « PROJECTION OFF »); in a sentence use « activé » / « désactivé ».
- Keyboard keys stay in English: Space, Esc, Enter, Tab, Shift, Backspace, H, 1–9 (« Maintenez Space pour parler », « ESC pour fermer »).
- Units: km, m, km/h, m/s, nd (knots), ft (feet), h, min, s, with a space before the unit: « 250 km », « il y a 5 min »; in UPPERCASE text KM, KM/S, ND, FT, MIN. MW, dBZ, hPa and `°` stay. Decimal comma in fixed numbers: « 28,2710° N », « M4,4 ». Money: « 12 k$ », « 3 M$ », « 2 Md$ ».
- "ago" goes before the time: « il y a {count} min ». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add « il y a » again.
- Round-about exit ordinals: 1re, 2e, 3e… (« prenez la 1re sortie »).
## Plurals
A `tn()` entry needs three forms. `Intl.PluralRules('fr')` picks:
| Form | Counts | Example |
| ------- | -------------------------------------- | -------------------- |
| `one` | 0 and 1, fractions below 2 (1,5) | 1 caméra, 0 caméra |
| `many` | 1 000 000, 2 000 000… (exact millions) | 1 000 000 de caméras |
| `other` | everything else: 2–999 999, 2,5… | 3 caméras |
The `many` form is the `other` text with « de » (« d' » before a vowel) after the number: « {count} de couches effacées », « {count} D'ENTRÉES INCONNUES ». When the words after the number cannot agree (an UPPERCASE readout, a list), put the number after a colon in every form: « CAPTÉS : {count} ». The same goes for a layer name whose gender and number vary: « Couche activée : {label} », not « {label} activé », or a tool name: « Outil actif : {name} », not « {name} est actif ».
## Glossary
| English | Français |
| ------------------------------------------------------------ | ----------------------------------------------------------------------- |
| layer / data layer / DATA LAYERS (panel title) | couche / couche de données / COUCHES |
| feed / source / data source | flux / source / source de données |
| LIVE (status) / live data | DIRECT / données en direct |
| SIMULATED | SIMULÉ |
| stale / degraded / unavailable | obsolète / dégradé / indisponible |
| retry / retrying (status: RETRYING X) | nouvel essai / réessayez (NOUVEL ESSAI : X) |
| rate-limited | limite de requêtes atteinte |
| key (API key) / POWER UP / POWERED UP | clé (clé API) / ALLUMER / ALLUMÉ |
| Provider Settings | paramètres des fournisseurs |
| enable / disable | activer / désactiver |
| globe / map / map source / basemap / map stack | globe / carte / source de carte / fond de carte / fond de carte |
| tile (map) | tuile |
| aircraft / flight / military flights | aéronef (avion in short copy) / vol / vols militaires |
| vessel, ship / tanker (AIS type) | navire / navire-citerne |
| satellite / shell (Starlink) / DENSE | satellite / couche de la constellation / DENSE |
| launch / launch site / space mission | lancement / site de lancement / mission spatiale |
| ascent / replay / payload / stage / downrange | ascension / rejeu / charge utile / étage / distance au sol (DIST. SOL) |
| contact / tracking / track (HUD) | contact / suivi / piste |
| brackets (HUD corner brackets around contacts) | cadres de ciblage |
| installation / site | installation / site |
| mapped / observed | cartographié / observé |
| camera / CCTV camera / ALPR camera | caméra / caméra CCTV / caméra LAPI |
| earthquake | séisme |
| active fire (FIRMS detection) / wildfire | feu actif / incendie |
| contained / containment (wildfire) | circonscrit / part circonscrite (not « maîtrisé », which is controlled) |
| advisory (NHC/CPHC cyclone) | bulletin |
| forecast / lead hours / valid / issued | prévision / échéances / valide / émise |
| AQHI (Canada) | CQAS (cote air santé) |
| radio station / tune | station / régler |
| bikeshare / transit | vélos partagés / transports en commun |
| route (directions) / turn (maneuver) | itinéraire / manœuvre |
| scene / shot / scene recipe | scène / plan / recette de scène |
| bundle / data pack / anchor | archive / paquet de données / ancre |
| import / export (noun: file, scene, project) | importation / exportation (not « import » / « export ») |
| capture camera as anchor / set move start (scene editor) | ancre à la position de la caméra / vue actuelle comme départ |
| story beat | étape du récit |
| briefing / panel / dock | briefing / panneau / dock |
| cockpit | cockpit |
| preset / visual preset | préréglage / préréglage visuel |
| Scope (circular viewport mask) / keyhole | lunette / ouverture |
| detection overlay | surcouche de détection |
| Clean UI | vue épurée |
| Street Level (layer) / Street View (Google) | Niveau rue / Street View |
| Recent Imagery / box | imagerie récente / zone |
| Daily overview / overview (Recent Imagery VIIRS product) | vue quotidienne (one name for the product, not « vue d'ensemble ») |
| pin (Draw tool) / pin (Recent Imagery) | épingle / épingler |
| Cyber (HUD layout, effect) | Cyber |
| heading / course (ground track) / altitude / ground speed | cap / route / altitude / vitesse sol |
| HDG / BRG / CRS / FOV / DEST / SPD / ALT | unchanged |
| RANGE / PITCH / HGT (CCTV calibration chips) | PORTÉE / INCL. / HAUT. |
| PREV / PREVIOUS / NEXT (chips) | PRÉC. / PRÉC. / SUIV. |
| Previous / Next (button text) | Précédent / Suivant |
| L / R (relative bearing) | G / D |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / ACTUS / LOCAL |
| compass points (N, NNE, …, W, NW) | N, NNE, …, O, NO |
| NOT AN ALL-CLEAR | PAS UN FEU VERT |
| AI agent | agent IA |
| share (a view or link) | partager |
| click / click to … | cliquez / cliquer pour … |
| map heading (compass, north-up control) | orientation (de la carte) |
| subject (cockpit / shared contact) | cible |
| cloud cover / CLOUD (briefing readout) | nébulosité / NÉBULOSITÉ |
| LEVEL (cockpit horizon guide) | HORIZON |
| CALIBRATED / CURATED / RAW PRIOR (CCTV pose) | CALIBRÉE / VÉRIFIÉE / ESTIM. INITIALE |
| DATASHEET / SPEC FOV (CCTV lens range) | FICHE TECHNIQUE / FOV CONSTRUCTEUR |
| PORT TENDER (AIS type) | SERVITUDE PORT. (navire de servitude portuaire) |
| ChatGPT sign-in (action) / opening sign-in / sign-in expired | connexion / page de connexion / la session a expiré |
| authored shot (scene) | plan scénarisé |
| report / record (USGS event card) / readout, reading | rapport / relevé (a measured value only) |
+108
View File
@@ -0,0 +1,108 @@
# Italian (`it`)
Catalog: `src/i18n/locales/it.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#912).
## Style
- Standard Italian as in Italian software UIs. Address the user with informal "tu" imperatives: «Aggiungi», «Attiva», «Scegli», «Incolla», «Riprova». Tooltips that name an action use the same imperative: «Espandi il pannello», «Ripristina la camera».
- Sentence case, not Title Case: "Live Flights" → «Voli live», "Space Missions" → «Missioni spaziali». UPPERCASE English stays UPPERCASE: «LIVE», «NON DISPONIBILE».
- Quotes inside Italian text are straight double quotes: «Eliminare lo scatto "{shot}"?». Use the accented capitals (È, À) in UPPERCASE text: «VELOCITÀ», «CITTÀ».
- Errors read as a statement, not a past participle on its own: "Search failed" → «Ricerca non riuscita»; "Could not …" → «Impossibile …». A dash that joins a cause and a hint becomes a colon: «Chiave API rifiutata: controlla AISSTREAM_API_KEY». The same goes for a label and its hint in a tooltip: «Direzione (bussola °): clic per digitare».
- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: REGOLAZ., RILEV., INCL., ALT., VEL., STIM., PROIEZ., ESP.; PREC / SUCC for PREV / NEXT and PREVIOUS / NEXT.
- The HUD codes HDG, BRG, CRS, FOV and DEST stay in English, as in cockpit avionics. RANGE is «PORTATA». Words used in a sentence are translated: heading → «prua» (aircraft, ships) or «direzione» (camera, buses, trams, trains), course → «rotta».
- Keyboard keys stay in English: Space, Enter, Esc, Tab, Shift, Backspace, H, 1–9 («Tieni premuto Space per parlare», «Premi Enter per riprovare»).
- Decimal comma in numbers written in the text, magnitudes included: «28,2710° N», «M5,2». Numbers formatted by the code follow `Intl` and need nothing.
- Amounts: thousands, millions and billions are written out or abbreviated in Italian, not with K/M/B: «{amount} mila $», «{amount} mln $», «{amount} mld $».
- Units: km, m, km/h, m/s, kn (knots), ft (feet), h, min, s; in UPPERCASE text KM, KM/S, KT, FT, MIN, H. "m" is always metres, so minutes are «min» («5 min fa»). MW stays MW. `°` and `%` stay.
- "ago" goes after the time: «{count} min fa». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «fa» again.
- A feature name used as a sentence subject gets a head noun so the verb agrees: «La modalità Missioni spaziali isola…», «Il pannello Impostazioni provider è disattivato…», «Il livello {layer} è attivo…», «Lo strumento {name} è attivo…». A short card title with a placeholder name puts the state after a colon: «{label}: attivato». The same goes for a placeholder list of names: «Attivazione recente: {layers}». A count in a plain `t()` entry (no plural forms) that can be 1 goes after a label, so nothing has to agree with it: «Record accettati: {accepted} su {raw}».
- English loanwords that Italian UIs use as they are stay in English: feed, live, cockpit, briefing, preset, overlay, layout, snapshot, tile, provider, timeout, payload, bloom, scope.
## Plurals
A `tn()` entry needs three forms. `Intl.PluralRules('it')` picks:
| Form | Counts | Example |
| ------- | -------------------------------------- | ----------------------- |
| `one` | 1 | 1 telecamera |
| `many` | 1 000 000, 2 000 000… (exact millions) | 1 000 000 di telecamere |
| `other` | everything else: 0, 2–999 999, 1,5… | 3 telecamere |
The `many` form is the `other` text with «di» after the number when a noun follows: «{count} di telecamere caricate», «{count} DI CHIAVI IN ATTESA». When no noun follows the number («{count} vicine», «{count} RICEVUTI»), `many` repeats `other`. Nouns that do not change in the plural keep one form for all three (`{count} file`, `{count} data center`), with «di» in `many`.
## Glossary
| English | Italiano |
| --------------------------------------------- | ------------------------------------------------------------ |
| layer / data layer | livello / livello dati |
| feed / source / data source | feed / fonte / fonte dati |
| LIVE (status) / live data / live (feed state) | LIVE / dati live / live |
| SIMULATED | SIMULATO |
| stale / degraded / unavailable | obsoleto / degradato / non disponibile |
| retry / retrying | riprova / nuovo tentativo |
| rate limited | limite di richieste (raggiunto) |
| key (API key) / POWER UP / POWERED UP | chiave / ACCENDI / ACCESO |
| Provider Settings | Impostazioni provider |
| ON / OFF (state chips) | ON / OFF |
| enable / disable | attivare / disattivare |
| globe / map / map source / map stack | globo / mappa / fonte mappa / stack mappa |
| basemap / box (Recent Imagery area) | mappa di base / riquadro |
| aircraft, plane / flight / military flights | aereo / volo / voli militari |
| vessel, ship | nave |
| satellite / shell (Starlink) / DENSE | satellite / shell / DENSO |
| SPARSE / BALANCED / DENSE (detection modes) | RADO / BILANCIATO / DENSO (agree with «overlay») |
| broadband (Starlink shell) | a banda larga |
| VISUAL (CelesTrak group) | VISIBILI |
| launch (rocket) / launch site / space mission | lancio / sito di lancio / missione spaziale |
| ascent / replay / payload / stage | salita / replay / payload / stadio |
| contact / tracking | contatto / tracciamento |
| installation / site | installazione / sito |
| mapped / observed | mappato / osservato |
| camera (view) / camera, CCTV camera | camera / telecamera, telecamera CCTV |
| earthquake / active fire / fire perimeter | terremoto / incendio attivo / perimetro dell'incendio |
| lightning strike / cloud (cover, %) | fulmine / nuvole («non singoli fulmini», «20% di nuvole») |
| incident (wildfire, Type N) / scene incident | evento (Evento di tipo N, Incendio senza nome) / evento |
| trigger record (USGS card) / OPEN SOURCE link | scheda / APRI LA FONTE |
| advisory (NHC/CPHC cyclone) / cone | bollettino / cono di incertezza |
| forecast lead hours (cyclone points) | scadenze di previsione in ore |
| AIR / SEA (radio category) / ground station | ARIA / MARE / stazione di terra |
| radio station / broadcaster | stazione radio / emittente |
| CH (radio channel readout) | CH (unchanged) |
| road / flow (traffic) | strada / flusso |
| transit / bike share | trasporto pubblico / bike sharing |
| last report (vehicle position) | ultima segnalazione |
| scene / shot / scene recipe | scena / scatto / ricetta della scena |
| anchor / data pack / bundle (scene) | ancora / pacchetto dati / pacchetto |
| story beat | tappa del racconto |
| briefing / panel / dock | briefing / pannello / dock |
| cockpit | cockpit |
| preset / visual preset | preset / preset visivo |
| Tactical / Minimal / Cyber | Tattico / Minimale / Cyber |
| Scope (circular viewport mask) / keyhole | scope / foro |
| feather / fade | sfumatura / dissolvenza |
| detection overlay | overlay di rilevamento |
| pin (Draw tool) / mark | segnaposto / segno |
| pin (Recent Imagery) | fissare |
| Draw / Directions (tools) | Disegno / Indicazioni |
| Street Level (layer) / Street View (Google) | Vista stradale / Street View |
| Recent Imagery | Immagini recenti |
| Global Context / Context (panel) | Contesto globale / Contesto |
| heading, course / altitude / ground speed | prua, rotta / altitudine / velocità al suolo (VEL. AL SUOLO) |
| altitude in short HUD captions / Alt unknown | QUOTA (QUOTA · FT) / Quota sconosciuta |
| fallback (FALLBACK, Street View fallback) | riserva / di riserva (SUPERFICIE DI RISERVA) |
| forecast track / advisory center (cyclones) | traccia prevista / centro del ciclone da bollettino |
| witness source / authored (scene) | fonte testimoniale / predefinito, definito dall'autore |
| explore manually | esplora manualmente |
| HDG / BRG / CRS / FOV / DEST | HDG / BRG / CRS / FOV / DEST (unchanged) |
| RANGE / PITCH / HGT (CCTV calibration) | PORTATA / INCL. / ALT. |
| PREV / NEXT | PREC / SUCC |
| Previous / Next (button text) | Precedente / Successivo |
| Focus {target} (action, aria label) | Inquadra {target} (the FOCUS button stays FOCUS) |
| L / R (relative bearing) | S / D |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTIZIE / LOCALE |
| compass points (N, NNE, …, W, NW) | N, NNE, …, O, NO (west is «O», ovest) |
| NOT AN ALL-CLEAR | NON È UN VIA LIBERA |
| weather field / model / valid / issued | campo / modello / valida / emessa |
| click / click to … | fai clic / clic per … |
+112
View File
@@ -0,0 +1,112 @@
# Japanese (`ja`)
Catalog: `src/i18n/locales/ja.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#913).
## Style
- Sentences use です/ます: 「キーを保存しました」, 「もう一度お試しください」. Labels, buttons and HUD chips are short nouns or verb stems without a final 。: 「再試行」, 「保存」, 「ライブ」, 「オフ」.
- Japanese has no case: an UPPERCASE English label becomes the same short Japanese label. Codes, units and names stay in their own case (HDG, ADS-B, km).
- Full-width punctuation in Japanese text: 、。:?!()「」. Quote names and titles with 「」: 「{shot}」を削除しますか? Keep `·` as the HUD separator, `…` for an ellipsis and `—`, `→`, `↗` as in English. A dash inside a sentence becomes 。 or :.
- Button names cited in running text go in []: [次へ]を使ってください.
- No space between Japanese and Latin letters, digits or a `{placeholder}`: 「AIS船舶」, 「Spaceを長押しで話す」, 「{count}件」, 「{name}を表示」.
- Two exceptions keep the space: a Japanese label before its value in a readout (「雲量 {pct}%」, 「ステーション {id}」, 「高度 {altitude}」), and the state words in toggles (「CCTV オン」, 「ラジオ オフ」).
- Waiting states are 「X待ち」, not 「Xを待機中」: 「観測データ待ち」, 「進路/予報円は第{number}報待ち」, 「RTL-SDRデバイスの接続待ち…」. 待機中 is for something on standby with no object: 「フィード待機中」, 「音声待機中」.
- Key names keep their own case in running text and hints: Tab, Enter, Space, Esc (「Tabでプレビュー · Enter / Spaceで選択」). Text that CSS upper-cases shows them in capitals, as in English.
- Loanwords are katakana with the long-vowel mark, as in Microsoft and Apple style: ユーザー, ブラウザー, ビューアー, プロバイダー, レイヤー, フィルター, モニター, パラメーター. Pick one katakana form per term and keep it (see the glossary).
- Product, provider and dataset names stay in Latin script: Google Maps, Cesium ion, OpenSky, CelesTrak, NASA FIRMS, Sentinel-2, KartaView. Google's own product names use Google's Japanese, joined like any Latin word: Googleストリートビュー. Nepal place names in the Bhote Koshi scenes stay in Latin script, as in zh-CN; country and well-known city names are translated (ネパール, 東京, オースティン).
## Numbers, units and dates
- Units: km, m, km/h, m/s, kt, ft, mph, hPa, MW, dB, °, % stay as written, with the English spacing (「250 km」, 「{frp} MW」, 「95 kt」). Code-built cockpit distances follow this too (`cockpitUnitCase` skips the HUD upper-casing for ja, as for zh). The cockpit's own speed caption is the HUD code KT, in 対地速度 · KT and under the readout.
- Number ranges keep the English en dash, not ~: 「2–6日ごと」, 「50–99%」, 「南緯25°–北緯80°」.
- Time units are words written straight after the number: 秒, 分, 時間, 日 (「5分前」, 「{count}時間」, 「1時間5分」). `formatQuantity` and `formatRelativeTime` drop the space for Japanese unit words, so code-built values match the catalog.
- Counters follow the thing counted: 機 (aircraft), 隻 (ships), 基 (satellites), 台 (cameras, vehicles), 局 (stations), 件 (records, results, events), か所 (sites), 個 (generic). Prefer a counter to a bare number.
- Dates come from `Intl` and read year first: 2026年8月26日. Coordinates in prose are 北緯 / 東経; HUD hemisphere letters stay N, S, E, W.
## Plurals
`Intl.PluralRules('ja')` has one category, `other`: a `tn()` entry has only that form. Put the counter next to the number: 「航空機{n}機」, 「船舶{n}隻」, 「メッセージ {count}件」.
## Glossary
| English | 日本語 |
| ----------------------------------------- | --------------------------------------- |
| layer / data layer | レイヤー / データレイヤー |
| feed | フィード |
| source / data source / attribution | ソース / データソース / 出典 |
| LIVE (status) / live data | ライブ / ライブデータ |
| SIMULATED | シミュレーション |
| stale / degraded / unavailable | 更新遅延 / 性能低下 / 利用不可 |
| cached (status) | キャッシュの(キャッシュの予報) |
| PARTIAL (feed state) | 部分的 |
| upstream (remote data source) | 配信元 |
| retry | 再試行 |
| key (API key) / POWER UP | キー(APIキー)/ パワーアップ |
| Provider Settings | プロバイダー設定 |
| globe / map / map source / basemap | 地球儀 / 地図 / マップソース / ベースマップ |
| mapped (OSM-mapped sites) | マップ済み |
| aircraft / flight / military flights | 航空機 / フライト / 軍用機 |
| FLIGHT {count} (a rocket stage's nth flight) | {count}回目の飛行 |
| vessel, ship | 船舶 |
| satellite | 衛星 |
| launch (rocket) / space mission | 打ち上げ / 宇宙ミッション |
| stage (rocket) / downrange | 段 / ダウンレンジ |
| contact (tracked object) | コンタクト |
| installation, site | 施設 |
| camera / CCTV camera | カメラ / CCTVカメラ |
| earthquake / active fire / fire perimeter | 地震 / 火災検知 / 延焼範囲 |
| contained / containment (fire) | 封じ込め / 封じ込め率 |
| scene / shot | シーン / ショット |
| briefing / panel / dock | ブリーフィング / パネル / ドック |
| preset / visual preset | プリセット / ビジュアルプリセット |
| Tactical / Minimal / Cyber | タクティカル / ミニマル / サイバー |
| Scope (circular viewport mask) / keyhole | スコープ / キーホール |
| Clean UI | クリーンUI |
| cockpit | コックピット |
| heading / altitude / ground speed | 機首方位 / 高度 / 対地速度 |
| track (TRK, direction over the ground) | 対地針路 |
| HDG / BRG / CRS / FOV / SPD / POS | そのまま(HUDコード) |
| squawk | スコーク |
| radio failure / emergency (7600 / 7700) | 通信機故障 / 緊急事態 |
| map heading / North up | 方位 / 北を上に |
| Street Level / Street View (Google) | ストリートレベル / ストリートビュー |
| Recent Imagery | 最近の衛星画像 |
| Global Context / Context (panel) | グローバルコンテキスト / コンテキスト |
| radio (station) / tune / seek | ラジオ(局)/ 選局・同調 / シーク |
| Public Safety / SAFETY (radio category) | 警察・消防 |
| transit / bikeshare | 公共交通 / シェアサイクル |
| occupancy: empty / standing room only / crushed | 乗客なし / 立ち乗りのみ / 非常に混雑 |
| traffic flow (TomTom) | 交通流 |
| Previous / Next | 前へ / 次へ |
| SIG / NEWS / LOCAL (briefing tabs) | シグナル / ニュース / ローカル |
| compass points (N, NNE, NE, ENE, …) | 北, 北北東, 北東, 東北東, … |
| weather field / FIELD (selector) | 要素 |
| weather model / forecast lead hours | モデル / 予報時間 |
| valid (forecast time) / issued | 対象時刻 / 発表 |
| radar reflectivity / lightning density | レーダー反射強度 / 雷密度 |
| cyclone, storm (NHC/CPHC) / advisory | サイクロン / 情報(第N報); alone: サイクロン情報 |
| potential tropical cyclone (NHC PC) | サイクロン化のおそれがある擾乱 |
| forecast uncertainty cone | 予報円 |
| tropical storm / tropical depression | 熱帯暴風雨 / 熱帯低気圧 |
| scene cloud / mosaic / daily overview | シーン雲量 / モザイク / 日次概観 |
| box (Recent Imagery area) | 範囲 |
| WINDOW (search radius around a subject) | 範囲 |
| coverage / coverage % / full, partial | カバー範囲 / カバー率(%のみ)/ 全体, 一部 |
| pin (Recent Imagery) | 固定 |
| clear day (Recent Imagery) | 雲の少ない日 |
| outline / mark (map annotation) | 輪郭 / マーク |
| share (a view or link) | 共有 |
| import / export (project, presets) | インポート / エクスポート |
| EXPORT (image, PNG) | 書き出し |
| voice (the voice assistant) | 音声アシスタント / 音声機能 |
| activate (a control, screen-reader text) | 押す(「押すと…」) |
| AI agent | AIエージェント |
| frame (bring into view) | 視野に収める |
| story beat / evidence beat | ストーリー区切り / 証拠の区切り |
| camera pose / camera move | カメラの姿勢 / 移動 |
| roundabout / ramp / exit | ロータリー / ランプ / 出口 |
| reduced motion | 動きを減らす |
| NOT AN ALL-CLEAR | 安全の保証ではありません |
| … Watch (scene titles: Earth, Orbital) | 監視(地球監視, 軌道監視) |
+107
View File
@@ -0,0 +1,107 @@
# Brazilian Portuguese (`pt`)
Catalog: `src/i18n/locales/pt.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#909).
## Style
- Brazilian Portuguese (pt-BR): câmera, tela, compartilhar, arquivo, baixar, ônibus, metrô. Every `pt` tag, `pt-PT` included, gets this catalog, so avoid slang and regionalisms.
- Address the user with «você» imperatives: «Clique no mapa», «Ative o CCTV», «Tente novamente». Tooltips that name an action use the infinitive: «Expandir painel», «Redefinir a câmera».
- Sentence case, not Title Case: "Live Flights" → «Voos ao vivo». UPPERCASE English stays UPPERCASE: «AO VIVO», «SEM DADOS».
- Quotes inside Portuguese text are straight double quotes: «Excluir o plano "{shot}"?». A sentence that names a panel quotes it: «o painel "Configurações de provedores"» as the subject, so the verb agrees, and «adicione em "Configurações de provedores"» after a preposition.
- A dash between clauses is a spaced em dash « — », even where the English runs it in.
- A date or time after a participle takes a preposition: «emitido em {time}», «válida para {valid}», «Posição em {time}», «onde estava em {time}». Use «em», not «às», wherever {time} can include a date.
- A count in a string with no plural form (no `tn()`) goes after a colon, so it reads right for 1: «sincronizando feeds de cidades: {count}...», «CÂMERAS EM ESPERA: {count}».
- A placeholder that holds a layer or tool name of unknown gender and number gets a noun to agree with: «{label}: camada ativada», «A ferramenta "{owner}" está usando o ponteiro — feche-a primeiro».
- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: ANT., PRÓX., DESLIG., EXCL., INCL., ALT., VEL., câm.
- The HUD codes HDG, BRG, CRS, FOV, DEST, SPD, RANGE, ALT, GS, TRK and V/S stay in English, as in cockpit avionics («ALT {altitude} FT · GS {speed} KT · TRK {track}», «V/S {rate} FT/MIN»), and so does the HUD recording mark REC. Words used in a sentence are translated: heading → «rumo», range → «alcance».
- Keyboard keys stay in English: Space, Esc, Enter, Tab, Shift, Backspace, H, 1–9 («Segure Space para falar»).
- Common English tech words used in Brazil stay: feed, stream, snapshot, briefing, dock, layout, bloom, status, app, link, token.
- Units: km, m, km/h, m/s, kt (knots), ft (feet), h, min, s, always spaced from the number («Terremotos (24 h)», «Missões espaciais (30 d)»; in the layer names the space is a no-break space, U+00A0, so the unit never wraps away from the number); in UPPERCASE text KM, KM/S, KT, FT, MIN, H. "m" is always metres, so minutes are «min» («há 5 min»). MW stays MW. `°` and `%` stay. Decimal comma in written numbers («M4,4»).
- "ago" goes before the time: «há {count} min», «mais recente há {value}». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «há» again.
- Money: «US$ {amount} mil / mi / bi» for thousands, millions and billions.
- Compass points use Leste/Oeste: N, NNE, NE, ENE, L, ESE, SE, SSE, S, SSO, SO, OSO, O, ONO, NO, NNO; hemisphere letters N, S, L, O.
## Plurals
A `tn()` entry needs three forms. `Intl.PluralRules('pt')` follows Brazilian usage:
| Form | Counts | Example |
| ------- | -------------------------------------- | ------------------ |
| `one` | 0, 1 and anything below 2: 0, 1, 1.5 | 0 câmera, 1 câmera |
| `many` | 1 000 000, 2 000 000… (exact millions) | 1000000 de câmeras |
| `other` | everything else: 2–999 999, 2.5… | 3 câmeras |
The `many` form is the `other` text with «de» after the number: «{count} de câmeras carregadas», «{count} DE CHAVES PENDENTES». A `one` form is also read for 0, so keep it natural with zero («0 arquivo selecionado» is standard Brazilian usage).
## Glossary
| English | Português |
| --------------------------------------------- | ------------------------------------------------------------ |
| layer / data layer | camada / camada de dados |
| feed / source / data source | feed / fonte / fonte de dados |
| LIVE (status) / live data | AO VIVO / dados ao vivo |
| SIMULATED | SIMULADO |
| stale / degraded / unavailable | desatualizado / degradado / indisponível |
| retry / retrying | nova tentativa / tentando de novo |
| retrying (a fetch) / retrying slowly | buscando de novo / tentando de novo em intervalos maiores |
| rate-limited | «X: limite de requisições atingido» |
| dismiss (window, card, error) | fechar |
| key (API key) / POWER UP / POWERED UP | chave / ATIVAR / ATIVADO |
| Provider Settings | Configurações de provedores |
| ON / OFF (state chips) | ATIVO / INATIVO |
| enable / disable | ativar / desativar |
| globe / map / map source / map stack | globo / mapa / fonte do mapa / pilha de mapas |
| basemap / tile | mapa base / bloco |
| aircraft, plane / flight / military flights | aeronave, avião / voo / voos militares |
| vessel, ship / TANKER (AIS class) | embarcação / NAVIO-TANQUE |
| satellite / shell (Starlink) / DENSE | satélite / camada orbital / DENSO |
| SPARSE / BALANCED / DENSE (detection modes) | ESPARSO / EQUILIBRADO / DENSO (o modo) |
| launch (rocket) / launch site / space mission | lançamento / local de lançamento / missão espacial |
| KM DOWNRANGE / EST. DOWNRANGE | KM DO LOCAL DE LANÇAMENTO / EST. PELA DIST. DO LANÇAMENTO |
| ascent / replay / payload / stage | subida / reprise / carga útil / estágio |
| contact / tracking / track | contato / rastreamento / rastro |
| installation / site | instalação / local |
| PLACES CANDIDATE (installation class) | CANDIDATO DO GOOGLE PLACES |
| mapped / observed | mapeado / observado |
| camera / CCTV camera | câmera / câmera de CCTV |
| CALIBRATED / CURATED (CCTV badge) | CALIBRADA / COM CURADORIA |
| AUTO HOP ON / OFF (CCTV) | TROCA AUTO. LIGADA / DESLIG. (as the other CCTV toggles) |
| earthquake / active fire | terremoto / incêndio ativo |
| advisory (NHC/CPHC cyclone) | boletim |
| E/C Pacific (NHC/CPHC coverage) | Pacífico Leste/Central (no «L/C») |
| weather (layer, card) / weather satellite | clima / meteorologia, meteorológico |
| FOG / cloud unknown | NEVOEIRO / nebulosidade desconhecida |
| AQHI bands Low … Very High | Risco baixo … Risco muito alto |
| radio station / broadcaster | estação de rádio / emissora |
| Oldies / World (radio tags) | Flashback / Músicas do mundo |
| radio genre tags agree with «música» | Alternativa, Clássica, Eletrônica, Latina |
| road / flow (traffic) | via / fluxo |
| Street Level (layer) | Nível da rua |
| scene / shot / scene recipe | cena / plano / receita de cena |
| briefing / panel / dock | briefing / painel / dock |
| cockpit | cabine |
| preset / visual preset / EXPORT PRESETS | predefinição / predefinição visual / EXPORTAR PREDEF. |
| VISUAL PRESETS (dock caption) | PREDEF. VISUAIS |
| Scope (circular viewport mask) / keyhole | mira / visor |
| feather / fade | suavização / esmaecimento |
| detection overlay | sobreposição de detecção |
| pin (Draw tool) / box (Recent Imagery) | marcador / área |
| Cyber (HUD layout, effect) | Ciber |
| heading / course / altitude / ground speed | rumo / curso / altitude / velocidade solo (VELOC. SOLO) |
| map heading / bearing (compass, north up) | orientação (do mapa) |
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | HDG / BRG / CRS / FOV / DEST / SPD / RANGE (unchanged) |
| V/S (vertical speed code) | V/S (unchanged) |
| PREV / NEXT | ANT. / PRÓX. |
| Previous / Next (button text) | Anterior / Próximo (Próxima) |
| L / R (relative bearing) | E / D |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTÍCIAS / LOCAL |
| REGIONAL NEWS (fits the 16-character kicker) | NOTÍCIAS DA ÁREA |
| PITCH / HGT (CCTV calibration chips) | INCL. / ALT. |
| NOT AN ALL-CLEAR | NÃO GARANTE SEGURANÇA |
| Last report (transit vehicle position) | Último registro |
| intersection (CCTV) | cruzamento |
| {layers} just turned on (any list of layers) | «{layers}: ativação recente» (no agreement needed) |
| {label} on / off (one layer) | «{label}: camada ativada / desativada» |
| turn-by-turn: turn left / keep left / lane | vire à esquerda / mantenha-se à esquerda / faixa da esquerda |
+106
View File
@@ -0,0 +1,106 @@
# Ukrainian (`uk`)
Catalog: `src/i18n/locales/uk.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: meirm (#312).
## Style
- Address the user with polite imperatives: «Натисніть», «Увімкніть», «Спробуйте ще раз».
- Sentence case, not Title Case: "Live Flights" → «Рейси онлайн».
- Ukrainian norms, not Russian calques: «у радіусі», «за посиланням», «надто довгий», «потім», «вигляд» (a camera view: «вигляд згори»), «стеження» (tracking); prepositions alternate у/в and і/й by euphony.
- The apostrophe is always ʼ (U+02BC): обʼєкт, звʼязок, памʼять. Not ' or ’.
- Quotes inside Ukrainian text are «ялинки»; a button or layer name inside a sentence goes in them: Увімкніть «Вуличні знімки».
- In short labels, a standard abbreviation with a period beats a wrapped line: ПОПЕР., ДАЛЬН., КАЛІБР.
- Units: км, м, км/год, м/с, вуз (knots), фт (feet), год, хв, с, МГц, дБ, гПа; in UPPERCASE text КМ, М, КМ/С, ВУЗ, ФТ. MW is МВт in any case. `°` and `%` stay. Decimal comma in coordinates written as text: 28,2710° пн. ш.
- Coordinates: пн. ш. / пд. ш. / сх. д. / зх. д.; the HUD hemisphere letters are Пн, Пд, Сх, Зх.
## Plurals
A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks:
| Form | Counts | Example |
| ------- | ------------------------ | ---------- |
| `one` | 1, 21, 31, 101… | 1 камера |
| `few` | 2–4, 22–24, 32–34… | 3 камери |
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
| `other` | fractions: 1.5, 2.25… | 1,5 камери |
`other` takes the genitive singular: 1,5 кадру, 1,5 літака, 1,5 ключа. When a word next to a number cannot agree in every form, move the number after a colon instead: «поруч: {count}», «відповідей: {responses}». The same trick keeps place names and tool names out of a preposition's case: «звідки: {origin}», «Активно: {name}».
## Glossary
| English | Українська |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| layer / data layer | шар / шар даних |
| feed | потік даних (short: потік) |
| source / data source | джерело / джерело даних |
| LIVE (status) / live data | ОНЛАЙН / у реальному часі (status chip: ДАНІ ОНЛАЙН) |
| SIMULATED / SYNTHETIC | ІМІТАЦІЯ |
| stale / degraded / unavailable | застарів / з перебоями (chip: ПЕРЕБОЇ) / недоступний |
| retry | повтор |
| key (API key) / POWER UP | ключ / ЗАПУСК |
| Provider Settings / provider | Налаштування постачальників / постачальник |
| globe / map / map source / basemap | глобус / карта / джерело карти / підкладка |
| aircraft / flight / military flights | літак (short HUD: борт) / рейс / військові рейси |
| vessel, ship / vessels | судно / судна (gen. pl. суден) |
| satellite | супутник |
| launch (rocket) / space mission | запуск / космічна місія |
| ascent (rocket) / booster FLIGHT {n} | виведення / ПОЛІТ {n} (not рейс) |
| contact / tracking | контакт / стеження |
| installation, site / mapped | обʼєкт / нанесений (обʼєкт, контакт) |
| camera / CCTV camera | камера / камера CCTV |
| earthquake / active fire / fire perimeter | землетрус / активна пожежа / контур пожежі |
| advisory (NHC/CPHC cyclone) | зведення |
| scene / shot / scene recipe | сцена / кадр / рецепт сцени |
| briefing / panel / dock | брифінг / панель / док |
| preset / visual preset | пресет / візуальний пресет |
| Scope, keyhole (circular viewport mask) | приціл |
| overlay (detection overlay) / world overlay | накладка / накладка на глобусі |
| heading / altitude / ground speed | курс / висота / шляхова швидкість |
| map bearing, map heading | азимут карти |
| HDG / BRG / CRS, TRK / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ШК (шляховий кут) / ОГЛЯД / ПРИЗН. / ШВИД / ДАЛЬН. |
| Street View (Google) | Перегляд вулиць (as in Ukrainian Google Maps) |
| Street Level (layer) | Вуличні знімки |
| imagery / recent imagery | знімки / свіжі знімки |
| radar reflectivity / lightning density | радіолокаційна відбивність / щільність блискавок |
| infrared | інфрачервоний (short: ІЧ) |
| receiver / gain / decoder | приймач / підсилення / декодер |
| broadcaster | мовник |
| CONUS, WHOT/BHOT, Ironbow, CRT, NVG, FLIR | без перекладу |
| PREV / NEXT (HUD) / Previous / Next | ПОПЕР. / ДАЛІ / Попередній / Наступний |
| anchor / data pack / bundle (scene) | якір / пакет даних / архів |
| box (Recent Imagery area) | область |
| camera move | переліт |
| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВИНИ / МІСЦ. |
| compass points (N, NNE, NE, …) | Пн, ПнПнСх, ПнСх, … |
| L / R (relative bearing) | Л / П |
| click / tap | клацнути / натиснути |
| session (voice, OAuth) | сеанс |
| sequence (street-level photos) | послідовність |
| search (place search) | пошук місця |
| pitch (CCTV camera pose) | нахил |
| traffic (road traffic layer) | дорожній рух |
| phosphor (CRT) | люмінофор |
| view (map extent) / view centre / in view | видима область (карти) / центр карти / у кадрі |
| select (verb) / selected | вибрати / вибраний (not обрати) |
| ping (cockpit proximity contacts) | контакт |
| merge (turn-by-turn) / on ramp | влитися в потік / вʼїзд (off ramp: зʼїзд) |
| watch (Earth/Orbital Watch) | спостереження |
| timeout | тайм-аут |
| icon | значок (not іконка) |
| run (scene playback) / RUN LOG | відтворення / ЖУРНАЛ ВІДТВОРЕННЯ (запуск is a rocket launch) |
| Preview (voice panel replies) | відповіді |
| Marine (radio tag) / pleasure craft | море (not флот) / прогулянкове судно |
| emergency (ADS-B) / Emergency (radio tag) | аварійний стан / надзвичайні ситуації |
| confidence (FIRMS fire detection) | достовірність (short: достов.) |
| thermal (FLIR-style imaging) | тепловий (not термальний) |
| map stack | набір карт |
| stage (rocket) re-entry / recovery | вхід ступеня в атмосферу / повернення |
| ON / OFF (toggle chips) | УВІМК. / ВИМК. |
| Clean UI / EXIT CLEAN VIEW | чистий інтерфейс / ПОВЕРНУТИ ІНТЕРФЕЙС |
| full globe view | вигляд всього глобуса |
| inputs (cockpit context chip) | потоки (not вхідні дані) |
| downrange (rocket stage) | дальність |
| resolution (spatial) | роздільна здатність (not роздільність) |
| shared follow expired | посилання на стеження застаріло |
+1 -1
View File
@@ -43,7 +43,7 @@ Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979).
| briefing / panel / dock | 简报 / 面板 / 停靠栏 | | briefing / panel / dock | 简报 / 面板 / 停靠栏 |
| preset / visual preset | 预设 / 视觉预设 | | preset / visual preset | 预设 / 视觉预设 |
| Display / HUD layout | 显示 / HUD 布局 | | Display / HUD layout | 显示 / HUD 布局 |
| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 | | Tactical / Minimal / Cyber | 战术 / 精简 / 赛博 |
| Scope (circular viewport mask) / keyhole | 视域 | | Scope (circular viewport mask) / keyhole | 视域 |
| Clean UI | 纯净界面 | | Clean UI | 纯净界面 |
| cockpit | 座舱 | | cockpit | 座舱 |
+17
View File
@@ -0,0 +1,17 @@
# Traditional Chinese (`zh-Hant`)
Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
`zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs.
After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布.
## Style
- Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters.
- Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前.
- The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese.
## Plurals
`Intl.PluralRules('zh-Hant')` uses only `other`. Keep the measure words of the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」.
+67 -67
View File
@@ -23,9 +23,9 @@
}, },
"devDependencies": { "devDependencies": {
"prettier": "3.9.6", "prettier": "3.9.6",
"puppeteer": "^25.10.0", "puppeteer": "^25.16.0",
"sharp": "^0.35.4", "sharp": "^0.35.4",
"vite": "^6.0.0", "vite": "^6.4.4",
"vite-plugin-cesium": "^1.2.23", "vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
@@ -1116,13 +1116,13 @@
} }
}, },
"node_modules/@puppeteer/browsers": { "node_modules/@puppeteer/browsers": {
"version": "3.2.2", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz",
"integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", "integrity": "sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"modern-tar": "^0.8.4", "modern-tar": "^0.8.6",
"yargs": "^18.0.0" "yargs": "^18.0.0"
}, },
"bin": { "bin": {
@@ -1620,9 +1620,9 @@
} }
}, },
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "6.3.0", "version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1707,14 +1707,14 @@
} }
}, },
"node_modules/chromium-bidi": { "node_modules/chromium-bidi": {
"version": "17.0.2", "version": "157.0.8090-0",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", "integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"mitt": "^3.0.1", "mitt": "^3.0.1",
"zod": "^3.24.1" "zod": "^4.6.5"
}, },
"engines": { "engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0" "node": ">=20.19.0 <22.0.0 || >=22.12.0"
@@ -1723,16 +1723,6 @@
"devtools-protocol": "*" "devtools-protocol": "*"
} }
}, },
"node_modules/chromium-bidi/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
@@ -1821,9 +1811,9 @@
} }
}, },
"node_modules/devtools-protocol": { "node_modules/devtools-protocol": {
"version": "0.0.1666840", "version": "0.0.1704951",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1704951.tgz",
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", "integrity": "sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg==",
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
@@ -2054,9 +2044,9 @@
} }
}, },
"node_modules/get-east-asian-width": { "node_modules/get-east-asian-width": {
"version": "1.6.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2361,9 +2351,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/modern-tar": { "node_modules/modern-tar": {
"version": "0.8.5", "version": "0.8.6",
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz",
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", "integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2557,19 +2547,19 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/puppeteer": { "node_modules/puppeteer": {
"version": "25.10.0", "version": "25.16.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.16.0.tgz",
"integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==", "integrity": "sha512-LOr3reYnxAYYfQkpopz1fQnL0LdvqhpR5U2Fu+QnwzTX2gpVTHsn91UETo5NNC1md2/oC54Zu1ChBXgZDNSP1A==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@puppeteer/browsers": "3.2.2", "@puppeteer/browsers": "3.2.6",
"chromium-bidi": "17.0.2", "chromium-bidi": "157.0.8090-0",
"devtools-protocol": "0.0.1666840", "devtools-protocol": "0.0.1704951",
"lilconfig": "^3.1.3", "lilconfig": "^3.1.3",
"puppeteer-core": "25.10.0", "puppeteer-core": "25.16.0",
"typed-query-selector": "^2.12.2" "typed-query-selector": "^2.12.3"
}, },
"bin": { "bin": {
"puppeteer": "lib/puppeteer/node/cli.js" "puppeteer": "lib/puppeteer/node/cli.js"
@@ -2579,18 +2569,18 @@
} }
}, },
"node_modules/puppeteer-core": { "node_modules/puppeteer-core": {
"version": "25.10.0", "version": "25.16.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.16.0.tgz",
"integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", "integrity": "sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@puppeteer/browsers": "3.2.2", "@puppeteer/browsers": "3.2.6",
"chromium-bidi": "17.0.2", "chromium-bidi": "157.0.8090-0",
"devtools-protocol": "0.0.1666840", "devtools-protocol": "0.0.1704951",
"typed-query-selector": "^2.12.2", "typed-query-selector": "^2.12.3",
"webdriver-bidi-protocol": "0.4.3", "webdriver-bidi-protocol": "1.0.1",
"ws": "^8.21.3" "ws": "^8.22.0"
}, },
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
@@ -2871,9 +2861,9 @@
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==" "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
}, },
"node_modules/string-width": { "node_modules/string-width": {
"version": "8.2.2", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2963,9 +2953,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/typed-query-selector": { "node_modules/typed-query-selector": {
"version": "2.12.2", "version": "2.12.3",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3002,9 +2992,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.3", "version": "6.4.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3132,9 +3122,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/webdriver-bidi-protocol": { "node_modules/webdriver-bidi-protocol": {
"version": "0.4.3", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", "integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
@@ -3175,9 +3165,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.21.3", "version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3226,9 +3216,9 @@
} }
}, },
"node_modules/yargs": { "node_modules/yargs": {
"version": "18.1.0", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3252,6 +3242,16 @@
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=23" "node": "^20.19.0 || ^22.12.0 || >=23"
} }
},
"node_modules/zod": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }
+3 -3
View File
@@ -6,7 +6,7 @@
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"author": "Bilawal Sidhu", "author": "Bilawal Sidhu",
"homepage": "https://github.com/bilawalsidhu/gods-eye-view#readme", "homepage": "https://godseyeview.ai",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/bilawalsidhu/gods-eye-view.git" "url": "https://github.com/bilawalsidhu/gods-eye-view.git"
@@ -70,9 +70,9 @@
}, },
"devDependencies": { "devDependencies": {
"prettier": "3.9.6", "prettier": "3.9.6",
"puppeteer": "^25.10.0", "puppeteer": "^25.16.0",
"sharp": "^0.35.4", "sharp": "^0.35.4",
"vite": "^6.0.0", "vite": "^6.4.4",
"vite-plugin-cesium": "^1.2.23", "vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
+4 -2
View File
@@ -212,7 +212,6 @@
"src/styles/thermal.js", "src/styles/thermal.js",
"src/styles/anime.js", "src/styles/anime.js",
"src/styles/noir.js", "src/styles/noir.js",
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs", "scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs", "scripts/qa-draw-tool.mjs",
"scripts/qa-geo-import.mjs", "scripts/qa-geo-import.mjs",
@@ -562,6 +561,7 @@
"src/data/cctvCataloniaSource.test.mjs", "src/data/cctvCataloniaSource.test.mjs",
"src/data/cctvHk.test.mjs", "src/data/cctvHk.test.mjs",
"src/data/cctvVegvesen.test.mjs", "src/data/cctvVegvesen.test.mjs",
"src/data/cctvA22.test.mjs",
"src/data/cctvNyc.test.mjs", "src/data/cctvNyc.test.mjs",
"src/data/cctvNswSource.test.mjs", "src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs", "src/data/cctvTxdotSource.test.mjs",
@@ -703,7 +703,6 @@
"src/ui/shareRestoration.js", "src/ui/shareRestoration.js",
"src/ui/visualSettings.js", "src/ui/visualSettings.js",
"src/ui/panelChrome.js", "src/ui/panelChrome.js",
"src/ui/panelChrome.radioOwnership.test.mjs",
"src/testSupport/readShellSource.mjs", "src/testSupport/readShellSource.mjs",
"src/ui/stateOwners.test.mjs", "src/ui/stateOwners.test.mjs",
"src/layers/flights/records.js", "src/layers/flights/records.js",
@@ -898,6 +897,7 @@
"scripts/qa-weather-swap.mjs", "scripts/qa-weather-swap.mjs",
"scripts/qa-weather-teardown.mjs", "scripts/qa-weather-teardown.mjs",
"src/layers/weather/imageryHost.test.mjs", "src/layers/weather/imageryHost.test.mjs",
"src/layers/weather/display.test.mjs",
"src/sdr/adsbDecoder.test.mjs", "src/sdr/adsbDecoder.test.mjs",
"src/sdr/controller.test.mjs", "src/sdr/controller.test.mjs",
"src/sdr/decoderDiagnostics.test.mjs", "src/sdr/decoderDiagnostics.test.mjs",
@@ -1102,6 +1102,8 @@
"src/cameraGlobeRotation.test.mjs", "src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs", "src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs", "src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs",
"src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs", "src/voice/panel/preferences.test.mjs",
"src/voice/panel/host.test.mjs", "src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs", "src/voice/panel/manualPanel.test.mjs",
+5 -2
View File
@@ -125,8 +125,11 @@ export function extractMessages() {
const html = readFileSync(path.join(ROOT, file), 'utf8'); const html = readFileSync(path.join(ROOT, file), 'utf8');
for (const [, tag, attrs, text] of html.matchAll(TAG)) { for (const [, tag, attrs, text] of html.matchAll(TAG)) {
const where = `${file} <${tag}>`; const where = `${file} <${tag}>`;
if (/\sdata-i18n(\s|=|$)/.test(attrs)) if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
add(normalize(decodeHtml(text)), where); const context = attrs.match(/\sdata-i18n-context="([^"]*)"/);
const message = normalize(decodeHtml(text));
add(context ? `${decodeHtml(context[1])}|${message}` : message, where);
}
for (const [, name] of attrs.matchAll( for (const [, name] of attrs.matchAll(
/\sdata-i18n-(title|aria-label|placeholder)\b/g, /\sdata-i18n-(title|aria-label|placeholder)\b/g,
)) { )) {
+338 -32
View File
@@ -439,9 +439,16 @@
"src/ui/slider.js", "src/ui/slider.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -462,7 +469,6 @@
"src/styles/thermal.js", "src/styles/thermal.js",
"src/styles/anime.js", "src/styles/anime.js",
"src/styles/noir.js", "src/styles/noir.js",
"src/styles/snow.js",
"src/i18n/marker.js" "src/i18n/marker.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -478,9 +484,16 @@
"src/localRequestGate.mjs", "src/localRequestGate.mjs",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -507,9 +520,16 @@
"src/data/layerSnapshot.js", "src/data/layerSnapshot.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/statusText.js", "src/ui/statusText.js",
@@ -528,9 +548,16 @@
"src/app/stateChannel.js", "src/app/stateChannel.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -544,9 +571,16 @@
"src/ui/radioTunerModel.js", "src/ui/radioTunerModel.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -563,9 +597,16 @@
"src/ui/cctvPresentation.js", "src/ui/cctvPresentation.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/statusBadge.js" "src/ui/statusBadge.js"
], ],
@@ -589,9 +630,16 @@
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/layerNames.js" "src/ui/layerNames.js"
], ],
"external": [] "external": []
@@ -623,9 +671,16 @@
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -639,9 +694,16 @@
"src/ui/actionButton.js", "src/ui/actionButton.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": [] "external": []
@@ -688,7 +750,6 @@
"src/styles/anime.js", "src/styles/anime.js",
"src/styles/noir.js", "src/styles/noir.js",
"src/styles/retro.js", "src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/ui/applicationShell.js", "src/ui/applicationShell.js",
@@ -797,15 +858,23 @@
"src/data/cameraArrival.js", "src/data/cameraArrival.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/statusBadge.js", "src/ui/statusBadge.js",
"src/scopeMaskPolicy.js" "src/scopeMaskPolicy.js",
"src/visualStylePolicy.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -918,10 +987,18 @@
"src/data/cameraArrival.js", "src/data/cameraArrival.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js" "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
"src/visualStylePolicy.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -985,9 +1062,16 @@
"src/data/modelEligibility.js", "src/data/modelEligibility.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -1033,9 +1117,16 @@
"src/data/modelEligibility.js", "src/data/modelEligibility.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1095,9 +1186,16 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1124,9 +1222,16 @@
"src/maps/imageryHost.js", "src/maps/imageryHost.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/layers/recentImagery/display.js" "src/layers/recentImagery/display.js"
], ],
@@ -1154,9 +1259,16 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1184,9 +1296,16 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium", "satellite.js"] "external": ["cesium", "satellite.js"]
@@ -1217,9 +1336,16 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1259,9 +1385,16 @@
"src/data/infrastructureOverlayEntry.js", "src/data/infrastructureOverlayEntry.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/installations/classLabel.js" "src/layers/installations/classLabel.js"
], ],
"external": ["cesium", "@mapbox/vector-tile", "pbf"] "external": ["cesium", "@mapbox/vector-tile", "pbf"]
@@ -1295,9 +1428,16 @@
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -1345,9 +1485,16 @@
"src/layers/traffic/roadModes.js", "src/layers/traffic/roadModes.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile"] "external": ["cesium", "pbf", "@mapbox/vector-tile"]
}, },
@@ -1374,9 +1521,16 @@
"src/data/cameraSensitivity.js", "src/data/cameraSensitivity.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1422,9 +1576,16 @@
"src/layers/cctv/sourcePolicy.js", "src/layers/cctv/sourcePolicy.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["hls.js", "cesium"] "external": ["hls.js", "cesium"]
}, },
@@ -1485,9 +1646,16 @@
"scripts/qa-street-level.mjs", "scripts/qa-street-level.mjs",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js" "src/layers/streetLevel/providers/mapillary/policy.js"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
@@ -1514,9 +1682,16 @@
"src/layers/alpr/floors.js", "src/layers/alpr/floors.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium", "@mapbox/vector-tile", "pbf"] "external": ["cesium", "@mapbox/vector-tile", "pbf"]
}, },
@@ -1554,9 +1729,16 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1576,9 +1758,16 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1632,9 +1821,16 @@
"src/maps/imageryHost.js", "src/maps/imageryHost.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1694,9 +1890,16 @@
"src/voice/pointerReticle.js", "src/voice/pointerReticle.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/voice/providerCommands.js", "src/voice/providerCommands.js",
"src/voice/providerCapabilities.js", "src/voice/providerCapabilities.js",
@@ -1924,6 +2127,7 @@
"src/hud.js", "src/hud.js",
"src/hudLocality.js", "src/hudLocality.js",
"src/hudLocalityFormat.js", "src/hudLocalityFormat.js",
"src/hudPlaceLookup.js",
"src/hudSummaryResponse.js", "src/hudSummaryResponse.js",
"src/keySetup.js", "src/keySetup.js",
"src/keySetupCore.mjs", "src/keySetupCore.mjs",
@@ -2210,7 +2414,6 @@
"src/styles/anime.js", "src/styles/anime.js",
"src/styles/noir.js", "src/styles/noir.js",
"src/styles/retro.js", "src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/ui/applicationShell.js", "src/ui/applicationShell.js",
@@ -2438,6 +2641,7 @@
"src/layers/weather/index.js", "src/layers/weather/index.js",
"src/layers/weather/clock.js", "src/layers/weather/clock.js",
"src/layers/weather/rendering.js", "src/layers/weather/rendering.js",
"src/layers/weather/display.js",
"src/layers/weather/shellRendering.js", "src/layers/weather/shellRendering.js",
"src/layers/weather/infraredAlpha.js", "src/layers/weather/infraredAlpha.js",
"src/layers/weather/infraredImage.js", "src/layers/weather/infraredImage.js",
@@ -2576,9 +2780,16 @@
"src/app/embed.js", "src/app/embed.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/layers/recentImagery/display.js", "src/layers/recentImagery/display.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
@@ -2599,7 +2810,8 @@
"src/voice/geminiCaptureWorklet.js", "src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js", "src/ui/statusBadge.js",
"src/sceneCommandRelease.js", "src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js" "src/scopeMaskPolicy.js",
"src/visualStylePolicy.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3062,6 +3274,7 @@
"src/layers/weather/index.js", "src/layers/weather/index.js",
"src/layers/weather/clock.js", "src/layers/weather/clock.js",
"src/layers/weather/rendering.js", "src/layers/weather/rendering.js",
"src/layers/weather/display.js",
"src/layers/weather/shellRendering.js", "src/layers/weather/shellRendering.js",
"src/layers/weather/infraredAlpha.js", "src/layers/weather/infraredAlpha.js",
"src/layers/weather/infraredImage.js", "src/layers/weather/infraredImage.js",
@@ -3160,9 +3373,16 @@
"src/sources/infrastructureData.js", "src/sources/infrastructureData.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/layers/recentImagery/display.js", "src/layers/recentImagery/display.js",
"src/layers/installations/classLabel.js", "src/layers/installations/classLabel.js",
@@ -3270,9 +3490,16 @@
"src/app/stateChannel.js", "src/app/stateChannel.js",
"src/data/cameraArrival.js", "src/data/cameraArrival.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/i18n/index.js" "src/i18n/index.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -3293,9 +3520,16 @@
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -3313,7 +3547,6 @@
"src/styles/anime.js", "src/styles/anime.js",
"src/styles/noir.js", "src/styles/noir.js",
"src/styles/retro.js", "src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/ui/applicationShortcuts.js", "src/ui/applicationShortcuts.js",
@@ -3329,11 +3562,19 @@
"src/i18n/marker.js", "src/i18n/marker.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/ui/displayControls.js" "src/ui/displayControls.js",
"src/visualStylePolicy.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -3355,9 +3596,16 @@
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -3446,9 +3694,16 @@
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -3501,9 +3756,16 @@
"src/voice/pointerReticle.js", "src/voice/pointerReticle.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/voice/providerCapabilities.js" "src/voice/providerCapabilities.js"
], ],
@@ -3755,9 +4017,16 @@
"src/sources/capability.js", "src/sources/capability.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium", "egm96-universal", "pbf"] "external": ["cesium", "egm96-universal", "pbf"]
}, },
@@ -3798,9 +4067,16 @@
"src/sources/transitHistory.js", "src/sources/transitHistory.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium", "pbf"] "external": ["cesium", "pbf"]
}, },
@@ -3851,9 +4127,16 @@
"src/layers/perimeters/inciweb.js", "src/layers/perimeters/inciweb.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -3867,9 +4150,16 @@
"src/layers/gnss/records.js", "src/layers/gnss/records.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/layerNames.js" "src/ui/layerNames.js"
], ],
@@ -3895,7 +4185,6 @@
"src/styles/anime.js", "src/styles/anime.js",
"src/styles/noir.js", "src/styles/noir.js",
"src/styles/retro.js", "src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js", "src/styles/surveillance.js",
"src/styles/thermal.js", "src/styles/thermal.js",
"src/view/index.js", "src/view/index.js",
@@ -3904,7 +4193,8 @@
"src/data/idSwitches.js", "src/data/idSwitches.js",
"src/data/detectionPolicy.js", "src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/i18n/marker.js" "src/i18n/marker.js",
"src/visualStylePolicy.js"
], ],
"external": [] "external": []
}, },
@@ -3926,9 +4216,16 @@
"src/i18n/marker.js", "src/i18n/marker.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/data/geoImportImages.js", "src/data/geoImportImages.js",
"src/data/geoImportPins.js", "src/data/geoImportPins.js",
"src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/tileMath.js",
@@ -4007,7 +4304,8 @@
"src/tools/views.js", "src/tools/views.js",
"src/tools/globePanel.js", "src/tools/globePanel.js",
"src/tools/queries/panelRequest.js", "src/tools/queries/panelRequest.js",
"src/geoDistance.js" "src/geoDistance.js",
"src/visualStylePolicy.js"
], ],
"external": ["satellite.js"] "external": ["satellite.js"]
}, },
@@ -4108,7 +4406,8 @@
"src/view/index.js", "src/view/index.js",
"src/data/idSwitches.js", "src/data/idSwitches.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json" "src/data/layerStateTokenReservations.json",
"src/visualStylePolicy.js"
], ],
"external": [] "external": []
}, },
@@ -4122,9 +4421,16 @@
"src/i18n/marker.js", "src/i18n/marker.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/layerNames.js" "src/ui/layerNames.js"
], ],
+19 -19
View File
@@ -1603,7 +1603,7 @@ try {
cockpit.setVisionMode('optical'); cockpit.setVisionMode('optical');
current.focus({ preventScroll: true }); current.focus({ preventScroll: true });
const states = [read()]; const states = [read()];
for (let index = 0; index < 7; index += 1) { for (let index = 0; index < 6; index += 1) {
current.click(); current.click();
states.push(read()); states.push(read());
} }
@@ -1637,36 +1637,36 @@ try {
reentered, reentered,
}; };
}); });
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'snow', 'optical']; const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'optical'];
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'SNOW', 'NORMAL']; const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'NORMAL'];
check( check(
'Cockpit vision control exposes Normal, CRT, NVG, FLIR, Anime, Noir and Snow before wrapping', 'Cockpit vision control exposes Normal, CRT, NVG, FLIR, Anime and Noir before wrapping',
JSON.stringify(visionCycle.states.map((state) => state.mode)) === JSON.stringify(cycleModes) JSON.stringify(visionCycle.states.map((state) => state.mode)) === JSON.stringify(cycleModes)
&& JSON.stringify(visionCycle.states.map((state) => state.label)) === JSON.stringify(cycleLabels) && JSON.stringify(visionCycle.states.map((state) => state.label)) === JSON.stringify(cycleLabels)
&& visionCycle.states[5]?.aria === 'Current cockpit vision style: Noir. Activate for next style.' && visionCycle.states[5]?.aria === 'Current cockpit vision style: Noir. Activate for next style.'
&& visionCycle.states[7]?.aria === 'Current cockpit vision style: Normal. Activate for next style.', && visionCycle.states[6]?.aria === 'Current cockpit vision style: Normal. Activate for next style.',
JSON.stringify(visionCycle.states.map(({ mode, label, aria }) => ({ mode, label, aria }))), JSON.stringify(visionCycle.states.map(({ mode, label, aria }) => ({ mode, label, aria }))),
); );
check( check(
'user vision changes open Display for every temporary treatment and retain selector focus', 'user vision changes open Display for every temporary treatment and retain selector focus',
visionCycle.states.slice(1, 7).every((state) => state.displayOpen === 'true') visionCycle.states.slice(1, 6).every((state) => state.displayOpen === 'true')
&& visionCycle.states.slice(1, 7).every((state) => state.radioOpen === 'false') && visionCycle.states.slice(1, 6).every((state) => state.radioOpen === 'false')
&& visionCycle.states.slice(1, 7).every((state) => state.parametersActive) && visionCycle.states.slice(1, 6).every((state) => state.parametersActive)
&& visionCycle.states.slice(1, 7).every((state) => !state.parametersCollapsed) && visionCycle.states.slice(1, 6).every((state) => !state.parametersCollapsed)
&& visionCycle.states.slice(1, 7).every((state) => state.signalCollapsed) && visionCycle.states.slice(1, 6).every((state) => state.signalCollapsed)
&& visionCycle.states.every((state) => state.focusRetained), && visionCycle.states.every((state) => state.focusRetained),
JSON.stringify(visionCycle.states), JSON.stringify(visionCycle.states),
); );
check( check(
'each Cockpit vision mode drives only its own shader and Normal clears them all', 'each Cockpit vision mode drives only its own shader and Normal clears them all',
visionCycle.states.slice(1, 7).every((state) => { visionCycle.states.slice(1, 6).every((state) => {
const target = { const target = {
crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir', snow: 'snow', crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir',
}[state.mode]; }[state.mode];
return Object.entries(state.intensities).every(([name, value]) => return Object.entries(state.intensities).every(([name, value]) =>
name === target ? value > 0 : value === 0); name === target ? value > 0 : value === 0);
}) })
&& Object.values(visionCycle.states[7].intensities).every((value) => value === 0), && Object.values(visionCycle.states[6].intensities).every((value) => value === 0),
JSON.stringify(visionCycle.states.map(({ mode, intensities }) => ({ mode, intensities }))), JSON.stringify(visionCycle.states.map(({ mode, intensities }) => ({ mode, intensities }))),
); );
check( check(
@@ -1751,7 +1751,7 @@ try {
return state; return state;
}, { variantName: variant, kind: openKind }); }, { variantName: variant, kind: openKind });
for (const variant of ['off', 'minimal', 'tactical', 'operator']) { for (const variant of ['off', 'minimal', 'tactical']) {
for (const kind of ['display', 'radio']) { for (const kind of ['display', 'radio']) {
const state = await desktopState(variant, kind); const state = await desktopState(variant, kind);
check( check(
@@ -2081,7 +2081,7 @@ try {
await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) }); await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) });
} }
} }
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator')); await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical'));
const signalTransition = await page.evaluate(() => { const signalTransition = await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
@@ -2317,13 +2317,13 @@ try {
const styleManager = window.__godsEyeView.styleManager; const styleManager = window.__godsEyeView.styleManager;
const styleBefore = styleManager.activeStyle; const styleBefore = styleManager.activeStyle;
// A Cockpit-only choice that Reset must undo. // A Cockpit-only choice that Reset must undo.
styleManager.cockpitView.setVisionMode('snow'); styleManager.cockpitView.setVisionMode('anime');
window.__qaCockpitReset = { window.__qaCockpitReset = {
calls: 0, calls: 0,
original: manager.resetToGlobeView, original: manager.resetToGlobeView,
subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null, subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null,
styleBefore, styleBefore,
snowDuringCockpit: styleManager.stages?.snow?.uniforms?.intensity, animeDuringCockpit: styleManager.stages?.anime?.uniforms?.intensity,
}; };
manager.resetToGlobeView = function qaCountedCockpitReset(...args) { manager.resetToGlobeView = function qaCountedCockpitReset(...args) {
window.__qaCockpitReset.calls += 1; window.__qaCockpitReset.calls += 1;
@@ -2360,7 +2360,7 @@ try {
height, height,
subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId), subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId),
styleRestored: gev.styleManager.activeStyle === qa.styleBefore, styleRestored: gev.styleManager.activeStyle === qa.styleBefore,
snowDuringCockpit: qa.snowDuringCockpit, animeDuringCockpit: qa.animeDuringCockpit,
// Cockpit-time intensities are Cockpit's own, so check against the map preset. // Cockpit-time intensities are Cockpit's own, so check against the map preset.
visionRestored: Object.entries(gev.styleManager.stages || {}).every(([name, stage]) => visionRestored: Object.entries(gev.styleManager.stages || {}).every(([name, stage]) =>
name === gev.styleManager.activeStyle name === gev.styleManager.activeStyle
@@ -2376,7 +2376,7 @@ try {
&& resetState.resetHidden && resetState.resetHidden
&& Math.abs(resetState.height - 18_000_000) < 150_000 && Math.abs(resetState.height - 18_000_000) < 150_000
&& resetState.subjectPreserved && resetState.subjectPreserved
&& resetState.snowDuringCockpit > 0 && resetState.animeDuringCockpit > 0
&& resetState.styleRestored && resetState.styleRestored
&& resetState.visionRestored, && resetState.visionRestored,
JSON.stringify(resetState), JSON.stringify(resetState),
+64
View File
@@ -88,6 +88,70 @@ try {
}; };
}); });
for (const [name, passed] of Object.entries(result)) check(name, passed); for (const [name, passed] of Object.entries(result)) check(name, passed);
const visibilityRestored = await page.evaluate(async () => {
// QA layer registration exists only in Vite dev builds.
if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped';
const { sceneDirector: director, dataManager } = window.__godsEyeView;
const layerId = 'director-seek-visibility-qa';
window.__gevQaRegisterLayer(dataManager, {
id: layerId,
name: 'Director seek visibility QA',
updateInterval: -1,
init() {},
enable() {},
disable() {},
update() {},
destroy() {},
getStats: () => ({ count: 0 }),
});
const scene = {
id: 'visibility-qa',
title: 'Visibility QA',
shots: [
{
id: 'visibility-shot',
title: 'Visibility shot',
durationSec: 0.4,
holdSec: 0,
camera: director.styleManager.getCameraState(),
visual: director.styleManager.getVisualState(),
layers: { [layerId]: { enabled: true } },
},
],
};
director._project.scenes.push(scene);
try {
const loaded = await director.loadShot(scene.id, scene.shots[0].id, {
flyDuration: 0.2,
});
const disabled =
(await dataManager.setEnabled(layerId, false, { origin: 'user' })) &&
!dataManager.layers.get(layerId).enabled;
const sought = await director.seekScene(scene.id, 0.5);
return (
loaded?.started &&
disabled &&
sought &&
dataManager.layers.get(layerId).enabled
);
} finally {
director.stopScene();
director._project.scenes = director._project.scenes.filter(
({ id }) => id !== scene.id,
);
await window.__gevQaUnregisterLayer(dataManager, layerId);
}
});
if (visibilityRestored === 'skipped') {
console.log(
'[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)',
);
} else {
check(
'same-shot seek restores manually changed layer visibility',
visibilityRestored,
);
}
const content = await page.evaluate(async () => { const content = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector; const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes.find(({ title }) => const scene = d._project.scenes.find(({ title }) =>
+5 -5
View File
@@ -175,12 +175,12 @@ const MUTATIONS = [
to: ' /* belt removed */', to: ' /* belt removed */',
}, },
{ {
// Anchored past the call so it cannot land on the cockpit disclosure's own // Anchored past the call so it lands on the Cockpit utility disclosure's
// stopImmediatePropagation() a few lines below and prove nothing about this. // Escape claim and nowhere else.
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things', defect: 'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
file: 'radio', file: 'radio',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
}, },
{ {
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind', defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
-47
View File
@@ -357,53 +357,6 @@ async function runArbitrationSection(page, { shots, consoleErrors }) {
`classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`, `classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`,
); );
// ── Blocker repro: a control that claims only the KEY ───────────────────
// The compact Radio disclosure closes on ESC from a capture listener bound
// long before this module exists. It called stopPropagation(), which does
// NOT stop later listeners on the same document — so one key closed the
// disclosure AND dismissed the launcher. The card is not hiding behind this
// one and must not yield to it: the key is simply already spoken for.
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
const beforeRadio = await launcherState();
record('the launcher is up before the radio disclosure opens', beforeRadio.onScreen);
const radioOpened = await page.evaluate(() => {
document.getElementById('context-radio-toggle-btn')?.click();
return !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open');
});
await sleep(250);
record('the compact Radio disclosure opens', radioOpened);
const withRadio = await launcherState();
record(
'a small disclosure is a key contest, not a screen takeover — no yield',
withRadio.classVisible && withRadio.topmost,
`classVisible=${withRadio.classVisible} topmost=${withRadio.topmost}`,
);
await page.keyboard.press('Escape');
await sleep(400);
const radioAfterEsc = await page.evaluate(() => (
!!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')
));
const launcherAfterRadioEsc = await launcherState();
record('ESC closes the disclosure', radioOpened && !radioAfterEsc,
`disclosure still open=${radioAfterEsc}`);
record(
'that SAME ESC does not also dismiss the launcher',
beforeRadio.onScreen && launcherAfterRadioEsc.classVisible
&& launcherAfterRadioEsc.session !== 'dismissed',
`classVisible=${launcherAfterRadioEsc.classVisible} session=${launcherAfterRadioEsc.session} — one key, one action`,
);
// ...and the NEXT press is the launcher's, so nothing was permanently taken.
await page.keyboard.press('Escape');
await sleep(600);
const escAfterRadioClosed = await launcherState();
record(
'the next ESC belongs to the launcher again',
beforeRadio.onScreen && !escAfterRadioClosed.classVisible
&& escAfterRadioClosed.session === 'dismissed',
`classVisible=${escAfterRadioClosed.classVisible} session=${escAfterRadioClosed.session}`,
);
// ── Deferred reveal: a surface already up when the launcher would show ── // ── Deferred reveal: a surface already up when the launcher would show ──
// The class goes on as soon as the app exists, which is well before the // The class goes on as soon as the app exists, which is well before the
// launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not // launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not
+8 -8
View File
@@ -200,8 +200,8 @@ const checkResponsiveStyleFocus = async (width) => {
await page.focus('#control-panel-toggle'); await page.focus('#control-panel-toggle');
await pressTabs(2); // disclosure -> pin -> Normal await pressTabs(2); // disclosure -> pin -> Normal
} }
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) { for (const [tabs, style] of [[0, 'normal'], [3, 'thermal'], [2, 'noir']]) {
if (index) await pressTabs(3); if (tabs) await pressTabs(tabs);
const state = await styleFocusMetrics(); const state = await styleFocusMetrics();
check(`${width} px ${style} keyboard ring is visible and unclipped`, check(`${width} px ${style} keyboard ring is visible and unclipped`,
hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal' hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal'
@@ -567,13 +567,13 @@ try {
// Visual Styles precede the five Map Source tiles. Keep the existing opening // Visual Styles precede the five Map Source tiles. Keep the existing opening
// handoff to selected OSM, then use the user's real Shift+Tab path into styles. // handoff to selected OSM, then use the user's real Shift+Tab path into styles.
await pressTabs(5, true); await pressTabs(5, true);
const snowFocus = await styleFocusMetrics(); const noirFocus = await styleFocusMetrics();
check('Shift+Tab reaches Snow with a visible ring while Normal and OSM stay selected', check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
hasVisibleStyleFocus(snowFocus, 'snow') && !snowFocus.selected hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
&& snowFocus.selectedStyle === 'normal' && snowFocus.selectedMap === 'osm', && noirFocus.selectedStyle === 'normal' && noirFocus.selectedMap === 'osm',
JSON.stringify(snowFocus)); JSON.stringify(noirFocus));
await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') }); await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') });
await pressTabs(3, true); await pressTabs(2, true);
const middleFocus = await styleFocusMetrics(); const middleFocus = await styleFocusMetrics();
check('Shift+Tab traverses the middle FLIR style without activating it', check('Shift+Tab traverses the middle FLIR style without activating it',
hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected
+76 -330
View File
@@ -81,6 +81,32 @@ function check(name, ok, detail = '') {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Radio lives only inside Context; a user reaches its controls by expanding
// Context and then Radio. Returns the prior collapse state so a step can put
// the panels back for the scenarios that follow.
async function revealRadioControls(page, targetId) {
const prior = await page.evaluate(async (id) => {
const manager = window.__godsEyeView.styleManager;
const collapsed = (panelId) => document.getElementById(panelId).classList.contains('collapsed');
const state = { context: collapsed('global-context-panel'), radio: collapsed('radio-panel') };
manager.setPanelCollapsed('global-context-panel', false, { explicit: true });
manager.setPanelCollapsed('radio-panel', false, { explicit: true });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
document.getElementById(id).scrollIntoView({ block: 'nearest' });
return state;
}, targetId);
await page.waitForSelector(`#${targetId}`, { visible: true });
return prior;
}
async function restoreRadioPanels(page, prior) {
await page.evaluate(({ context, radio }) => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('radio-panel', radio);
manager.setPanelCollapsed('global-context-panel', context);
}, prior);
}
// Native pick buffers and clustered overlay entries settle on rendered frames, // Native pick buffers and clustered overlay entries settle on rendered frames,
// not on wall-clock sleeps, particularly with software rendering. // not on wall-clock sleeps, particularly with software rendering.
async function settleRadioFrames(page) { async function settleRadioFrames(page) {
@@ -285,31 +311,16 @@ async function main() {
window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true); window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true);
}); });
const enablePanels = await revealRadioControls(page, 'radio-enable-btn');
const enableStartedAt = Date.now(); const enableStartedAt = Date.now();
const initialDisclosure = await page.evaluate(() => { await page.click('#radio-enable-btn');
const button = document.getElementById('context-radio-toggle-btn');
const mini = document.getElementById('context-radio-mini');
const enabledBefore = window.__godsEyeView.dataManager.isEnabled('radio');
button.click();
return {
enabledBefore,
enabledAfterDisclosure: window.__godsEyeView.dataManager.isEnabled('radio'),
expanded: button.getAttribute('aria-expanded'),
miniHidden: mini.hidden,
};
});
check(
'Radio launcher discloses compact controls without changing power',
!initialDisclosure.enabledBefore && !initialDisclosure.enabledAfterDisclosure
&& initialDisclosure.expanded === 'true' && !initialDisclosure.miniHidden,
JSON.stringify(initialDisclosure),
);
await page.$eval('#context-radio-mini-enable-btn', (button) => button.click());
await page.waitForFunction(() => { await page.waitForFunction(() => {
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState(); const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
return state.enabled && !state.loading && state.stationCount === 750; return state.enabled && !state.loading && state.stationCount === 750;
}); });
const enableElapsedMs = Date.now() - enableStartedAt; const enableElapsedMs = Date.now() - enableStartedAt;
// Later scenarios start from the collapsed rail this step began with.
await restoreRadioPanels(page, enablePanels);
const initial = await page.evaluate(() => { const initial = await page.evaluate(() => {
const module = window.__godsEyeView.dataManager.layers.get('radio').module; const module = window.__godsEyeView.dataManager.layers.get('radio').module;
const state = module.getUIState(); const state = module.getUIState();
@@ -328,9 +339,6 @@ async function main() {
color: entry.dataset.radioColor, color: entry.dataset.radioColor,
})), })),
enablePressed: document.getElementById('radio-enable-btn').getAttribute('aria-pressed'), enablePressed: document.getElementById('radio-enable-btn').getAttribute('aria-pressed'),
launcherExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
launcherActive: document.getElementById('context-radio-toggle-btn').classList.contains('active'),
compactEnablePressed: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-pressed'),
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled, fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'), fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
tunerVisible: !document.getElementById('radio-tuner').hidden, tunerVisible: !document.getElementById('radio-tuner').hidden,
@@ -341,9 +349,8 @@ async function main() {
check('750-station catalog renders within the browser budget', initial.state.stationCount === 750 && enableElapsedMs < 5000, `${enableElapsedMs}ms`); check('750-station catalog renders within the browser budget', initial.state.stationCount === 750 && enableElapsedMs < 5000, `${enableElapsedMs}ms`);
check('enable does not autoplay', initial.plays === 0 && initial.state.audioState === 'stopped'); check('enable does not autoplay', initial.plays === 0 && initial.state.audioState === 'stopped');
check( check(
'compact and detailed Radio power controls reflect the layer manager independently of disclosure', 'Radio power control reflects the layer manager after the panels collapse again',
initial.enablePressed === 'true' && initial.compactEnablePressed === 'true' initial.enablePressed === 'true',
&& initial.launcherExpanded === 'true' && initial.launcherActive,
); );
check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`); check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`);
const musicOption = initial.filterPalette.find((entry) => entry.id === 'music'); const musicOption = initial.filterPalette.find((entry) => entry.id === 'music');
@@ -419,14 +426,10 @@ async function main() {
prevDisabled: document.getElementById('radio-prev-btn').disabled, prevDisabled: document.getElementById('radio-prev-btn').disabled,
playDisabled: document.getElementById('radio-play-btn').disabled, playDisabled: document.getElementById('radio-play-btn').disabled,
volumeDisabled: document.getElementById('radio-volume').disabled, volumeDisabled: document.getElementById('radio-volume').disabled,
disclosureLabel: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
compactPowerLabel: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-label'),
compactPowerText: document.getElementById('context-radio-mini-enable-btn').textContent,
fullLabel: document.getElementById('radio-enable-btn').getAttribute('aria-label'), fullLabel: document.getElementById('radio-enable-btn').getAttribute('aria-label'),
fullText: document.getElementById('radio-enable-btn').textContent, fullText: document.getElementById('radio-enable-btn').textContent,
layerState: document.getElementById('radio-layer-state').textContent, layerState: document.getElementById('radio-layer-state').textContent,
playbackMessage: document.getElementById('radio-playback-state').textContent, playbackMessage: document.getElementById('radio-playback-state').textContent,
miniStation: document.getElementById('context-radio-mini-station').textContent,
dataRailText: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.textContent, dataRailText: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.textContent,
dataRailState: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.dataset.feedState, dataRailState: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.dataset.feedState,
dataRailLabel: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.getAttribute('aria-label'), dataRailLabel: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.getAttribute('aria-label'),
@@ -468,14 +471,10 @@ async function main() {
&& uncertainPresentation.prevDisabled && uncertainPresentation.prevDisabled
&& uncertainPresentation.playDisabled && uncertainPresentation.playDisabled
&& uncertainPresentation.volumeDisabled && uncertainPresentation.volumeDisabled
&& /compact Radio controls/i.test(uncertainPresentation.disclosureLabel)
&& /uncertain/i.test(uncertainPresentation.compactPowerLabel)
&& uncertainPresentation.compactPowerText === 'RECONCILE'
&& /uncertain/i.test(uncertainPresentation.fullLabel) && /uncertain/i.test(uncertainPresentation.fullLabel)
&& uncertainPresentation.fullText === 'RECONCILE' && uncertainPresentation.fullText === 'RECONCILE'
&& uncertainPresentation.layerState === 'UNCERTAIN' && uncertainPresentation.layerState === 'UNCERTAIN'
&& /uncertain/i.test(uncertainPresentation.playbackMessage) && /uncertain/i.test(uncertainPresentation.playbackMessage)
&& uncertainPresentation.miniStation === 'RADIO STATE UNCERTAIN'
&& uncertainPresentation.dataRailText === 'UNCERTAIN' && uncertainPresentation.dataRailText === 'UNCERTAIN'
&& uncertainPresentation.dataRailState === 'uncertain' && uncertainPresentation.dataRailState === 'uncertain'
&& /uncertain/i.test(uncertainPresentation.dataRailLabel) && /uncertain/i.test(uncertainPresentation.dataRailLabel)
@@ -517,10 +516,6 @@ async function main() {
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive, presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show, markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
full: document.getElementById('radio-enable-btn').textContent, full: document.getElementById('radio-enable-btn').textContent,
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
layerState: document.getElementById('radio-layer-state').textContent, layerState: document.getElementById('radio-layer-state').textContent,
message: document.getElementById('radio-playback-state').textContent, message: document.getElementById('radio-playback-state').textContent,
stopDisabled: document.getElementById('radio-stop-btn').disabled, stopDisabled: document.getElementById('radio-stop-btn').disabled,
@@ -534,8 +529,6 @@ async function main() {
&& disablingPresentation.presentationActive === false && disablingPresentation.presentationActive === false
&& disablingPresentation.markerSourceVisible === false && disablingPresentation.markerSourceVisible === false
&& disablingPresentation.full === 'DISABLING' && disablingPresentation.full === 'DISABLING'
&& /compact Radio controls/i.test(disablingPresentation.disclosure)
&& disablingPresentation.compactPower === 'DISABLING'
&& disablingPresentation.layerState === 'DISABLING' && disablingPresentation.layerState === 'DISABLING'
&& /disabling/i.test(disablingPresentation.message) && /disabling/i.test(disablingPresentation.message)
&& disablingPresentation.stopDisabled && disablingPresentation.stopDisabled
@@ -592,10 +585,6 @@ async function main() {
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive, presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show, markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
full: document.getElementById('radio-enable-btn').textContent, full: document.getElementById('radio-enable-btn').textContent,
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
layerState: document.getElementById('radio-layer-state').textContent, layerState: document.getElementById('radio-layer-state').textContent,
message: document.getElementById('radio-playback-state').textContent, message: document.getElementById('radio-playback-state').textContent,
tunerHidden: document.getElementById('radio-tuner').hidden, tunerHidden: document.getElementById('radio-tuner').hidden,
@@ -610,10 +599,6 @@ async function main() {
&& enablingPresentation.presentationActive === false && enablingPresentation.presentationActive === false
&& enablingPresentation.markerSourceVisible === false && enablingPresentation.markerSourceVisible === false
&& enablingPresentation.full === 'ENABLING' && enablingPresentation.full === 'ENABLING'
&& enablingPresentation.disclosure === 'Go to expanded Radio section'
&& enablingPresentation.disclosureControls === 'radio-panel'
&& enablingPresentation.disclosureExpanded === 'true'
&& enablingPresentation.compactPower === 'ENABLING'
&& enablingPresentation.layerState === 'ENABLING' && enablingPresentation.layerState === 'ENABLING'
&& /enabling/i.test(enablingPresentation.message) && /enabling/i.test(enablingPresentation.message)
&& enablingPresentation.tunerHidden && enablingPresentation.tunerHidden
@@ -1581,112 +1566,9 @@ async function main() {
JSON.stringify(directoryRefreshContinuity), JSON.stringify(directoryRefreshContinuity),
); );
await page.select('#radio-filter', 'news'); await page.select('#radio-filter', 'news');
check('launcher enables Radio without forcing Context open', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext); check('enabled Radio stays nested in Context and leaves both panels collapsed', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext);
check('full Play is available before a station is selected', !initial.state.selected && initial.fullPlayEnabled && initial.fullPlayLabel === 'Play nearest radio station'); check('full Play is available before a station is selected', !initial.state.selected && initial.fullPlayEnabled && initial.fullPlayLabel === 'Play nearest radio station');
const hoverOnlyDisclosure = await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
if (button.getAttribute('aria-expanded') === 'true') button.click();
return {
expanded: button.getAttribute('aria-expanded'),
hidden: document.getElementById('context-radio-mini').hidden,
};
});
await page.hover('#context-radio-toggle-btn');
await sleep(100);
const hoverOnlyVisible = await page.$eval('#context-radio-mini', (mini) => (
!mini.hidden && getComputedStyle(mini).display !== 'none'
));
check(
'hover alone does not disclose compact Radio controls',
hoverOnlyDisclosure.expanded === 'false' && hoverOnlyDisclosure.hidden && !hoverOnlyVisible,
JSON.stringify({ hoverOnlyDisclosure, hoverOnlyVisible }),
);
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => {
const button = document.getElementById('context-radio-toggle-btn');
const mini = document.getElementById('context-radio-mini');
return button.getAttribute('aria-expanded') === 'true'
&& !mini.hidden && getComputedStyle(mini).display !== 'none';
});
const compact = await page.evaluate(() => {
const mini = document.getElementById('context-radio-mini');
const toggle = document.getElementById('context-radio-toggle-btn');
const dock = document.getElementById('context-radio-dock');
const miniRect = mini.getBoundingClientRect();
const toggleRect = toggle.getBoundingClientRect();
const miniStyle = getComputedStyle(mini);
return {
visible: miniStyle.display !== 'none' && miniRect.width > 0 && miniRect.height > 0,
display: miniStyle.display,
visibility: miniStyle.visibility,
opacity: miniStyle.opacity,
miniRect: { top: miniRect.top, left: miniRect.left, width: miniRect.width, height: miniRect.height },
toggleRect: { top: toggleRect.top, left: toggleRect.left, width: toggleRect.width, height: toggleRect.height },
dockRect: (() => {
const rect = dock.getBoundingClientRect();
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
})(),
offsetParent: mini.offsetParent?.id || mini.offsetParent?.className || null,
prevEnabled: !document.getElementById('context-radio-mini-prev-btn').disabled,
playEnabled: !document.getElementById('context-radio-mini-play-btn').disabled,
playLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
nextEnabled: !document.getElementById('context-radio-mini-next-btn').disabled,
volumeEnabled: !document.getElementById('context-radio-mini-volume').disabled,
offsetBelowToggle: Math.round(miniRect.top - toggleRect.bottom),
};
});
check(
'explicit launcher disclosure reveals enabled compact Radio controls',
compact.visible && compact.prevEnabled && compact.playEnabled && compact.nextEnabled && compact.volumeEnabled,
JSON.stringify(compact),
);
check('compact Play is available before a station is selected', compact.playLabel === 'Play nearest radio station');
check('compact Radio controls sit 20px below the launcher', compact.offsetBelowToggle === 20, `${compact.offsetBelowToggle}px`);
const compactCloseDisclosure = await page.evaluate(() => {
const launcher = document.getElementById('context-radio-toggle-btn');
const close = document.getElementById('context-radio-mini-close-btn');
close.focus();
close.click();
const result = {
launcherExpanded: launcher.getAttribute('aria-expanded'),
compactHidden: document.getElementById('context-radio-mini').hidden,
focusRestored: document.activeElement === launcher,
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
};
launcher.click();
return result;
});
check(
'dedicated compact close hides only the compact Radio disclosure and restores launcher focus',
compactCloseDisclosure.launcherExpanded === 'false'
&& compactCloseDisclosure.compactHidden
&& compactCloseDisclosure.focusRestored
&& compactCloseDisclosure.radioCollapsed,
JSON.stringify(compactCloseDisclosure),
);
const compactTouchDisclosure = await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
button.click();
const closed = button.getAttribute('aria-expanded') === 'false'
&& document.getElementById('context-radio-mini').hidden;
button.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
pointerType: 'touch',
isPrimary: true,
}));
button.click();
return {
closed,
reopened: button.getAttribute('aria-expanded') === 'true'
&& !document.getElementById('context-radio-mini').hidden,
};
});
check(
'touch/coarse-pointer activation can close and reopen compact Radio controls',
compactTouchDisclosure.closed && compactTouchDisclosure.reopened,
JSON.stringify(compactTouchDisclosure),
);
const multiExpandedPanelLayout = await page.evaluate(async () => { const multiExpandedPanelLayout = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
const ids = ['data-panel', 'scene-panel', 'pp-toggles', 'cctv-panel', 'global-context-panel']; const ids = ['data-panel', 'scene-panel', 'pp-toggles', 'cctv-panel', 'global-context-panel'];
@@ -2062,7 +1944,6 @@ async function main() {
hudVariant: manager.hud.getVariant(), hudVariant: manager.hud.getVariant(),
displayExpanded: display.getAttribute('aria-expanded') === 'true', displayExpanded: display.getAttribute('aria-expanded') === 'true',
radioExpanded: radio.getAttribute('aria-expanded') === 'true', radioExpanded: radio.getAttribute('aria-expanded') === 'true',
contextRadioExpanded: document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === 'true',
focusId: document.activeElement?.id || null, focusId: document.activeElement?.id || null,
detectionMode: manager.getDetectionState().detectionMode, detectionMode: manager.getDetectionState().detectionMode,
models3dEnabled: manager._models3dEnabled, models3dEnabled: manager._models3dEnabled,
@@ -2158,11 +2039,13 @@ async function main() {
for (let index = 0; index < 5; index += 1) { for (let index = 0; index < 5; index += 1) {
recordLayoutStep(); recordLayoutStep();
if (index < 4) { if (index < 4) {
if (index < 2) { // Off and back on in Minimal, so the toggle must restore a
hudLayout.value = index === 0 ? 'operator' : 'minimal'; // non-default layout before the cycle returns to Tactical.
hudLayout.dispatchEvent(new Event('change', { bubbles: true })); if (index === 1 || index === 2) {
} else {
hudToggle.click(); hudToggle.click();
} else {
hudLayout.value = index === 0 ? 'minimal' : 'tactical';
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
} }
await waitForHudSettle(); await waitForHudSettle();
} }
@@ -2287,8 +2170,7 @@ async function main() {
document.getElementById('cockpit-radio-next-btn'), document.getElementById('cockpit-radio-next-btn'),
].every((button) => button?.isConnected && !button.disabled); ].every((button) => button?.isConnected && !button.disabled);
const detailsExcluded = !document.querySelector( const detailsExcluded = !document.querySelector(
'#cockpit-radio-panel #context-radio-details-btn, ' '#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner',
+ '#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner',
); );
const scenesHidden = getComputedStyle(scene).display === 'none'; const scenesHidden = getComputedStyle(scene).display === 'none';
radio.click(); radio.click();
@@ -2331,7 +2213,7 @@ async function main() {
scenesHidden, scenesHidden,
mapProviderUtilityStable, mapProviderUtilityStable,
hudInteractionsExact: layoutSteps.map((step) => step.hud).join('>') hudInteractionsExact: layoutSteps.map((step) => step.hud).join('>')
=== 'TACTICAL>OPERATOR>MINIMAL>OFF>MINIMAL', === 'TACTICAL>MINIMAL>OFF>MINIMAL>TACTICAL',
// The accordion clears the topline, Cockpit cards, and Cesium credits // The accordion clears the topline, Cockpit cards, and Cesium credits
// across every HUD layout. Its list owns any overflow internally. // across every HUD layout. Its list owns any overflow internally.
leftLaneClearAcrossHudCycle: layoutSteps.every((step) => ( leftLaneClearAcrossHudCycle: layoutSteps.every((step) => (
@@ -2381,7 +2263,6 @@ async function main() {
manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed); manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed);
if (display.getAttribute('aria-expanded') !== String(prior.displayExpanded)) display.click(); if (display.getAttribute('aria-expanded') !== String(prior.displayExpanded)) display.click();
if (radio.getAttribute('aria-expanded') !== String(prior.radioExpanded)) radio.click(); if (radio.getAttribute('aria-expanded') !== String(prior.radioExpanded)) radio.click();
manager._setRadioDisclosure(prior.contextRadioExpanded);
hud.hidden = prior.hudHidden; hud.hidden = prior.hudHidden;
signal.hidden = prior.signalHidden; signal.hidden = prior.signalHidden;
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop); if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
@@ -2416,7 +2297,6 @@ async function main() {
&& document.getElementById('global-context-panel').classList.contains('collapsed') === prior.contextCollapsed && document.getElementById('global-context-panel').classList.contains('collapsed') === prior.contextCollapsed
&& display.getAttribute('aria-expanded') === String(prior.displayExpanded) && display.getAttribute('aria-expanded') === String(prior.displayExpanded)
&& radio.getAttribute('aria-expanded') === String(prior.radioExpanded) && radio.getAttribute('aria-expanded') === String(prior.radioExpanded)
&& document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded)
&& hud.hidden === prior.hudHidden && hud.hidden === prior.hudHidden
&& signal.hidden === prior.signalHidden && signal.hidden === prior.signalHidden
&& intelHud.style.getPropertyValue('transition') === priorHudTransition && intelHud.style.getPropertyValue('transition') === priorHudTransition
@@ -2472,21 +2352,20 @@ async function main() {
check('first Play scenario starts without a leaked Radio selection', firstPlayPrecondition === null, check('first Play scenario starts without a leaked Radio selection', firstPlayPrecondition === null,
`selected=${firstPlayPrecondition}`); `selected=${firstPlayPrecondition}`);
await page.select('#radio-filter', 'all'); await page.select('#radio-filter', 'all');
await page.$eval('#context-radio-toggle-btn', (button) => {
if (button.getAttribute('aria-expanded') !== 'true') button.click();
});
await page.evaluate(() => { await page.evaluate(() => {
const camera = window.__godsEyeView.viewer.camera; const camera = window.__godsEyeView.viewer.camera;
window.__qaFirstPlayFlyToCalls = []; window.__qaFirstPlayFlyToCalls = [];
window.__qaFirstPlayOriginalFlyTo = camera.flyTo; window.__qaFirstPlayOriginalFlyTo = camera.flyTo;
camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options); camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options);
document.getElementById('context-radio-toggle-btn').focus();
}); });
await page.$eval('#context-radio-mini-play-btn', (button) => button.click()); const firstPlayPanels = await revealRadioControls(page, 'radio-play-btn');
await page.focus('#radio-play-btn');
await page.click('#radio-play-btn');
await page.waitForFunction(() => { await page.waitForFunction(() => {
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState(); const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
return state.audioState === 'playing' || state.audioState === 'error'; return state.audioState === 'playing' || state.audioState === 'error';
}); });
await restoreRadioPanels(page, firstPlayPanels);
const postMicroDragPlayback = await page.evaluate(async () => { const postMicroDragPlayback = await page.evaluate(async () => {
const mod = window.__godsEyeView.dataManager.layers.get('radio').module; const mod = window.__godsEyeView.dataManager.layers.get('radio').module;
let state = mod.getUIState(); let state = mod.getUIState();
@@ -2593,59 +2472,19 @@ async function main() {
); );
await page.select('#radio-filter', 'news'); await page.select('#radio-filter', 'news');
await page.$eval('#context-radio-mini-volume', (input) => { await page.$eval('#radio-volume', (input) => {
input.value = '35'; input.value = '35';
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
}); });
const compactVolume = await page.evaluate(() => ({ const radioVolume = await page.evaluate(() => ({
volume: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().volume, volume: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().volume,
full: document.getElementById('radio-volume').value, full: document.getElementById('radio-volume').value,
mini: document.getElementById('context-radio-mini-volume').value, label: document.getElementById('radio-volume-value').textContent,
})); }));
check('compact volume shares the full Radio player state', Math.abs(compactVolume.volume - 0.35) < 0.001 && compactVolume.full === '35' && compactVolume.mini === '35');
const compactDisclosureContinuity = await page.evaluate(async () => {
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', true);
manager._setRadioDisclosure(true);
await radio.play({ origin: 'user' });
const before = radio.getUIState();
const disclosure = document.getElementById('context-radio-toggle-btn');
if (disclosure.getAttribute('aria-expanded') !== 'true') disclosure.click();
disclosure.click();
const closed = {
expanded: disclosure.getAttribute('aria-expanded'),
hidden: document.getElementById('context-radio-mini').hidden,
state: radio.getUIState(),
};
disclosure.click();
return {
before,
closed,
reopened: disclosure.getAttribute('aria-expanded'),
miniHidden: document.getElementById('context-radio-mini').hidden,
after: radio.getUIState(),
};
});
check( check(
'compact disclosure preserves playback, station, filter, and volume continuity', 'full Radio volume drives the Radio player state',
compactDisclosureContinuity.closed.expanded === 'false' Math.abs(radioVolume.volume - 0.35) < 0.001 && radioVolume.full === '35' && radioVolume.label === '35%',
&& compactDisclosureContinuity.closed.hidden JSON.stringify(radioVolume),
&& compactDisclosureContinuity.reopened === 'true'
&& !compactDisclosureContinuity.miniHidden
&& compactDisclosureContinuity.before.audioState === 'playing'
&& compactDisclosureContinuity.closed.state.audioState === 'playing'
&& compactDisclosureContinuity.after.audioState === 'playing'
&& compactDisclosureContinuity.after.selected?.id === compactDisclosureContinuity.before.selected?.id
&& compactDisclosureContinuity.after.filter === compactDisclosureContinuity.before.filter
&& compactDisclosureContinuity.after.volume === compactDisclosureContinuity.before.volume,
JSON.stringify({
before: compactDisclosureContinuity.before,
closed: compactDisclosureContinuity.closed,
reopened: compactDisclosureContinuity.reopened,
after: compactDisclosureContinuity.after,
}),
); );
const voicePause = await page.evaluate(async () => { const voicePause = await page.evaluate(async () => {
@@ -2709,11 +2548,13 @@ async function main() {
idleVoice.audioState === 'paused', idleVoice.audioState === 'paused',
idleVoice.audioState, idleVoice.audioState,
); );
const manualPlayTarget = await page.evaluate(() => { const manualPlayTarget = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', true); manager.setPanelCollapsed('global-context-panel', false);
manager._setRadioDisclosure(true); manager.setPanelCollapsed('radio-panel', false);
const button = document.getElementById('context-radio-mini-play-btn'); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const button = document.getElementById('radio-play-btn');
button.scrollIntoView({ block: 'nearest' });
const rect = button.getBoundingClientRect(); const rect = button.getBoundingClientRect();
const style = getComputedStyle(button); const style = getComputedStyle(button);
return { return {
@@ -2728,12 +2569,12 @@ async function main() {
}; };
}); });
check( check(
'manual voice takeover targets the visible compact Radio Play control', 'manual voice takeover targets the visible full Radio Play control',
manualPlayTarget.visible && manualPlayTarget.enabled, manualPlayTarget.visible && manualPlayTarget.enabled,
JSON.stringify(manualPlayTarget), JSON.stringify(manualPlayTarget),
); );
if (manualPlayTarget.visible && manualPlayTarget.enabled) { if (manualPlayTarget.visible && manualPlayTarget.enabled) {
await page.click('#context-radio-mini-play-btn'); await page.click('#radio-play-btn');
await page.waitForFunction(() => ( await page.waitForFunction(() => (
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing' window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'
&& window.__godsEyeView.voiceCommands.status === 'idle' && window.__godsEyeView.voiceCommands.status === 'idle'
@@ -2781,7 +2622,6 @@ async function main() {
await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('idle', 'QA complete')); await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('idle', 'QA complete'));
await page.evaluate(async () => { await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
manager._setRadioDisclosure(false);
manager.setPanelCollapsed('global-context-panel', false); manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false); manager.setPanelCollapsed('radio-panel', false);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
@@ -3835,22 +3675,22 @@ async function main() {
gev.dataManager.layers.get('flights').enabled = true; gev.dataManager.layers.get('flights').enabled = true;
}); });
await sleep(500); await sleep(500);
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true));
await page.evaluate(() => { await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn'); const manager = window.__godsEyeView.styleManager;
if (button.getAttribute('aria-expanded') !== 'true') button.click(); manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false);
}); });
await page.waitForSelector('#context-radio-mini', { visible: true }); await page.waitForSelector('#radio-next-btn', { visible: true });
await page.evaluate(() => { await page.evaluate(() => {
window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height; window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height;
}); });
const radioOnlyNextDispatched = await page.evaluate(() => { const radioOnlyNextDispatched = await page.evaluate(() => {
const button = document.getElementById('context-radio-mini-next-btn'); const button = document.getElementById('radio-next-btn');
if (!button) return false; if (!button) return false;
button.click(); button.click();
return true; return true;
}); });
if (!radioOnlyNextDispatched) throw new Error('Radio compact next button unavailable'); if (!radioOnlyNextDispatched) throw new Error('Radio next button unavailable');
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function'); await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
await page.evaluate(() => { await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer; const viewer = window.__godsEyeView.viewer;
@@ -3885,7 +3725,7 @@ async function main() {
flyToCalls: window.__qaRadioFlyToCalls.length, flyToCalls: window.__qaRadioFlyToCalls.length,
flightTargets, flightTargets,
cameraHeight: window.__qaRadioCameraHeight, cameraHeight: window.__qaRadioCameraHeight,
compactPlayLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'), playLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
regularMarkerSize: Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => window.__godsEyeView.viewer.dataSources.get(index)) regularMarkerSize: Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => window.__godsEyeView.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations')?.entities.values[0]?.point?.pixelSize?.getValue(), .find((item) => item.name === 'Radio stations')?.entities.values[0]?.point?.pixelSize?.getValue(),
selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(), selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(),
@@ -3898,7 +3738,7 @@ async function main() {
}; };
}); });
check( check(
'compact Next follows stable directory order and retries its next available station', 'Radio Next follows stable directory order and retries its next available station',
playing.state.selected?.id === playing.expectedFallback?.id playing.state.selected?.id === playing.expectedFallback?.id
&& playing.calls[0] === playing.expectedPrimary?.streamUrl && playing.calls[0] === playing.expectedPrimary?.streamUrl
&& playing.calls[1] === playing.expectedFallback?.streamUrl, && playing.calls[1] === playing.expectedFallback?.streamUrl,
@@ -3909,9 +3749,9 @@ async function main() {
fallback: playing.expectedFallback?.id, fallback: playing.expectedFallback?.id,
}), }),
); );
check('compact next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.compactPlayLabel.startsWith('Pause')); check('Radio next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.playLabel.startsWith('Pause'));
check( check(
'compact next rotates toward its station without changing zoom, including fallback', 'Radio next rotates toward its station without changing zoom, including fallback',
playing.flyToCalls === 2 playing.flyToCalls === 2
&& Math.abs(playing.flightTargets[0]?.lat - playing.expectedPrimary?.lat) < 0.001 && Math.abs(playing.flightTargets[0]?.lat - playing.expectedPrimary?.lat) < 0.001
&& Math.abs(playing.flightTargets[0]?.lon - playing.expectedPrimary?.lon) < 0.001 && Math.abs(playing.flightTargets[0]?.lon - playing.expectedPrimary?.lon) < 0.001
@@ -3950,11 +3790,11 @@ async function main() {
}; };
return [ return [
await run('flights', 'radio-next-btn'), await run('flights', 'radio-next-btn'),
await run('military', 'context-radio-mini-prev-btn'), await run('military', 'radio-prev-btn'),
]; ];
}); });
check( check(
'full and compact Radio navigation preserve Flights/Military camera ownership', 'full Radio Next and Prev navigation preserve Flights/Military camera ownership',
trackedRadioControls.every((sample) => sample.afterStationId trackedRadioControls.every((sample) => sample.afterStationId
&& sample.afterStationId !== sample.beforeStationId && sample.afterStationId !== sample.beforeStationId
&& sample.flyToDelta === 0 && sample.flyToDelta === 0
@@ -3968,7 +3808,7 @@ async function main() {
viewer.trackedEntity = undefined; viewer.trackedEntity = undefined;
window.__qaRadioDelayNextFailure = true; window.__qaRadioDelayNextFailure = true;
const beforeFlyToCalls = window.__qaRadioFlyToCalls.length; const beforeFlyToCalls = window.__qaRadioFlyToCalls.length;
document.getElementById('context-radio-mini-next-btn')?.click(); document.getElementById('radio-next-btn')?.click();
return { beforeFlyToCalls }; return { beforeFlyToCalls };
}); });
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function'); await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
@@ -4244,7 +4084,7 @@ async function main() {
window.__godsEyeView.dataManager.layers.get('flights').enabled = false; window.__godsEyeView.dataManager.layers.get('flights').enabled = false;
}); });
const radioOnlyNextClicked = await page.evaluate(() => { const radioOnlyNextClicked = await page.evaluate(() => {
const button = document.getElementById('context-radio-mini-next-btn'); const button = document.getElementById('radio-next-btn');
if (!(button instanceof HTMLButtonElement)) return false; if (!(button instanceof HTMLButtonElement)) return false;
button.click(); button.click();
return true; return true;
@@ -4508,106 +4348,12 @@ async function main() {
&& companion.radioCollapsed && !companion.contextCollapsed && companion.nested, && companion.radioCollapsed && !companion.contextCollapsed && companion.nested,
); );
const expandedContextRadioBefore = await page.evaluate(() => {
const gev = window.__godsEyeView;
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
scroller.scrollTop = 0;
const camera = gev.viewer.camera.positionWC;
return {
scrollTop: scroller.scrollTop,
pageY: window.scrollY,
camera: { x: camera.x, y: camera.y, z: camera.z },
plays: window.__qaRadioPlayCalls.length,
};
});
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
await page.waitForFunction((priorScroll) => (
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
const expandedContextRadioAfter = await page.evaluate(() => {
const gev = window.__godsEyeView;
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
const launcher = document.getElementById('context-radio-toggle-btn');
const radio = document.getElementById('radio-panel');
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
const camera = gev.viewer.camera.positionWC;
return {
ariaControls: launcher.getAttribute('aria-controls'),
expanded: launcher.getAttribute('aria-expanded'),
label: launcher.getAttribute('aria-label'),
radioCollapsed: radio.classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
scrollTop: scroller.scrollTop,
focusTarget: document.activeElement?.getAttribute('data-collapse-target'),
pageY: window.scrollY,
camera: { x: camera.x, y: camera.y, z: camera.z },
plays: window.__qaRadioPlayCalls.length,
state: gev.dataManager.layers.get('radio').module.getUIState().audioState,
};
});
const expandedContextRadioCameraDelta = Math.hypot(
expandedContextRadioAfter.camera.x - expandedContextRadioBefore.camera.x,
expandedContextRadioAfter.camera.y - expandedContextRadioBefore.camera.y,
expandedContextRadioAfter.camera.z - expandedContextRadioBefore.camera.z,
);
check(
'expanded Context Radio icon reveals and scrolls to embedded Radio without an overlay or playback/camera theft',
expandedContextRadioAfter.ariaControls === 'radio-panel'
&& expandedContextRadioAfter.expanded === 'true'
&& expandedContextRadioAfter.label === 'Go to expanded Radio section'
&& !expandedContextRadioAfter.radioCollapsed
&& expandedContextRadioAfter.compactHidden
&& expandedContextRadioAfter.scrollTop > expandedContextRadioBefore.scrollTop
&& expandedContextRadioAfter.focusTarget === 'radio-panel'
&& expandedContextRadioAfter.pageY === expandedContextRadioBefore.pageY
&& expandedContextRadioCameraDelta < 0.01
&& expandedContextRadioAfter.plays === expandedContextRadioBefore.plays
&& expandedContextRadioAfter.state === 'playing',
JSON.stringify({
before: expandedContextRadioBefore,
after: expandedContextRadioAfter,
cameraDelta: expandedContextRadioCameraDelta,
}),
);
await page.evaluate(() => { await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', true); manager.setPanelCollapsed('global-context-panel', false);
manager._setRadioDisclosure(true); manager.setPanelCollapsed('radio-panel', false);
}); });
await page.waitForFunction(() => { await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
const close = document.getElementById('context-radio-mini-close-btn');
const rect = close.getBoundingClientRect();
return !document.getElementById('context-radio-mini').hidden && rect.width > 0 && rect.height > 0;
});
await page.click('#context-radio-mini-close-btn');
const closedCompact = await page.evaluate(() => {
const launcher = document.getElementById('context-radio-toggle-btn');
return {
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
focusRestored: document.activeElement === launcher,
};
});
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => !document.getElementById('context-radio-mini').hidden);
await page.click('#context-radio-details-btn');
const repeatedOpen = await page.evaluate(() => ({
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
}));
const splitDetailActions = { closedCompact, repeatedOpen };
check(
'collapsed Context keeps compact close and full-panel open as separate stable actions',
!splitDetailActions.closedCompact.radioCollapsed
&& splitDetailActions.closedCompact.compactHidden
&& splitDetailActions.closedCompact.focusRestored
&& !splitDetailActions.repeatedOpen.radioCollapsed
&& splitDetailActions.repeatedOpen.compactHidden,
JSON.stringify(splitDetailActions),
);
await page.click('#global-context-flights-btn'); await page.click('#global-context-flights-btn');
await page.waitForFunction(() => !document.getElementById('context-flights-view').hidden); await page.waitForFunction(() => !document.getElementById('context-flights-view').hidden);
+33
View File
@@ -106,6 +106,39 @@ try {
), ),
), ),
); );
// A long rename keeps its editor visible inside the shot row.
await page.click('.scene-shot-label', { count: 2 });
await page.waitForSelector('.scene-shot-rename', { visible: true });
await page.type(
'.scene-shot-rename',
' with a much longer title that runs well past the shot row',
);
await page.keyboard.press('End');
check(
'A long rename keeps the editor visible inside its shot row',
await page.evaluate(() => {
// The label clips with an ellipsis, which hides an editor that
// overflows it, so its content must fit its width.
const input = document.querySelector('.scene-shot-rename');
const label = input?.closest('.scene-shot-label');
const box = input?.getBoundingClientRect();
const style = input && getComputedStyle(input);
return (
!!box &&
box.width > 120 &&
box.height > 10 &&
label.scrollWidth <= label.clientWidth + 1 &&
style.visibility === 'visible' &&
style.borderTopWidth !== '0px' &&
document.activeElement === input
);
}),
);
await page.keyboard.press('Escape');
await page.waitForFunction(
() => !document.querySelector('.scene-shot-rename'),
);
await page.click('#scene-capture-btn'); await page.click('#scene-capture-btn');
await page.waitForFunction( await page.waitForFunction(
() => document.querySelectorAll('.scene-shot-row').length === 2, () => document.querySelectorAll('.scene-shot-row').length === 2,
+5 -12
View File
@@ -345,7 +345,7 @@ try {
(await readScope()).feather === 2, (await readScope()).feather === 2,
); );
await page.click('#scope-toggle'); await page.click('#scope-toggle');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) { for (const layout of ['minimal', 'cyber', 'tactical']) {
await page.select('#hud-layout-select', layout); await page.select('#hud-layout-select', layout);
const state = await readScope(); const state = await readScope();
check( check(
@@ -395,7 +395,7 @@ try {
restored, restored,
); );
for (const layout of ['minimal', 'operator', 'tactical']) { for (const layout of ['minimal', 'tactical']) {
await page.select('#hud-layout-select', layout); await page.select('#hud-layout-select', layout);
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({ window.__godsEyeView.styleManager.applyVisualState({
@@ -429,20 +429,13 @@ try {
wrappersTransparent: wrappers.every( wrappersTransparent: wrappers.every(
(el) => getComputedStyle(el).display === 'contents', (el) => getComputedStyle(el).display === 'contents',
), ),
operatorFrame:
hud.dataset.variant !== 'operator' ||
(getComputedStyle(hud.querySelector('.hud-corner'))
.backgroundColor === 'rgba(0, 0, 0, 0.36)' &&
getComputedStyle(hud.querySelector('.hud-top-bar'))
.backgroundImage !== 'none'),
}; };
}); });
check( check(
`${layout}: Scope ON retains the original presentation without new backplates`, `${layout}: Scope ON retains the original presentation without new backplates`,
original.markerAbsent && original.markerAbsent &&
original.unbacked && original.unbacked &&
original.wrappersTransparent && original.wrappersTransparent,
original.operatorFrame,
original, original,
); );
check( check(
@@ -571,7 +564,7 @@ try {
); );
}); });
const broadBackings = [ const broadBackings = [
...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'), ...hud.querySelectorAll('.hud-corner, .hud-bottom-bar'),
].filter( ].filter(
(el) => (el) =>
el.offsetWidth > 0 && el.offsetWidth > 0 &&
@@ -656,7 +649,7 @@ try {
}), }),
); );
check('narrow HUD text backings stay within the viewport', narrow); check('narrow HUD text backings stay within the viewport', narrow);
for (const layout of ['minimal', 'operator', 'tactical']) { for (const layout of ['minimal', 'tactical']) {
await page.select('#hud-layout-select', layout); await page.select('#hud-layout-select', layout);
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({ window.__godsEyeView.styleManager.applyVisualState({
-1
View File
@@ -80,7 +80,6 @@ try {
'thermal', 'thermal',
'anime', 'anime',
'noir', 'noir',
'snow',
'normal', 'normal',
]) { ]) {
await page.evaluate( await page.evaluate(
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env node
// Minimal screenshot sink: the browser POSTs a data:image/... URL (grabbed from
// the real-GPU Cesium canvas) and this writes it to qa-shots/height-datum/<name>.
// Keeps large image payloads OUT of the agent's context — browser → sink → disk.
// Usage: node scripts/shot-sink.mjs (listens on :4399)
import http from 'node:http';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'qa-shots', 'height-datum');
mkdirSync(OUT, { recursive: true });
const PORT = 4399;
const cors = (res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
};
http.createServer((req, res) => {
cors(res);
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const url = new URL(req.url, 'http://localhost');
if (req.method === 'POST' && url.pathname === '/save') {
const name = (url.searchParams.get('name') || 'shot').replace(/[^a-zA-Z0-9._-]/g, '_');
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
try {
const m = /^data:image\/(png|jpeg);base64,(.+)$/s.exec(body.trim());
if (!m) { res.writeHead(400); res.end('bad dataurl'); return; }
const ext = m[1] === 'jpeg' ? 'jpg' : 'png';
const file = path.join(OUT, `${name}.${ext}`);
writeFileSync(file, Buffer.from(m[2], 'base64'));
res.writeHead(200); res.end(file);
console.log(`saved ${file} (${Math.round(m[2].length / 1024)} KB b64)`);
} catch (e) { res.writeHead(500); res.end(String(e)); }
});
return;
}
res.writeHead(404); res.end('nope');
}).listen(PORT, () => console.log(`shot-sink on http://localhost:${PORT} → ${OUT}`));
+6
View File
@@ -28,6 +28,7 @@ import {
loadNycSourcesFromOpenData, loadNycSourcesFromOpenData,
loadTorontoSourcesFromOpenData, loadTorontoSourcesFromOpenData,
loadFloridaSourcesFromOpenData, loadFloridaSourcesFromOpenData,
loadA22SourcesFromOpenDataHub,
} from './sources.js'; } from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */ /** Env kill switch: unset or anything but "0" means enabled. */
@@ -165,6 +166,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'), enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
load: loadTaiwanSources, load: loadTaiwanSources,
}, },
{
name: 'a22',
enabled: () => envEnabled('CCTV_A22_ENABLED'),
load: loadA22SourcesFromOpenDataHub,
},
]; ];
/** /**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
+33
View File
@@ -443,6 +443,39 @@ export const NYC_ANCHORS = [
{ lat: 40.5795, lon: -74.1502 }, // Staten Island { lat: 40.5795, lon: -74.1502 }, // Staten Island
]; ];
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
* own camera list with coordinates, one request for the whole motorway
* (13 cameras). Frames are stills on the operator's host, refreshed about
* once a minute. */
export const DEFAULT_A22_CCTV_URL =
'https://tourism.api.opendatahub.com/v1/WebcamInfo?source=a22&pagesize=200&removenullvalues=true';
/** The only origin A22 camera frames may come from: `km<N>.jpg` per camera. */
export const A22_IMAGE_ORIGIN = 'https://www.autobrennero.it/WebCamImg/';
/** Hard ceiling on the catalog body. The whole motorway is ~45 KB. */
export const A22_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
/** Orthometric ground elevation (m) at each camera, keyed by its motorway
* kilometre, from the Copernicus GLO-90 DEM via Open-Meteo's elevation API.
* The feed's coordinates are 2D and the road climbs from the Po valley (~20 m)
* to the Brenner Pass (~1,370 m), so one flat prior would bury the Alpine
* cameras. A camera not listed here uses A22_DEFAULT_GROUND_ELEVATION_M. */
export const A22_GROUND_ELEVATION_BY_KM = Object.freeze({
1: 1365,
68: 375,
77: 301,
100: 219,
129: 206,
138: 204,
159: 175,
205: 188,
228: 72,
240: 38,
272: 16,
285: 19,
313: 40,
});
export const A22_DEFAULT_GROUND_ELEVATION_M = 200;
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ /** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
export const DEFAULT_DELDOT_MAX_SOURCES = 300; export const DEFAULT_DELDOT_MAX_SOURCES = 300;
+2
View File
@@ -1,6 +1,7 @@
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { hashSeed, escapeXml, pinnedCameraUrl } from './normalize.js'; import { hashSeed, escapeXml, pinnedCameraUrl } from './normalize.js';
import { import {
A22_IMAGE_ORIGIN,
CALGARY_IMAGE_ORIGIN, CALGARY_IMAGE_ORIGIN,
CCTV_FRAME_FETCH_TIMEOUT_MS, CCTV_FRAME_FETCH_TIMEOUT_MS,
CCTV_FRAME_MAX_BODY_BYTES, CCTV_FRAME_MAX_BODY_BYTES,
@@ -528,6 +529,7 @@ for (const prefix of [
HK_IMAGE_ORIGIN, HK_IMAGE_ORIGIN,
KING_COUNTY_IMAGE_ORIGIN, KING_COUNTY_IMAGE_ORIGIN,
WSDOT_IMAGE_ORIGIN, WSDOT_IMAGE_ORIGIN,
A22_IMAGE_ORIGIN,
]) { ]) {
const host = new URL(prefix).hostname; const host = new URL(prefix).hostname;
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [ CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
+11
View File
@@ -421,6 +421,17 @@ export function isLikelyNorwayCoordinate(lat, lon) {
); );
} }
/** The A22 corridor, Brenner Pass to Modena, with slack either side. */
export function isLikelyA22Coordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 44.5 &&
lat <= 47.1 &&
lon >= 10.5 &&
lon <= 11.8
);
}
export function isLikelyFinlandCoordinate(lat, lon) { export function isLikelyFinlandCoordinate(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32; return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
+126
View File
@@ -122,6 +122,11 @@ import {
NYC_MAX_CATALOG_BYTES, NYC_MAX_CATALOG_BYTES,
NYC_GROUND_ELEVATION_M, NYC_GROUND_ELEVATION_M,
NYC_ANCHORS, NYC_ANCHORS,
DEFAULT_A22_CCTV_URL,
A22_IMAGE_ORIGIN,
A22_MAX_CATALOG_BYTES,
A22_GROUND_ELEVATION_BY_KM,
A22_DEFAULT_GROUND_ELEVATION_M,
} from './constants.js'; } from './constants.js';
import { import {
toFiniteNumber, toFiniteNumber,
@@ -145,6 +150,7 @@ import {
isLikelyHongKongCoordinate, isLikelyHongKongCoordinate,
isLikelyNorwayCoordinate, isLikelyNorwayCoordinate,
isLikelyTorontoCoordinate, isLikelyTorontoCoordinate,
isLikelyA22Coordinate,
cameraDisplayCode, cameraDisplayCode,
rowArrayToObject, rowArrayToObject,
prioritizeSources, prioritizeSources,
@@ -3645,3 +3651,123 @@ export async function loadFloridaSourcesFromOpenData() {
return []; return [];
} }
} }
/**
* One Open Data Hub `WebcamInfo` item (source `a22`) -> one catalog source,
* or null.
*
* The frame must be the operator's own `WebCamImg/km<N>.jpg` for the
* camera's motorway kilometre. The feed names the place and the kilometre but
* no bearing, so the heading uses the shared id-hash fallback at low
* confidence, like Calgary and Vegvesen; the ground elevation comes from the
* per-kilometre DEM table because the road spans ~1,350 m of altitude.
*
* @param {object} item - One element of the API's `Items` array.
* @returns {?object}
*/
export function a22WebcamToSource(item) {
if (!item || typeof item !== 'object') return null;
if (item.Active === false) return null;
if (String(item.Source ?? '').toLowerCase() !== 'a22') return null;
const gps = Array.isArray(item.GpsInfo) ? item.GpsInfo[0] : null;
const lat = toFiniteNumber(gps?.Latitude);
const lon = toFiniteNumber(gps?.Longitude);
if (!isLikelyA22Coordinate(lat, lon)) return null;
const km = String(item.Mapping?.a22?.km ?? '').trim();
if (!/^\d{1,3}$/.test(km)) return null;
const imageUrl = `${A22_IMAGE_ORIGIN}km${Number(km)}.jpg`;
let image;
try {
image = new URL(String(item.Webcamurl ?? ''));
} catch {
return null;
}
if (image.username || image.password || image.href !== imageUrl) return null;
const place =
String(item.Shortname ?? item.Webcamname?.it ?? '')
.replace(/\s+/g, ' ')
.trim() || `Km ${Number(km)}`;
const cameraId = `it-a22-km${Number(km)}`;
const ground = A22_GROUND_ELEVATION_BY_KM[Number(km)];
return {
id: cameraId,
name: `A22 ${place} (km ${Number(km)})`,
// One motorway-wide category in the camera picker; the place is already
// in the name.
city: 'A22 Brennero',
cityId: 'a22',
provider: 'Autostrada del Brennero',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -15,
fovDeg: 50,
rangeM: 160,
mountHeightM: 8,
groundElevationM: Number.isFinite(ground)
? ground
: A22_DEFAULT_GROUND_ELEVATION_M,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'a22-opendatahub',
license:
'Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)',
code: cameraDisplayCode(place.toUpperCase()),
};
}
/**
* Fetch the Autostrada del Brennero (A22) webcams from the keyless Open Data
* Hub tourism API. Frames are stills on www.autobrennero.it.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadA22SourcesFromOpenDataHub() {
try {
const endpoint = process.env.CCTV_A22_URL || DEFAULT_A22_CCTV_URL;
const resp = await fetch(endpoint, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn('[CCTV] A22 catalog redirected; redirects are not followed');
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] A22 camera download failed:', resp.status);
return discard();
}
const payload = await readResponseJsonCapped(resp, A22_MAX_CATALOG_BYTES);
const items = Array.isArray(payload?.Items) ? payload.Items : [];
const cameras = [];
const seen = new Set();
for (const item of items) {
const camera = a22WebcamToSource(item);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
// North to south along the motorway, so the picker reads in road order.
cameras.sort((a, b) => b.lat - a.lat);
console.log(`[CCTV] Loaded A22 camera sources: ${cameras.length}`);
return cameras;
} catch (error) {
console.warn('[CCTV] A22 camera download error:', error?.message || error);
return [];
}
}
+10 -1
View File
@@ -39,6 +39,8 @@ const API_URL =
// The service caps a single response at its maxRecordCount (2000); a peak // The service caps a single response at its maxRecordCount (2000); a peak
// season can exceed that, so follow exceededTransferLimit with offset pages. // season can exceed that, so follow exceededTransferLimit with offset pages.
const MAX_PAGES = 5; const MAX_PAGES = 5;
/** Longest a failed refresh keeps serving the last perimeter snapshot, marked stale. */
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
const exceededTransferLimit = (payload) => const exceededTransferLimit = (payload) =>
payload?.exceededTransferLimit === true || payload?.exceededTransferLimit === true ||
@@ -165,7 +167,14 @@ export function firePerimetersProxy({
}); });
return { value: await promise, stale: false }; return { value: await promise, stale: false };
} catch (error) { } catch (error) {
if (previous) return { value: previous.value, stale: true }; // A failed refresh serves the last answer, marked stale. A perimeter
// snapshot only for a day: older than that, it is no picture of
// current fires. (InciWeb entries keep their previous behaviour.)
if (
previous &&
(key !== 'perimeters' || now() - previous.savedAt <= STALE_MAX_MS)
)
return { value: previous.value, stale: true };
throw error; throw error;
} }
} }
+138 -6
View File
@@ -1,9 +1,132 @@
import path from 'node:path'; import path from 'node:path';
import https from 'node:https';
import { promises as fsp } from 'node:fs'; import { promises as fsp } from 'node:fs';
import { Readable } from 'node:stream';
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js'; import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
import { readResponseTextCapped } from './common/http.js'; import { readResponseTextCapped } from './common/http.js';
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
/**
* The per-address connection errors behind a fetch failure, or null. Node 24
* puts the AggregateError directly on `err.cause`; Node 26's fetch wraps it in
* a ConnectTimeoutError one level deeper.
* @param {unknown} err
* @returns {?Array<unknown>}
*/
function connectAttemptErrors(err) {
let cause = err?.cause;
for (let depth = 0; cause && depth < 3; depth++) {
if (Array.isArray(cause.errors)) return cause.errors;
cause = cause.cause;
}
return null;
}
/**
* Describe a fetch failure with its socket-level cause. Node reports every
* connection failure as `fetch failed`; the cause carries the error code and
* the address that failed (never the URL, which embeds the MAP_KEY).
* @param {unknown} err
* @returns {string}
*/
export function describeFetchError(err) {
const message = err?.message || String(err);
const cause = err?.cause;
if (!cause) return message;
const attempts = connectAttemptErrors(err);
const parts = attempts?.length
? attempts.map((inner) => inner?.message || inner?.code || String(inner))
: [cause.message || cause.code || String(cause)];
return `${message} (${parts.join('; ')})`;
}
/**
* Whether a fetch failed because every address it tried failed to connect.
* Node's network-family auto-selection gives each address a short attempt
* timeout, so a slow IPv4 handshake is abandoned in favour of an unreachable
* IPv6 address and the whole request fails (#68).
* @param {unknown} err
* @returns {boolean}
*/
export function isAddressSelectionFailure(err) {
return err instanceof TypeError && connectAttemptErrors(err)?.length > 0;
}
/**
* GET over IPv4 only, with one connection attempt bounded by the caller's
* signal rather than by the auto-selection attempt timeout.
* @param {string} url
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<Response>}
*/
export function fetchIpv4(url, { signal } = {}) {
return new Promise((resolve, reject) => {
const request = https.request(
url,
{
method: 'GET',
signal,
family: 4,
autoSelectFamily: false,
agent: false,
},
(response) => {
// Nothing thrown here may escape: this callback runs outside the
// promise, so an exception would be uncaught and crash the server.
try {
const status = response.statusCode || 500;
// A `Response` only represents statuses up to 599; Node never hands
// this callback a 1xx.
if (status > 599)
throw new Error(`FIRMS returned unsupported status ${status}`);
const headers = new Headers();
for (const [name, value] of Object.entries(response.headers)) {
if (Array.isArray(value))
value.forEach((item) => headers.append(name, item));
else if (value !== undefined) headers.set(name, String(value));
}
const nullBody = NULL_BODY_STATUSES.has(status);
const upstream = new Response(
nullBody ? null : Readable.toWeb(response),
{ status, statusText: response.statusMessage || '', headers },
);
if (nullBody) response.resume();
resolve(upstream);
} catch (error) {
response.destroy();
reject(error);
}
},
);
request.on('error', reject);
request.end();
});
}
/**
* Fetch a FIRMS URL, retrying once over IPv4 when address selection fails.
* Other failures (HTTP status, timeout, DNS) are returned or thrown as is.
* @param {string} url
* @param {{signal?: AbortSignal}} options
* @param {{fetchImpl?: typeof fetch, fetchIpv4Impl?: typeof fetchIpv4, warn?: Function}} [deps]
* @returns {Promise<Response>}
*/
export async function fetchFirms(
url,
options,
{ fetchImpl = fetch, fetchIpv4Impl = fetchIpv4, warn = console.warn } = {},
) {
try {
return await fetchImpl(url, options);
} catch (err) {
if (!isAddressSelectionFailure(err) || options?.signal?.aborted) throw err;
warn(`[firms-proxy] ${describeFetchError(err)} — retrying over IPv4`);
return fetchIpv4Impl(url, options);
}
}
/** /**
* NASA FIRMS live active-fire proxy with a memory + disk cache. * NASA FIRMS live active-fire proxy with a memory + disk cache.
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2 * Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
@@ -91,8 +214,12 @@ export function firmsProxy() {
async function fetchSource(key, source) { async function fetchSource(key, source) {
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`; const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
const signal = AbortSignal.timeout(60_000); const signal = AbortSignal.timeout(60_000);
const res = await fetch(url, { signal }); const res = await fetchFirms(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) {
// Release the connection now rather than when the deadline fires.
await res.body?.cancel().catch(() => {});
throw new Error(`HTTP ${res.status}`);
}
const records = parseFirmsCsv( const records = parseFirmsCsv(
await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal), await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal),
); );
@@ -121,7 +248,7 @@ export function firmsProxy() {
} catch (err) { } catch (err) {
console.warn( console.warn(
`[firms-proxy] ${source} fetch failed:`, `[firms-proxy] ${source} fetch failed:`,
err?.message || err, describeFetchError(err),
); );
sources.push({ source, count: 0, ok: false }); sources.push({ source, count: 0, ok: false });
} }
@@ -156,8 +283,13 @@ export function firmsProxy() {
statusInflight = (async () => { statusInflight = (async () => {
try { try {
const url = `https://firms.modaps.eosdis.nasa.gov/mapserver/mapkey_status/?MAP_KEY=${encodeURIComponent(key)}`; const url = `https://firms.modaps.eosdis.nasa.gov/mapserver/mapkey_status/?MAP_KEY=${encodeURIComponent(key)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); const res = await fetchFirms(url, {
if (!res.ok) throw new Error(`HTTP ${res.status}`); signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
await res.body?.cancel().catch(() => {});
throw new Error(`HTTP ${res.status}`);
}
const body = await res.json(); const body = await res.json();
const used = Number(body?.current_transactions); const used = Number(body?.current_transactions);
const limit = Number(body?.transaction_limit); const limit = Number(body?.transaction_limit);
@@ -167,7 +299,7 @@ export function firmsProxy() {
} catch (err) { } catch (err) {
console.warn( console.warn(
'[firms-proxy] mapkey status failed:', '[firms-proxy] mapkey status failed:',
err?.message || err, describeFetchError(err),
); );
return null; return null;
} }
+13
View File
@@ -29,6 +29,19 @@ function toFiveWordHudSummary(value) {
} }
async function handleHudSummary(req, res) { async function handleHudSummary(req, res) {
// Capability probe: lets the HUD skip its paid place lookups when there is
// no summary to feed them to.
if (req.method === 'GET' && req.url?.split('?')[0] === '/status') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(
JSON.stringify({
configured: !keylessHudSummaryResponse(process.env.OPENAI_API_KEY),
}),
);
return;
}
if (req.method !== 'POST') { if (req.method !== 'POST') {
res.statusCode = 405; res.statusCode = 405;
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
+3 -3
View File
@@ -46,15 +46,15 @@ function realtimeInstructions(
// string is the whole rollback. // string is the whole rollback.
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.', 'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
'For visual filter requests, call set_visual_style with one of the allowed style IDs.', 'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, snow, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.', 'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.',
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any other mention of "satellite" or "satellites" means the satellites DATA LAYER via set_layer_visibility, never a basemap — except "recent/latest satellite imagery" (the recent-imagery layer) and "weather satellite"/clouds (weather-satellite). "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.', 'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any other mention of "satellite" or "satellites" means the satellites DATA LAYER via set_layer_visibility, never a basemap — except "recent/latest satellite imagery" (the recent-imagery layer) and "weather satellite"/clouds (weather-satellite). "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.', 'HUD requests ("hud on/off", "switch to minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.', 'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.', 'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.', 'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.', '"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
'"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.', '"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.',
'Confirmations echo the RESULTING state, never the request: "HUD operator layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.', 'Confirmations echo the RESULTING state, never the request: "HUD minimal layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.', 'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.',
'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.', 'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.',
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.', 'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
+47 -6
View File
@@ -6,6 +6,7 @@ import {
parseTerrainPoints, parseTerrainPoints,
resolveTerrainHeightRequest, resolveTerrainHeightRequest,
terrainPointKey, terrainPointKey,
terrainRetryAfterMs,
validTerrainResult, validTerrainResult,
} from '../../src/data/terrainHeightsProxy.js'; } from '../../src/data/terrainHeightsProxy.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js';
@@ -55,6 +56,15 @@ export function terrainHeightsProxy(options = {}) {
// 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of // 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of
// that range. // that range.
const UPSTREAM_CHUNK = 64; const UPSTREAM_CHUNK = 64;
// After a 429 the upstream is left alone for its Retry-After (at most
// RATE_LIMIT_PAUSE_MAX_MS, so a far-off date can't stop terrain until a
// restart), or RATE_LIMIT_PAUSE_MS when it gives none. Every page poll for
// new points (about once a second while the camera moves) otherwise asks
// again with its own retries, which keeps the limit in force and logs each
// time.
const RATE_LIMIT_PAUSE_MS = 60_000;
const RATE_LIMIT_PAUSE_MAX_MS = 10 * 60_000;
let rateLimitedUntil = 0;
const MAX_POINTS = UPSTREAM_CHUNK; const MAX_POINTS = UPSTREAM_CHUNK;
/** /**
* Entry ceiling for the point cache, and with it the file written from it. * Entry ceiling for the point cache, and with it the file written from it.
@@ -199,7 +209,8 @@ export function terrainHeightsProxy(options = {}) {
* *
* The first try keeps its empirically required 30s timeout; network errors, * The first try keeps its empirically required 30s timeout; network errors,
* 429, and 5xx receive up to three jittered retries sharing a 10s added-time * 429, and 5xx receive up to three jittered retries sharing a 10s added-time
* budget, with Retry-After honored within that bound. * budget, with Retry-After honored within that bound; a longer Retry-After
* ends the retries at once so the 429 pause below starts without delay.
* *
* A chunk that still fails yields nulls for its own positions rather than * A chunk that still fails yields nulls for its own positions rather than
* rejecting: with several chunks per request, throwing would discard every * rejecting: with several chunks per request, throwing would discard every
@@ -211,15 +222,37 @@ export function terrainHeightsProxy(options = {}) {
* @returns {Promise<Array<object>>} * @returns {Promise<Array<object>>}
*/ */
async function fetchUpstreamAll(points) { async function fetchUpstreamAll(points) {
if (Date.now() < rateLimitedUntil) {
// Rate-limited: asking again only extends the limit. Cached points are
// still served; the rest resolve on the page through its geoid.
const error = new Error('HTTP 429');
error.status = 429;
error.coolingDown = true;
throw error;
}
const results = []; const results = [];
let firstError = null; let firstError = null;
for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) { for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) {
const chunk = points.slice(i, i + UPSTREAM_CHUNK); const chunk = points.slice(i, i + UPSTREAM_CHUNK);
let chunkResults = []; let chunkResults = [];
try { // Re-checked per chunk for when a request spans several chunks again;
chunkResults = await fetchTerrainChunkWithRetry(chunk); // today requests are capped at one chunk (MAX_POINTS).
} catch (error) { if (Date.now() >= rateLimitedUntil) {
firstError = firstError || error; try {
chunkResults = await fetchTerrainChunkWithRetry(chunk);
} catch (error) {
firstError = firstError || error;
// Concurrent 429s extend the pause, never cut it short.
if (error?.status === 429)
rateLimitedUntil = Math.max(
rateLimitedUntil,
Date.now() +
Math.min(
terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS,
RATE_LIMIT_PAUSE_MAX_MS,
),
);
}
} }
// Keep later chunks aligned even if a malformed upstream response omits // Keep later chunks aligned even if a malformed upstream response omits
// trailing positions. The resolver will reject each null individually. // trailing positions. The resolver will reject each null individually.
@@ -292,9 +325,17 @@ export function terrainHeightsProxy(options = {}) {
evictOverflow(); evictOverflow();
diskDirty = true; diskDirty = true;
} }
if (outcome.upstreamError) { if (outcome.upstreamError?.coolingDown) {
// Logged once, when the pause began.
} else if (outcome.upstreamError) {
const pause =
outcome.upstreamError.status === 429 &&
Date.now() < rateLimitedUntil
? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)`
: '';
console.warn( console.warn(
'[terrain-heights-proxy] refresh incomplete' + '[terrain-heights-proxy] refresh incomplete' +
pause +
' — serving stale points when available', ' — serving stale points when available',
); );
} else if (outcome.absentPoints > 0) { } else if (outcome.absentPoints > 0) {
+6 -3
View File
@@ -215,9 +215,12 @@ export function formatMeasure(session) {
} }
if (session.shape === 'area') { if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices); const m2 = ringAreaM2(session.vertices);
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`; // Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`; const whole = Math.round(m2);
return `${Math.round(m2)} m²`; const ha = (m2 / 1e4).toFixed(1);
if (whole < 1e4) return `${whole} m²`;
if (Number(ha) < 100) return `${ha} ha`;
return `${(m2 / 1e6).toFixed(2)} km²`;
} }
return ''; return '';
} }
+40
View File
@@ -345,3 +345,43 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
assert.equal(formatMeasure(line), '1.0 km'); assert.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(line), /^1\.0 km · /); assert.match(drawHint(line), /^1\.0 km · /);
}); });
test('an area that rounds up to the next unit reads in that unit', () => {
// A square on the equator whose shoelace area is about `m2`.
const square = (m2) => {
const side = Math.sqrt(m2) / 111320;
const area = createDrawSession('area');
for (const [lon, lat] of [
[0, 0],
[side, 0],
[side, side],
[0, side],
])
addVertex(area, { lon, lat });
return area;
};
const almostHectare = square(9999.6);
const metres = ringAreaM2(almostHectare.vertices);
assert.ok(
metres >= 9999.5 && metres < 1e4,
`area should round to 10000 m², got ${metres}`,
);
assert.equal(formatMeasure(almostHectare), '1.0 ha');
assert.match(drawHint(almostHectare), /^1\.0 ha · /);
assert.equal(formatMeasure(square(9999.4)), '9999 m²');
// Pin the shoelace area too, so a geodesy change fails here and not as a unit.
const almostKm2 = square(999_960);
const nearKm2 = ringAreaM2(almostKm2.vertices);
assert.ok(
nearKm2 >= 999_500 && nearKm2 < 1e6,
`area should round to 100.0 ha, got ${nearKm2}`,
);
assert.equal(formatMeasure(almostKm2), '1.00 km²');
const under = square(999_400);
const underM2 = ringAreaM2(under.vertices);
assert.ok(
underM2 >= 998_500 && underM2 < 999_500,
`area should round to 99.9 ha, got ${underM2}`,
);
assert.equal(formatMeasure(under), '99.9 ha');
});
+29
View File
@@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains
assert.equal(dataManager.layers.size, 0); assert.equal(dataManager.layers.size, 0);
}); });
test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => {
const previous = globalThis.document;
globalThis.document = {
hidden: true,
visibilityState: 'hidden',
getElementById: () => null,
addEventListener() {},
removeEventListener() {},
};
t.after(() => {
delete globalThis.GEV_EMBED_INLINE;
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
});
const releases = [];
const { dataManager } = createApplicationData({
scene: { viewer: {} },
controls: { styleManager: { attachDataManager() {} } },
catalog: createLayerCatalog([], []),
defer: (release) => releases.push(release),
});
const visibility = dataManager._visibilityTarget;
assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel');
globalThis.GEV_EMBED_INLINE = true;
assert.equal(visibility.hidden, false);
assert.equal(visibility.visibilityState, 'visible');
for (const release of releases.reverse()) await release();
});
test('controls bind catalog instances rather than similarly named defaults', () => { test('controls bind catalog instances rather than similarly named defaults', () => {
const ids = [ const ids = [
'traffic', 'traffic',
+1
View File
@@ -126,6 +126,7 @@ export function createApplicationCatalog({
}), }),
createApplicationGnssIntegrity({ createApplicationGnssIntegrity({
source: sources['gnss-integrity'], source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}), }),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationAlpr({ surface, source: sources.alpr }), createApplicationAlpr({ surface, source: sources.alpr }),
+39
View File
@@ -193,3 +193,42 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
/Mapillary source/, /Mapillary source/,
); );
}); });
test('a GNSS integrity source can set the layer evidence window', (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const gnssLayer = (descriptor) => {
const sources = fixtureSources([], []);
sources['gnss-integrity'] = {
getSnapshot: async () => ({}),
...descriptor,
};
return createApplicationCatalog({
sources,
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}).get('gnss-integrity');
};
const shows = (layer, minutes) => {
assert.equal(layer.provenance.window.minutes, minutes);
const { info, legend } = layer.getRowControls();
assert.match(info, new RegExp(`visited views over ${minutes} min`));
assert.match(legend[0].blurb, new RegExp(`over ${minutes} minutes`));
};
shows(gnssLayer({ windowMs: 65 * 60_000 }), 65);
shows(gnssLayer({}), 30);
for (const windowMs of [
undefined,
null,
NaN,
Infinity,
0,
-60_000,
'3600000',
])
assert.equal(
gnssLayer({ windowMs }).provenance.window.minutes,
30,
`${String(windowMs)} falls back to the default`,
);
});
+4 -1
View File
@@ -1,6 +1,7 @@
import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js'; import { LayerPresentation } from './layerPresentation.js';
import { createCyberSonarScene } from '../cyberSonarScene.js'; import { createCyberSonarScene } from '../cyberSonarScene.js';
import { panelAwareVisibility } from './embed.js';
/** Register the application layer catalog before allowing state restoration. */ /** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({ export function createApplicationData({
scene: { viewer, mapStackController }, scene: { viewer, mapStackController },
@@ -13,7 +14,9 @@ export function createApplicationData({
// Initialize data layer manager // Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, { const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration, allowQaRegistration,
visibilityTarget: document, // A panel's host may report it hidden while it is on screen; its layers
// keep polling (see keepPanelRendering in embed.js).
visibilityTarget: panelAwareVisibility(document),
getSourceAvailability: catalog?.getSourceAvailability, getSourceAvailability: catalog?.getSourceAvailability,
}); });
defer(async () => { defer(async () => {
+22
View File
@@ -45,6 +45,28 @@ export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true; return globalThis.GEV_EMBED_INLINE === true;
} }
/**
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
* may report it hidden while it is on screen (see keepPanelRendering), so an
* inline panel always reads as visible.
*/
export function panelAwareVisibility(doc = globalThis.document) {
const hidden = () =>
(doc.hidden === true || doc.visibilityState === 'hidden') &&
!isEmbeddedInline();
return {
get hidden() {
return hidden();
},
get visibilityState() {
return hidden() ? 'hidden' : 'visible';
},
addEventListener: (type, listener) => doc.addEventListener(type, listener),
removeEventListener: (type, listener) =>
doc.removeEventListener(type, listener),
};
}
// A panel keeps drawing at about this rate when its host stops animation // A panel keeps drawing at about this rate when its host stops animation
// frames; see keepPanelRendering. // frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33; const PANEL_FRAME_MS = 33;
+70
View File
@@ -5,7 +5,9 @@ import {
installViews, installViews,
isEmbedded, isEmbedded,
keepPanelRendering, keepPanelRendering,
panelAwareVisibility,
} from './embed.js'; } from './embed.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { createView } from '../view/index.js'; import { createView } from '../view/index.js';
const fakeViewer = () => { const fakeViewer = () => {
@@ -144,6 +146,12 @@ test('an embedded page takes views only from its parent and answers it', async (
id: 3, id: 3,
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] }, view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
}); });
// A view kept from before Snow was retired still applies, on Normal.
send(parent, {
type: 'gev:view',
id: 4,
view: { camera: { lat: 1, lon: 2 }, style: 'snow' },
});
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(posted[1], { assert.deepEqual(posted[1], {
type: 'gev:view-applied', type: 'gev:view-applied',
@@ -159,6 +167,13 @@ test('an embedded page takes views only from its parent and answers it', async (
name === 'set_layer_visibility' && args.layerId === 'flights', name === 'set_layer_visibility' && args.layerId === 'flights',
), ),
); );
assert.equal(posted[3].id, 4);
assert.equal(posted[3].ok, true);
assert.ok(
calls.some(
([name, args]) => name === 'set_visual_style' && args.style === 'normal',
),
);
assert.equal(posted.filter((message) => message.id === 1).length, 0); assert.equal(posted.filter((message) => message.id === 1).length, 0);
// Ready names no data and goes to any parent; answers go only to the // Ready names no data and goes to any parent; answers go only to the
// origin that asked. // origin that asked.
@@ -166,6 +181,7 @@ test('an embedded page takes views only from its parent and answers it', async (
'*', '*',
'https://host.example', 'https://host.example',
'https://host.example', 'https://host.example',
'https://host.example',
]); ]);
remove(); remove();
}); });
@@ -211,6 +227,60 @@ test('an app loaded inline into a panel page talks through its own window', asyn
} }
}); });
test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => {
const listeners = new Set();
const doc = {
hidden: true,
visibilityState: 'hidden',
addEventListener: (_type, fn) => listeners.add(fn),
removeEventListener: (_type, fn) => listeners.delete(fn),
};
const settle = async () => {
for (let i = 0; i < 3; i++) await Promise.resolve();
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
let updates = 0;
const manager = new LayerLifecycle(
{},
{ visibilityTarget: panelAwareVisibility(doc) },
);
manager.register({
id: 'flights',
name: 'flights',
icon: '',
source: 'test',
updateInterval: 1000,
async init() {},
enable() {},
disable() {},
async update() {
updates++;
await Promise.resolve();
},
getStats: () => ({ count: 0, lastUpdate: null }),
});
globalThis.GEV_EMBED_INLINE = true;
try {
await manager.setEnabled('flights', true);
await settle();
const enabled = updates;
t.mock.timers.tick(1000);
await settle();
t.mock.timers.tick(1000);
await settle();
assert.equal(updates, enabled + 2, 'the panel keeps polling');
delete globalThis.GEV_EMBED_INLINE;
t.mock.timers.tick(1000);
await settle();
assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses');
} finally {
delete globalThis.GEV_EMBED_INLINE;
await manager.destroyAll();
}
assert.equal(listeners.size, 0);
});
test('annotations in a link are drawn after it restores, embedded or not', async () => { test('annotations in a link are drawn after it restores, embedded or not', async () => {
const { calls, run } = recorder(); const { calls, run } = recorder();
const an = encodeURIComponent( const an = encodeURIComponent(
+1 -1
View File
@@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => {
test('celestial ring is available only in Normal style', () => { test('celestial ring is available only in Normal style', () => {
assert.equal(isCelestialRingStyleSupported('normal'), true); assert.equal(isCelestialRingStyleSupported('normal'), true);
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) { for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir']) {
assert.equal(isCelestialRingStyleSupported(style), false); assert.equal(isCelestialRingStyleSupported(style), false);
} }
}); });
+4 -8
View File
@@ -151,7 +151,7 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
); );
assert.match( assert.match(
setVisionMode.toString(), setVisionMode.toString(),
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),\s*snow: t\('SNOW'\),?\s*\};/, /const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),?\s*\};/,
); );
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/); assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
assert.doesNotMatch(ui, /getInheritedVisionLabel/); assert.doesNotMatch(ui, /getInheritedVisionLabel/);
@@ -227,13 +227,9 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
); );
}); });
test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => { test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
const keydown = cockpitKeyDown.toString(); const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing'); assert.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(
keydown,
/document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/,
);
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/); assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match( assert.match(
keydown, keydown,
@@ -443,7 +439,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing'); assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
assert.doesNotMatch( assert.doesNotMatch(
cockpitRadio[0], cockpitRadio[0],
/context-radio-details-btn|radio-filter|radio-tuner/, /radio-filter|radio-tuner/,
); );
assert.match( assert.match(
css, css,
@@ -898,7 +894,7 @@ test('Cockpit Radio station changes preserve first-person camera ownership', ()
); );
assert.match( assert.match(
radioBindings, radioBindings,
/listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/, /listen\(this\._radioNextBtn, 'click', \(\) => cycleRadio\(1\)\)/,
); );
assert.match( assert.match(
radioBindings, radioBindings,
-3
View File
@@ -5,7 +5,6 @@ export const COCKPIT_VISION_MODES = Object.freeze([
'thermal', 'thermal',
'anime', 'anime',
'noir', 'noir',
'snow',
]); ]);
const TARGET_STYLE_BY_MODE = Object.freeze({ const TARGET_STYLE_BY_MODE = Object.freeze({
@@ -14,7 +13,6 @@ const TARGET_STYLE_BY_MODE = Object.freeze({
thermal: 'thermal', thermal: 'thermal',
anime: 'anime', anime: 'anime',
noir: 'noir', noir: 'noir',
snow: 'snow',
}); });
const COCKPIT_MODE_BY_STYLE = Object.freeze({ const COCKPIT_MODE_BY_STYLE = Object.freeze({
@@ -24,7 +22,6 @@ const COCKPIT_MODE_BY_STYLE = Object.freeze({
thermal: 'thermal', thermal: 'thermal',
anime: 'anime', anime: 'anime',
noir: 'noir', noir: 'noir',
snow: 'snow',
}); });
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */ /** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
+1 -2
View File
@@ -23,7 +23,6 @@ test('Cockpit vision order mirrors the unique map preset order', () => {
'thermal', 'thermal',
'anime', 'anime',
'noir', 'noir',
'snow',
]); ]);
assert.equal(normalizeCockpitVisionMode('none'), 'optical'); assert.equal(normalizeCockpitVisionMode('none'), 'optical');
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical'); assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
@@ -36,7 +35,7 @@ test('Cockpit entry selects the vision mode equivalent to the active map style',
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal'); assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
assert.equal(cockpitVisionModeForStyle('anime'), 'anime'); assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
assert.equal(cockpitVisionModeForStyle('noir'), 'noir'); assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
assert.equal(cockpitVisionModeForStyle('snow'), 'snow'); assert.equal(cockpitVisionModeForStyle('snow'), 'optical');
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical'); assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
}); });
+35 -2
View File
@@ -129,7 +129,7 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
test('Context async action buttons remain focused while busy', () => { test('Context async action buttons remain focused while busy', () => {
const init = _initGlobalContextPanel.toString(); const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,')); const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)')); const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const clear = clearSelectedLayers.toString(); const clear = clearSelectedLayers.toString();
@@ -137,7 +137,7 @@ test('Context async action buttons remain focused while busy', () => {
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/); assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/); assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/); assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) { for (const name of ['_radioEnableBtn', '_cockpitRadioEnableBtn']) {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`)); assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
} }
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/); assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
@@ -146,3 +146,36 @@ test('Context async action buttons remain focused while busy', () => {
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/); assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
assert.doesNotMatch(control, /button\.disabled\s*=/); assert.doesNotMatch(control, /button\.disabled\s*=/);
}); });
test('Context header holds only its title and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body"/);
assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body"/);
assert.ok(header, 'Global Context header is missing');
const tags = [...header[1].matchAll(/<(\w+)\s+class="([^"]+)"/g)].map(([, tag, cls]) => `${tag}.${cls}`);
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
});
test('Context reads as one segmented control with legible type and no halo', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim();
const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || '';
const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0);
assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/);
const modes = rule('.global-context-modes');
assert.match(modes, /border:\s*1px solid/);
assert.match(modes, /padding:\s*2px/);
assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px');
assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/);
const standby = rule('.context-mode-standby');
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
assert.doesNotMatch(standby, /dashed/);
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
// The shared rail title colour (brighter than --text-secondary) keeps the
// open Context title legible; no Context-only rule dims it again.
assert.equal(
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
'',
);
});
+47
View File
@@ -1719,3 +1719,50 @@ test('vessel selection passes the opaque source reference to optional history',
aisLiveVesselsLayer.setSource(createVesselSource()); aisLiveVesselsLayer.setSource(createVesselSource());
} }
}); });
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
const sprite = (show) => ({
position: { x: 1, y: 2, z: 3 },
show,
color: makeColor(1),
});
const visible = sprite(true);
const hidden = Array.from({ length: 50 }, () => sprite(false));
const records = [visible, ...hidden].map((billboard) => ({
position: billboard.position,
billboard,
}));
const target = {
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
paddingPx: 0,
cameraDistance: 1000,
};
let projected = 0;
let measured = 0;
const apply = (nowMs) =>
applyVesselFocusDeemphasis({
records,
target,
nowMs,
screenPositionFor: () => {
projected += 1;
return { x: 50, y: 50 };
},
cameraDistanceFor: () => {
measured += 1;
return 1200;
},
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
});
apply(0);
const result = apply(300);
// Only the on-screen sprite pays for projection and distance.
assert.equal(projected, 2);
assert.equal(measured, 2);
assert.equal(visible.color.alpha, 0.25);
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
+2
View File
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
viewer, viewer,
_sceneSeekGeneration: 0, _sceneSeekGeneration: 0,
_loadGeneration: 0, _loadGeneration: 0,
_bumpSceneSeekGeneration:
SceneDirector.prototype._bumpSceneSeekGeneration,
_cancelActiveSceneTravel: _cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel, SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback, _setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
+2
View File
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
export const processGeometryBatch = layer.processGeometryBatch; export const processGeometryBatch = layer.processGeometryBatch;
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals; export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
export const refreshCoverageStyles = layer.refreshCoverageStyles; export const refreshCoverageStyles = layer.refreshCoverageStyles;
export const refreshHorizonCulling = layer.refreshHorizonCulling;
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
export const clearProbeClampOnDeactivation = export const clearProbeClampOnDeactivation =
layer.clearProbeClampOnDeactivation; layer.clearProbeClampOnDeactivation;
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation; export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
+3 -2
View File
@@ -1309,11 +1309,12 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
assert.ok(renderer, '_renderCctvState is missing'); assert.ok(renderer, '_renderCctvState is missing');
assert.match( assert.match(
renderer[0], renderer[0],
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/, // Nothing active clears the pick; only the outside-view note may show.
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
); );
assert.match( assert.match(
renderer[0], renderer[0],
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/, /_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
); );
} finally { } finally {
cctvLayer.setParams({ autoHop: false }); cctvLayer.setParams({ autoHop: false });
+226
View File
@@ -0,0 +1,226 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
a22WebcamToSource,
loadA22SourcesFromOpenDataHub,
} from '../../server/providers/cctv/sources.js';
import {
A22_DEFAULT_GROUND_ELEVATION_M,
A22_IMAGE_ORIGIN,
DEFAULT_A22_CCTV_URL,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { staticFrameRefreshMs } from './cctvLod.js';
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
const item = (
patch = {},
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
) => ({
Id: `A22_${km}`,
Active: true,
Source: 'a22',
Shortname: 'ADS Sciliar',
Webcamname: { it: 'ADS Sciliar' },
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
Mapping: { a22: { km } },
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
LicenseInfo: {
ClosedData: false,
LicenseHolder: 'https://www.autobrennero.it/',
},
...patch,
});
test('an A22 webcam maps to a source on the pinned operator frame', () => {
const source = a22WebcamToSource(item());
assert.equal(source.id, 'it-a22-km68');
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
assert.equal(source.city, 'A22 Brennero');
assert.equal(source.cityId, 'a22');
assert.equal(source.provider, 'Autostrada del Brennero');
assert.equal(source.lat, 46.53591);
assert.equal(source.lon, 11.49772);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'a22-opendatahub');
assert.equal(source.code, 'ADS SCILIAR');
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
assert.equal(source.snapshotUrl, source.url);
});
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
const brenner = a22WebcamToSource(
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
);
const ponte = a22WebcamToSource(
item(
{ Shortname: 'Ponte Po' },
{ km: '272', lat: 45.03145, lon: 10.85369 },
),
);
assert.ok(brenner.groundElevationM > 1300);
assert.ok(ponte.groundElevationM < 50);
// A kilometre the table does not know falls back to the corridor prior.
const unknown = a22WebcamToSource(
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
);
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
});
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
assert.equal(a22WebcamToSource(item({ Active: false })), null);
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
assert.equal(
a22WebcamToSource(
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
),
null,
);
// The frame must be exactly the camera's own kilometre image.
assert.equal(
a22WebcamToSource(
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
),
null,
);
assert.equal(
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
null,
);
// Milan: plausible, but off the Brenner–Modena corridor.
assert.equal(
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
null,
);
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
assert.equal(a22WebcamToSource(null), null);
});
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const requested = [];
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json({
TotalResults: 3,
Items: [
item(
{ Shortname: 'Ponte Po' },
{ km: '272', lat: 45.03145, lon: 10.85369 },
),
item(),
item(),
item(
{ Shortname: 'Brennero' },
{ km: '1', lat: 46.99682, lon: 11.50218 },
),
],
});
});
const cameras = await loadA22SourcesFromOpenDataHub();
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
assert.deepEqual(
cameras.map((c) => c.id),
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
);
fetchMock.mock.mockImplementation(
async () => new Response('nope', { status: 503 }),
);
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
fetchMock.mock.mockImplementation(
async () =>
new Response(null, {
status: 302,
headers: { Location: 'https://evil.test/' },
}),
);
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
fetchMock.mock.mockImplementation(async () => {
throw new Error('offline');
});
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
});
const runCatalog = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === DEFAULT_A22_CCTV_URL) {
return Response.json({ Items: [item()] });
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
const withEnv = async (patch, fn) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_A22_ENABLED;
Object.assign(process.env, patch);
await fn();
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('the A22 lane is wired into the catalog', async (t) => {
await withEnv({}, async () => {
const { requested, sources } = await runCatalog(t);
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
assert.deepEqual(
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
['it-a22-km68'],
);
});
});
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
const { requested, sources } = await runCatalog(t);
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
assert.deepEqual(
sources.filter((s) => s.cityId === 'a22'),
[],
);
});
});
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
const frame = `${A22_IMAGE_ORIGIN}km68.jpg`;
const redirectTo = (location) => async (url) =>
url === frame
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
assert.equal(
await fetchWithinHost(frame, {}, redirectTo(location)),
null,
location,
);
const followed = await fetchWithinHost(
frame,
{},
redirectTo('/WebCamImg/km77.jpg'),
);
assert.equal(await followed.text(), 'frame');
});
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
const source = a22WebcamToSource(item());
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
});
+3
View File
@@ -42,6 +42,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
// every 20s showed no change inside 64s but a new frame by ~90-150s later — // every 20s showed no change inside 64s but a new frame by ~90-150s later —
// matches the other highway packs' 3-minute cadence. // matches the other highway packs' 3-minute cadence.
'servei català de trànsit': 3 * 60 * 1000, 'servei català de trànsit': 3 * 60 * 1000,
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
// tracks the minute); 3 min matches the other highway packs.
'autostrada del brennero': 3 * 60 * 1000,
}); });
/** /**
+251
View File
@@ -0,0 +1,251 @@
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
// near the screen show, a zoomed-out view keeps one camera per world-anchored
// cell, and the layer's UI state never rebuilds every camera for a listener
// that does not read the list.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import cctvLayer, {
_setCctvCoverageStateForTest,
_viewRecordIdsForTest,
refreshHorizonCulling,
} from './cctv.js';
const WIDTH = 800;
const HEIGHT = 600;
/**
* Records at the given [lon, lat] degrees near the origin, each drawn at the
* screen point `screenAt` gives it (default: the middle of the screen).
*/
function recordsAt(
lonLats,
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
) {
const screenByPosition = new Map();
const records = lonLats.map(([lon, lat], index) => {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
screenByPosition.set(position, screenAt(index));
return {
camera: {
id: `cam-${index}`,
name: `Camera ${index}`,
city: 'Test',
lat,
lon,
headingDeg: 0,
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
},
position,
billboard: { position, show: true },
coverageEntities: [],
projection: null,
};
});
return { records, screenByPosition };
}
function viewerAt(heightM, screenByPosition, shiftX = 0) {
return {
isDestroyed: () => false,
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
frustum: { fovy: Math.PI / 3 },
},
scene: {
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
cartesianToCanvasCoordinates: (position) => {
const screen = screenByPosition.get(position);
return screen && { x: screen.x + shiftX, y: screen.y };
},
},
};
}
function shownIds(records) {
return records
.filter((record) => record.billboard.show)
.map((record) => record.camera.id)
.sort();
}
// Three cameras within ~110 m, and one ~22 km away.
const CLUSTER = [
[0, 0],
[0.001, 0],
[0.0005, 0.0005],
[0.2, 0],
];
test('zoomed out, cameras sharing a ground cell show one icon', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
});
assert.equal(
refreshHorizonCulling(),
true,
'the first pass changes the view',
);
const shown = shownIds(records);
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
});
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
x: 100 + index * 7,
y: 300,
}));
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
});
refreshHorizonCulling();
const before = shownIds(records);
const list = _viewRecordIdsForTest();
for (const shiftX of [3, 11, 20, 37]) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition, shiftX),
records,
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
}
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
assert.deepEqual(_viewRecordIdsForTest(), list);
});
test('a small zoom within one cell size keeps the same winners', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
const shownAt = (heightM) => {
_setCctvCoverageStateForTest({
viewer: viewerAt(heightM, screenByPosition),
records,
});
refreshHorizonCulling();
return shownIds(records);
};
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
// these heights fall in the same step, so the same cameras win.
assert.deepEqual(shownAt(20_500), shownAt(20_000));
});
test('a healthy camera wins its cell over an unknown one', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
// Whichever camera the id order would pick, the healthy one must win.
for (const id of ['cam-0', 'cam-1']) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
healthById: new Map([[id, { status: 'ok' }]]),
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), [id]);
}
});
test('the active camera always shows, even in a crowded cell', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
for (const active of records) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
activeCameraId: active.camera.id,
});
refreshHorizonCulling();
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
}
});
test('close up, every camera near the screen shows and far-off ones hide', () => {
const { records, screenByPosition } = recordsAt(
[
[0, 0],
[0.0001, 0],
[0.0002, 0],
],
(index) =>
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
});
test('a disabled layer skips the view pass', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
enabled: false,
});
assert.equal(refreshHorizonCulling(), false);
assert.deepEqual(_viewRecordIdsForTest(), []);
});
test('UI state builds the full camera list only when it is read', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
let built = 0;
for (const record of records) {
const camera = record.camera;
record.camera = new Proxy(camera, {
get(target, key) {
if (key === 'calibration') built += 1;
return target[key];
},
});
}
const first = cctvLayer.getUIState();
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
assert.deepEqual(
first.cameraIndex.map((camera) => camera.id),
['cam-0', 'cam-1'],
);
assert.equal(
cctvLayer.getUIState().cameraIndex,
first.cameraIndex,
'the index keeps its identity while the view does',
);
assert.equal(first.cameras.length, 2);
assert.ok(built > 0, 'reading `cameras` builds it');
assert.equal(first.cameras, first.cameras, 'and builds it once');
});
test('the camera picker lists the view, with the active camera first', () => {
const { records, screenByPosition } = recordsAt(
[
[0, 0],
[0.0001, 0],
[0.0002, 0],
],
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
activeCameraId: 'cam-1',
});
refreshHorizonCulling();
const state = cctvLayer.getUIState();
assert.deepEqual(
state.cameraIndex.map((camera) => camera.id),
['cam-1', 'cam-0', 'cam-2'],
'the off-screen active camera leads, then what is in view',
);
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
});
+7
View File
@@ -343,6 +343,13 @@ export const DATA_CREDITS = [
key: 'panoramax', key: 'panoramax',
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture', html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
}, },
{
key: 'a22-cctv',
html:
'Motorway webcams (A22 Brennero–Modena): images © ' +
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
},
{ {
key: 'gbfs', key: 'gbfs',
html: html:
+29
View File
@@ -875,6 +875,13 @@ test('route numbers follow the page language and never break from their unit', (
); );
const list = directionsStepList({ route }); const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м'); assert.equal(list.items[1].lead, '240\u00a0м');
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
setLocale('ja');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDurationText(39), '39秒');
assert.equal(routeDurationText(3900), '1時間5分');
assert.equal(routeDurationText(7200), '2時間');
}); });
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => { test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
@@ -892,6 +899,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
'Toma la salida bruscamente a la derecha', 'Toma la salida bruscamente a la derecha',
'Toma el carril izquierdo', 'Toma el carril izquierdo',
], ],
[
'ja',
'ランプを斜め左に進んでI-95に入る',
'出口を急角度で右に出る',
'左車線を通行',
],
[ [
'zh-CN', 'zh-CN',
'向左前方驶入匝道,进入 I-95', '向左前方驶入匝道,进入 I-95',
@@ -919,3 +932,19 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
); );
} }
}); });
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
t.after(() => setLocale('en'));
const title = (id) =>
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
setLocale('de');
assert.equal(
title('mode-foot'),
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
);
assert.equal(
title('mode-bike'),
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
);
});
+231 -2
View File
@@ -1,7 +1,19 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import test from 'node:test'; import test from 'node:test';
import { EventEmitter } from 'node:events';
import dns from 'node:dns/promises';
import dnsLookup from 'node:dns';
import https from 'node:https';
import net from 'node:net';
import { Readable } from 'node:stream';
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js'; import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
import {
describeFetchError,
fetchFirms,
fetchIpv4,
isAddressSelectionFailure,
} from '../../server/providers/firms.js';
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8'); const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
const start = config.indexOf(' async function refreshUpstream(key) {'); const start = config.indexOf(' async function refreshUpstream(key) {');
@@ -17,8 +29,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' };
// Inject only its upstream, clock and filter dependencies; keep its aggregation // Inject only its upstream, clock and filter dependencies; keep its aggregation
// and source-status code intact, including failures while consuming records. // and source-status code intact, including failures while consuming records.
function createRefresh(fetchSource, filter = filterTrailing24h) { function createRefresh(fetchSource, filter = filterTrailing24h) {
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console', return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console',
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} }); `return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} });
} }
test('FIRMS retains large sources in order and filters expired rows', async () => { test('FIRMS retains large sources in order and filters expired rows', async () => {
@@ -78,3 +90,220 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy
assert.deepEqual(result.fires, []); assert.deepEqual(result.fires, []);
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true }))); assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
}); });
// #68: Node's network-family auto-selection gives each address a short attempt
// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and
// reports only `fetch failed`. This is the shape Node 24 throws for it.
function addressSelectionFailure() {
const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' });
const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' });
const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' });
return new TypeError('fetch failed', { cause });
}
// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError.
function wrappedAddressSelectionFailure() {
const inner = addressSelectionFailure().cause;
const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'),
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
return new TypeError('fetch failed', { cause });
}
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
test('FIRMS failures log the socket cause, never the keyed URL', () => {
const described = describeFetchError(addressSelectionFailure());
assert.equal(described,
'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)');
assert.ok(!described.includes('SECRET'));
assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts');
const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) });
assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)');
assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503');
});
test('FIRMS retries once over IPv4 when every address fails to connect', async () => {
for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) {
const retried = [];
await fetchFirms(URL_WITH_KEY, {}, {
fetchImpl: async () => { throw shape(); },
fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); },
warn() {},
});
assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`);
}
const signal = new AbortController().signal;
const ok = new Response('csv');
const retried = [];
const warnings = [];
const response = await fetchFirms(URL_WITH_KEY, { signal }, {
fetchImpl: async () => { throw addressSelectionFailure(); },
fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; },
warn: (message) => warnings.push(message),
});
assert.equal(response, ok);
assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline');
assert.equal(warnings.length, 1);
assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/);
assert.ok(!warnings[0].includes('SECRET'));
const failure = new Error('connect ETIMEDOUT 198.118.194.34:443');
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, {
fetchImpl: async () => { throw addressSelectionFailure(); },
fetchIpv4Impl: async () => { throw failure; },
warn() {},
}), (err) => err === failure, 'a failed retry reports its own error');
});
test('FIRMS does not retry anything but an address-selection failure', async () => {
const deps = (fetchImpl) => ({
fetchImpl,
fetchIpv4Impl: async () => assert.fail('must not retry'),
warn: () => assert.fail('must not warn'),
});
const serverError = new Response('down', { status: 503 });
assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError);
for (const thrown of [
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }),
new TypeError('fetch failed', { cause: new AggregateError([]) }),
new TypeError('fetch failed'),
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
]) {
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown);
}
const controller = new AbortController();
controller.abort();
const aborted = addressSelectionFailure();
await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal },
deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried');
});
test('the IPv4 retry connects to an IPv4 address only', async (t) => {
// `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the
// same port and record which one the retry reaches; the TLS handshake then
// fails against a plain socket, which is fine — only the connection matters.
const reached = [];
const listen = (host, port) => new Promise((resolve, reject) => {
const server = net.createServer((socket) => { reached.push(host); socket.destroy(); });
server.once('error', reject).listen(port, host, () => resolve(server));
});
const v4 = await listen('127.0.0.1', 0);
t.after(() => v4.close());
const { port } = v4.address();
let v6;
try {
v6 = await listen('::1', port);
} catch {
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
return;
}
t.after(() => v6.close());
const order = await dns.lookup('localhost', { all: true });
if (order[0]?.family !== 6) {
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
return;
}
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
assert.deepEqual(reached, ['127.0.0.1']);
});
// Answer the IPv4 retry with `status`, invoking the response callback
// asynchronously as `https.request` does, outside the caller's promise.
// The returned promise rejects on an uncaught exception, so a regression fails
// the race at once instead of crashing the test runner.
function mockIpv4Status(t, status, statusMessage = '') {
let crash;
const crashed = new Promise((_resolve, reject) => (crash = reject));
const onUncaught = (error) =>
crash(new Error(`uncaught exception: ${error.message}`));
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
t.mock.method(https, 'request', (_url, _options, callback) => {
const request = new EventEmitter();
request.end = () =>
setImmediate(() =>
callback(
Object.assign(Readable.from([]), {
statusCode: status,
statusMessage,
headers: { 'content-type': 'text/csv' },
}),
),
);
return request;
});
return crashed;
}
for (const status of [204, 205, 304]) {
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
const crashed = mockIpv4Status(t, status);
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
assert.equal(response.status, status);
assert.equal(response.body, null);
});
}
for (const status of [600, 999]) {
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
const crashed = mockIpv4Status(t, status);
await assert.rejects(
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
new RegExp(`unsupported status ${status}`),
);
});
}
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
});
// The fixtures above copy Node's error shape; check it against a real fetch so
// a Node or undici change that stops matching fails here instead of silently
// disabling the retry. A closed port refuses both loopback addresses; resolve
// `localhost` to both here, since some hosts map it to 127.0.0.1 only.
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
const probe = net.createServer();
try {
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
} catch {
t.skip('no IPv6 loopback on this host');
return;
}
const { port } = probe.address();
await new Promise((resolve) => probe.close(resolve));
const v4 = net.createServer();
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
await new Promise((resolve) => v4.close(resolve));
const loopback = [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }];
const lookup = t.mock.method(dnsLookup, 'lookup', (_host, _options, callback) => callback(null, loopback));
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
assert.equal(lookup.mock.callCount(), 1, 'fetch resolves localhost through dns.lookup');
assert.ok(err, 'a closed port refuses the connection');
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
});
// #68 only stays fixed while both upstream calls go through fetchFirms; the
// refresh harness injects fetchSource, so guard the call sites directly.
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
const calls = [];
const response = new Response('latitude,longitude\n');
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
() => [],
1024,
AbortSignal,
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
);
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
assert.equal(calls.length, 2);
assert.equal(calls[0][0], 'fetchFirms');
assert.ok(calls[0][2] instanceof AbortSignal);
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
});
+15
View File
@@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) {
return _focusEvidenceNowMs; return _focusEvidenceNowMs;
} }
/**
* True when a sprite holds no dimming state, or has settled at full emphasis
* with nothing pending. Advancing such a sprite without a screen position (a
* hidden sprite) changes neither its state nor its alpha, so a layer pass may
* skip it.
* @param {object} sprite
* @returns {boolean}
*/
export function spriteFocusAtRest(sprite) {
const state = _spriteStates.get(sprite);
return (
!state || (state.factor === 1 && state.desired === 1 && !state.wasFarther)
);
}
/** Forget state when a primitive is removed from its owning collection. */ /** Forget state when a primitive is removed from its owning collection. */
export function forgetSpriteFocus(sprite) { export function forgetSpriteFocus(sprite) {
if (sprite) _spriteStates.delete(sprite); if (sprite) _spriteStates.delete(sprite);
+7 -6
View File
@@ -1,6 +1,6 @@
/** Pure proximity helpers for Global Context. No capability modeling. */ /** Pure proximity helpers for Global Context. No capability modeling. */
import { t } from '../i18n/index.js'; import { msg, t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js'; import { UNNAMED_PLACE_NAMES } from './installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES); const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
@@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) {
/** /**
* Summarize sorted nearby results without promoting missing/stale feeds to a * Summarize sorted nearby results without promoting missing/stale feeds to a
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range * negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP. * claim in this MVP. `reason` is English data; the awareness panel shows it
* in the UI language.
*/ */
export function summarizeAwarenessCohort( export function summarizeAwarenessCohort(
items, items,
@@ -95,14 +96,14 @@ export function summarizeAwarenessCohort(
relationship: AWARENESS_RELATIONSHIP.UNKNOWN, relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null, count: null,
nearest: [], nearest: [],
reason: t('feed unavailable'), reason: msg('feed unavailable'),
}; };
if (stale) if (stale)
return { return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN, relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null, count: null,
nearest: [], nearest: [],
reason: t('feed stale'), reason: msg('feed stale'),
}; };
const normalized = (Array.isArray(items) ? items : []) const normalized = (Array.isArray(items) ? items : [])
.filter( .filter(
@@ -119,8 +120,8 @@ export function summarizeAwarenessCohort(
count: normalized.length, count: normalized.length,
nearest: normalized.slice(0, limit), nearest: normalized.slice(0, limit),
reason: normalized.length reason: normalized.length
? t('observed or mapped nearby context') ? msg('observed or mapped nearby context')
: t('no observed or mapped objects in current feeds'), : msg('no observed or mapped objects in current feeds'),
}; };
} }
+20
View File
@@ -1,5 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../i18n/index.js';
import { import {
AWARENESS_RELATIONSHIP, AWARENESS_RELATIONSHIP,
findByDoublingRadius, findByDoublingRadius,
@@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => {
assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale'); assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale');
}); });
test('cohort reasons stay English in data and translate where the panel shows them', (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const reasons = [
summarizeAwarenessCohort([], { available: false }).reason,
summarizeAwarenessCohort([], { stale: true }).reason,
summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason,
summarizeAwarenessCohort([]).reason,
];
assert.deepEqual(reasons, [
'feed unavailable',
'feed stale',
'observed or mapped nearby context',
'no observed or mapped objects in current feeds',
]);
// The awareness panel shows a reason through translateKnown().
for (const reason of reasons) assert.notEqual(translateKnown(reason), reason);
});
test('navigation prioritizes nearby targets from the selected cohort', () => { test('navigation prioritizes nearby targets from the selected cohort', () => {
const cohorts = [ const cohorts = [
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } }, { id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
+6 -1
View File
@@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) {
/** /**
* Fetch one upstream chunk with jittered exponential backoff. Network errors, * Fetch one upstream chunk with jittered exponential backoff. Network errors,
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast. * 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
* Retry sleeps and retry attempts share a 10s budget after attempt one. * Retry sleeps and retry attempts share a 10s budget after attempt one. A
* Retry-After longer than the budget left ends the retries at once: sleeping
* out the budget cannot reach the time the upstream asked for, and it would
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
* *
* Dependencies are injectable for deterministic offline tests. * Dependencies are injectable for deterministic offline tests.
* @param {Array<[number, number]>} points * @param {Array<[number, number]>} points
@@ -145,6 +148,7 @@ export async function fetchTerrainChunkWithRetry(
const res = await fetchImpl(url, signal ? { signal } : {}); const res = await fetchImpl(url, signal ? { signal } : {});
if (!res.ok) { if (!res.ok) {
const error = new Error(`HTTP ${res.status}`); const error = new Error(`HTTP ${res.status}`);
error.status = res.status;
error.retryable = res.status === 429 || res.status >= 500; error.retryable = res.status === 429 || res.status >= 500;
error.retryAfter = res.headers?.get?.('retry-after') ?? null; error.retryAfter = res.headers?.get?.('retry-after') ?? null;
throw error; throw error;
@@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry(
if (remaining <= 0) break; if (remaining <= 0) break;
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now()); const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
if (retryAfterMs != null && retryAfterMs > remaining) break;
const backoffMs = Math.round( const backoffMs = Math.round(
350 * 2 ** attempt * (0.75 + random() * 0.5), 350 * 2 ** attempt * (0.75 + random() * 0.5),
); );
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff. Show more