Compare commits

..
Author SHA1 Message Date
Sameh Khamis 769d1944df Improve touch zoom and reduce aircraft update overhead 2026-10-24 22:46:40 -07:00
Sameh Khamis f2ee3c984f Merge pull request #1352 from rudycelekli/fix/cockpit-destination-heading
Reviewed display correction with its runtime documentation update.
2026-10-24 22:33:05 -07:00
Sameh Khamis 19de386bcc Merge main into PR #1352 2026-10-24 22:27:39 -07:00
Sameh Khamis 5d23a874b1 Merge pull request #1354 from rudycelekli/fix/firms-frp-ten-megawatts
Reviewed display correction with its runtime documentation update.
2026-10-24 22:27:34 -07:00
Sameh Khamis b616188cbf Merge main into PR #1354 2026-10-24 22:21:46 -07:00
Said Turksever b304fd4a34 docs(changelog): restore the GBFS rate-limit entry (#1184)
Restore the original GBFS rate-limit changelog entry accidentally removed by #1176. Documentation only.

Contribution by Said Turksever (@asturksever).
2026-10-24 22:21:40 -07:00
Sameh Khamis 5673831619 docs: record PR #1352 display behavior 2026-10-24 22:17:56 -07:00
Sameh Khamis 6b85990fec docs: record PR #1354 display behavior 2026-10-24 22:17:56 -07:00
Sameh Khamis 80cd3476bf Merge main into PR #1354 2026-10-24 22:16:42 -07:00
Sameh Khamis 5754e8f046 Merge main into PR #1352 2026-10-24 22:16:39 -07:00
Sameh Khamis 7a8ba29186 Merge pull request #1351 from rudycelekli/fix/ais-static-keeps-motion
fix(ais): keep a tracked vessel's motion when static data arrives
2026-10-24 21:45:40 -07:00
Sameh Khamis a2a3aba1d2 Merge main into fix/ais-static-keeps-motion 2026-10-24 21:39:32 -07:00
Sameh Khamis 2fc9ca8516 Merge pull request #1350 from rudycelekli/fix/cyclone-movement-knots
fix(cyclones): convert NHC movement speed from mph to knots
2026-10-24 21:39:26 -07:00
Sameh Khamis 19ed82af07 Merge branch 'main' into fix/cyclone-movement-knots 2026-10-24 21:33:20 -07:00
Sameh Khamis 88803f16e8 Merge pull request #1303 from quanticsoul4772/upstream/wind-stale
fix(tools): flag stale wind forecasts in get_wind
2026-10-24 21:31:01 -07:00
Sameh Khamis 77b485d619 fix(ais): refresh changed static metadata without advancing position 2026-10-24 21:29:21 -07:00
Sameh Khamis 4ba0a1b57f Merge branch 'main' into upstream/wind-stale 2026-10-24 21:26:18 -07:00
Sameh Khamis 2a48e1d186 Merge pull request #1244 from KnottyDyes/fix/terrain-test-cache-isolation
test(terrain): give every terrain-heights proxy in the bounds tests its own cache
2026-10-24 21:26:11 -07:00
Sameh Khamis aa33bb5ec3 Merge branch 'main' into fix/terrain-test-cache-isolation 2026-10-24 21:20:50 -07:00
Sameh Khamis aa9a7d41bf Merge main into fix/cyclone-movement-knots 2026-10-24 21:20:41 -07:00
Sameh Khamis cf4e8e1d87 docs(cyclones): record upstream movement speed units 2026-10-24 21:19:54 -07:00
Said Turksever 2dca762e4a Merge pull request #1282 from bilawalsidhu/codex/fix-hidden-tab-polling
fix(layers): pause periodic network updates in hidden tabs
2026-10-24 23:59:30 +01:00
Said Turksever 60ec1f962b Merge branch 'main' into codex/fix-hidden-tab-polling 2026-10-24 23:51:23 +01:00
quanticsoul4772 89a6f8c96f Merge branch 'main' into upstream/wind-stale 2026-10-24 08:51:41 -07:00
quanticsoul4772andSameh Khamis 1c234ce027 fix(opensky): load OAuth credential files and retire Basic auth (#1229)
Read OpenSky OAuth credentials from OPENSKY_CREDENTIALS_FILE and stop sending retired Basic authentication. Update setup diagnostics and guidance.

Keep credential-file warnings free of parser excerpts and filesystem error details, cover both failure paths with a regression test, and use a launcher-compatible absolute path example.

Original contribution by quanticsoul4772, with credential-warning and documentation follow-up by Sameh Khamis.

Co-authored-by: quanticsoul4772 <[email protected]>
Co-authored-by: Sameh Khamis <[email protected]>
2026-10-24 08:25:42 -07:00
quanticsoul4772 07318cc9a4 fix(geo): wrap longitude differences across the antimeridian in point distances (#1304)
Wrap longitude differences in annotation proximity checks and nearby-place distances so points across the antimeridian remain close.

Original contribution by quanticsoul4772.

Co-authored-by: quanticsoul4772 <[email protected]>
2026-10-24 08:19:21 -07:00
Rudy Celekli a9eda1d9e7 changelog: note the rounded-north destination heading 2026-10-24 00:36:02 -04:00
Rudy Celekli 9f8ed74e7b changelog: note the 10 MW fire-power label 2026-10-24 00:35:59 -04:00
Rudy Celekli 05316bb34b fix(cockpit): read a rounded-north destination heading as 000
359.6 degrees was shown as 360 degrees on the destination bearing while the compass tape uses 000.
2026-10-24 00:24:43 -04:00
Rudy Celekli b8f8a8685d docs(changelog): add AIS static data fragment 2026-10-24 00:24:08 -04:00
Rudy Celekli 2bcd27c1e0 fix(ais): keep a tracked vessel's motion when static data arrives 2026-10-24 00:23:44 -04:00
Rudy Celekli 2beef9be39 fix(firms): drop the decimal when fire power rounds to 10 MW
A 9.96 MW detection was labeled 10.0 MW because the one-decimal cutoff ran before rounding.
2026-10-24 00:23:11 -04:00
Rudy Celekli 1b52298c6c docs(changelog): add cyclone movement speed fragment 2026-10-24 00:21:39 -04:00
Rudy Celekli 0b19f11ad0 fix(cyclones): convert NHC movement speed from mph to knots 2026-10-24 00:21:05 -04:00
Said Turksever 9d3548c27c Merge pull request #1341 from rudycelekli/fix/kartaview-impossible-timestamps
fix(kartaview): reject impossible photo timestamps
2026-10-24 01:49:53 +01:00
Said Turksever 39777ad173 Merge branch 'main' into fix/kartaview-impossible-timestamps 2026-10-24 01:42:59 +01:00
Said Turksever 98fd1eca69 Merge pull request #1342 from rudycelekli/fix/firms-acquisition-calendar
fix(firms): keep impossible acquisition dates off the fire clock
2026-10-24 01:41:29 +01:00
Said Turksever ddd3962078 Merge branch 'main' into fix/firms-acquisition-calendar 2026-10-24 01:31:25 +01:00
Said Turksever f76b6ef9ab Merge pull request #1278 from bilawalsidhu/codex/fix-overpass-cache-singleflight
fix(overpass): reserve singleflight before disk cache reads
2026-10-24 01:30:09 +01:00
Said Turksever 1c2fe8201f Merge branch 'main' into codex/fix-overpass-cache-singleflight 2026-10-24 01:11:43 +01:00
Said Turksever 8b7cdac910 Merge pull request #1279 from bilawalsidhu/codex/fix-launch-hover-throttle
fix(missions): throttle hover picks and clear stale cursors
2026-10-24 01:11:13 +01:00
Said Turksever 70bdcbfd8a Merge branch 'main' into codex/fix-launch-hover-throttle 2026-10-24 01:06:07 +01:00
Rudy CelekliandCursor d9ecd9869a Drop the second changelog fragment for the KartaView fix.
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:14:35 -04:00
Rudy CelekliandCursor e07b0e9256 Add the changelog note for impossible wildfire acquisition dates.
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:12:34 -04:00
Rudy CelekliandCursor cbefa2567a Add the changelog note for impossible KartaView timestamps.
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:12:32 -04:00
Rudy CelekliandCursor bdb3851bb2 changelog: note 1342-firms-acquisition-calendar
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:12:07 -04:00
Rudy CelekliandCursor dbf306379f changelog: note 1341-kartaview-impossible-timestamps
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:12:04 -04:00
Rudy CelekliandCursor 7d2294580b fix(firms): keep impossible acquisition dates off the fire clock
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:08:55 -04:00
Rudy CelekliandCursor 9e2879c0ba fix(kartaview): reject impossible photo timestamps
Co-authored-by: Cursor <[email protected]>
2026-10-23 19:08:52 -04:00
Sameh Khamis 8d454bf7f1 fix: improve mobile contact selection and context drawers (#1338)
Improve coarse-pointer contact selection, reveal tracked aircraft through Contacts, support downward drawer dismissal, and enlarge the mobile logo.
2026-10-23 15:05:23 -07:00
manjunath22466 b2fb4a261e feat(camera): add globe rotation (#1225)
Add continuous globe rotation with direction and speed controls, viewport fit checks, camera ownership handoff, and voice refusal guards.
2026-10-23 14:51:42 -07:00
Sameh Khamis 8cdd184a90 feat(street-level): provider exports and a Mapillary Graph endpoint for hosts (#1324)
* feat(street-level): export the KartaView and Panoramax providers

A host that composes Street Level with its own provider list can already
import the Mapillary and Google Street View providers; KartaView and Panoramax
were reachable only through the default list, which needs a Mapillary source.
Exporting them lets a host offer the keyless providers without Mapillary.

* chore: changelog entry for the Street Level provider exports

* feat(street-level): send Mapillary Graph lookups to a source endpoint

A Mapillary source can take a graphEndpoint, a Graph API base that adds
the token itself. Its sequence lookups go there through the source's
transport, and the photo viewer uses MapillaryJS's stock GraphDataProvider
at that endpoint, so a host that proxies Mapillary needs no client token in
the page. Without it, behaviour is unchanged: direct Graph API calls with
the client token.
2026-10-23 07:47:57 -07:00
Said Turksever f19a6da5b8 Merge pull request #1274 from bilawalsidhu/codex/fix-kml-root-scanner
fix(import): scan the KML root delimiter in bounded time
2026-10-23 15:37:27 +01:00
Said Turksever 6194989018 Merge branch 'main' into codex/fix-kml-root-scanner 2026-10-23 15:26:25 +01:00
Said Turksever 525f48c472 Merge pull request #1273 from bilawalsidhu/codex/fix-military-history-failures
fix(military): retire unavailable history and keep local trails
2026-10-23 15:07:46 +01:00
Said Turksever 8eb1029393 Merge branch 'main' into codex/fix-military-history-failures 2026-10-23 15:01:01 +01:00
Said Turksever 1516fc1902 Merge pull request #1270 from bilawalsidhu/codex/fix-space-provider-cooldowns
fix(space): respect provider cooldowns and bound TLE groups
2026-10-23 14:48:44 +01:00
Said Turksever a4f9b5e925 Merge branch 'main' into codex/fix-space-provider-cooldowns 2026-10-23 14:39:06 +01:00
Said Turksever 3b9e83d4a5 Merge pull request #1281 from bilawalsidhu/codex/fix-traffic-render-hold
fix(traffic): hold rendering only for animated content
2026-10-23 13:05:27 +01:00
Said Turksever 3af42082b1 Merge branch 'main' into codex/fix-traffic-render-hold 2026-10-23 12:59:46 +01:00
Said Turksever f6eabb4912 Merge pull request #1268 from bilawalsidhu/codex/fix-radio-tag-normalization
fix(radio): normalize station tags and retain valid mixed rows
2026-10-23 12:52:27 +01:00
Said TurkseverandClaude Opus 5.5 f93982f55e Carry server structural rejections into Radio diagnostics
The proxy now skips malformed upstream rows individually but counts
them, capped at 10,000, in the catalog metadata and the /stations
envelope (rejectedStationCount). The count never fails health policy,
so an otherwise healthy generation is still accepted. The client
validates the field, adds its own rejected rows after admission, and
reports degraded coverage when either side skipped rows. Adds an
end-to-end proxy-to-client test and aligns DATA_SOURCES.md and
CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:39:56 +01:00
Said TurkseverandClaude Opus 5.5 82190d531a Reset the Radio rejection counter on destroy
Clear _rejectedStationCount alongside _degraded so a destroyed and
re-initialized layer does not report the previous catalog's count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:39:56 +01:00
Said TurkseverandClaude Opus 5.5 0c20ed9975 fix(overpass): retry a joined caller's admission on its own quota
A caller that joins a cold reservation no longer inherits another client's
RATE_LIMITED result. Once the rejected reservation is released it retries:
it either owns the next reservation and is admitted on its own quota, or joins
an admitted fetch. Adds a two-client regression (exhausted owner, available
joiner, exhausted joiner coalescing onto the admitted fetch).

Also drops the duplicate memory publication for a disk hit in the handler;
preflight already sets it, the handler only trims.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:33:05 +01:00
Said TurkseverandClaude Opus 5.5 6b2aa95cc6 Skip DOCTYPE comments/PIs and match root namespaces only as attributes
- The DOCTYPE scan now jumps over whole comments and processing
  instructions found outside a declaration string literal, so a quote or
  bracket inside them no longer leaves the scanner stuck before the root.
- Root namespace repair walks the root's attributes in order, so
  declaration-like text inside a quoted value can no longer fill `seen`
  and cause the real declaration to be dropped as a duplicate.
- Regression tests for both, plus linear-time cases in the isolated
  deadline test.
- CURRENT-STATE: describe the scanner as a boundary check and leave the
  rest of the XML grammar to DOMParser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:30:32 +01:00
Said TurkseverandClaude Opus 5.5 0b5b897c11 Allow active TLE group, admit proxied same-origin requests, keep longest cooldown
- CelesTrak allowlist includes `active`, which Space Missions'
  getActiveTle() requests; unknown groups still answer 400.
- The CelesTrak gate uses admitSameSiteKeyless so the documented reverse
  proxy keeps working while foreign/opaque Origins and cross-site
  Sec-Fetch-Site are still refused with 403.
- A provider failure never shortens an existing cooldown (CelesTrak and
  Launch Library keep the later deadline).
- Tests: real getActiveTle() through the middleware, proxied app request
  vs unrelated Origin, concurrent failures finishing in both orders.
- docs/CURRENT-STATE.md: eight groups, keyless gate, non-shortening cooldown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:26:41 +01:00
Said TurkseverandClaude Opus 5.5 730ba57073 Report unsupported military track history in get_aircraft_track
The standalone military source no longer has getTrack, so the MCP
get_aircraft_track tool threw a TypeError and MCP advised a retry. It
now returns an `unsupported` ToolError when the selected feed has no
getTrack, while custom sources that supply history still work. Tests
cover the default composition through the catalog and MCP.

Also drop the unused epoch import and update the CURRENT-STATE track
sections to describe local trails and 503 history_unavailable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:26:02 +01:00
Said TurkseverandClaude Opus 5.5 e60caab180 Run a trailing hover check for the last suppressed pointer position
A move inside the 120 ms hover throttle window was dropped, so a pointer
that stopped right after a pick kept a stale cursor (pointer over empty
space, or default over a mission) until the next move. Keep a copy of the
last suppressed position (Cesium reuses the movement object) and run one
trailing check when the window ends. The check rechecks hover eligibility
and is cancelled by reset, disable, camera motion, and destroy.

Tests cover a stopped pointer in both directions without another event,
the copied position, one timer at a time, cancellation, and the
eligibility recheck.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:23:07 +01:00
Said TurkseverandClaude Opus 5.5 07d6417446 Realign periodic polling to the deadline after a visibility catch-up
An overdue refresh on return to a visible tab advanced the due time but kept
the old setInterval phase, so the next phase tick was skipped and the gap
could approach two intervals (also after a failed resume refresh). The
catch-up now re-arms the interval at the new deadline. Interval ticks no
longer compare against the due time, since the phase and the deadline share
one origin. Overlap prevention and teardown are unchanged, and a stale
listener cannot re-arm a stopped loop.

Tests cover the next poll one interval after a between-ticks resume, with a
successful and a failed catch-up refresh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:22:42 +01:00
Said TurkseverandClaude Opus 5.5 e974ce096a Qualify the Traffic render-hold note for failed loads
A failed load leaves no hold only when no animated content is retained.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:12:27 +01:00
Said TurkseverandClaude Opus 5.5 1b7498820f Remove the unused trafficLogs collector from Traffic QA
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:12:27 +01:00
Said TurkseverandClaude Opus 5.5 090eeb9b11 Settle pending Traffic height work when the population empties
A closure that removes the last animated points mid height correction
released the render hold, and animate() then returned before easeHeights()
could run, leaving _heightPending (and getStats().loading) true. Settle
every retained road on its corrected heights before the hold is released.

Adds a regression combining height correction, a late closure and an
empty population.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:12:27 +01:00
Said Turksever d8dce93ff4 Merge remote-tracking branch 'origin/main' into fix/1281
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:12:27 +01:00
Said Turksever bdf5899dda Merge origin/main into codex/fix-overpass-cache-singleflight 2026-10-23 12:10:39 +01:00
Said Turksever 5ca2266f53 Merge remote-tracking branch 'origin/main' into fix/1274
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:09:32 +01:00
Said Turksever 98af799cb4 Merge remote-tracking branch 'origin/main' into fix/1273
# Conflicts:
#	docs/CURRENT-STATE.md
#	src/tooling/liveProviders.test.mjs
2026-10-23 12:09:32 +01:00
Said TurkseverandClaude Opus 5.5 0caffd8b70 Merge origin/main into codex/fix-space-provider-cooldowns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:09:31 +01:00
Said Turksever be44f523e0 Merge remote-tracking branch 'origin/main' into fix/1282
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:09:26 +01:00
Said Turksever 17864965d9 Merge remote-tracking branch 'origin/main' into fix/1268
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:09:23 +01:00
Said d41c0ccaea Merge remote-tracking branch 'origin/main' into fix/1279
# Conflicts:
#	docs/CURRENT-STATE.md
2026-10-23 12:09:19 +01:00
Sameh Khamis d48b9c4df9 Merge pull request #1325 from bilawalsidhu/feat/phone-drawer
feat(ui): phone layout with a bottom tab bar and one panel sheet at a time
2026-10-22 22:26:49 -07:00
Sameh Khamis e7cdd47d8d chore: changelog entry for the phone layout 2026-10-22 22:12:36 -07:00
Sameh Khamis 9e0c0fe960 docs: describe the phone layout 2026-10-22 22:09:37 -07:00
Sameh Khamis 10bb2c6a36 test(qa): open the phone tab sheets in phone-width harness checks
At 720px and narrower the panels live in the phone shell's tab sheets, so the
camera, scene and map-source tray harnesses open the matching tab (from the
keyboard where they check focus rings) before measuring, wait for the shell to
follow a viewport change, measure the container that actually scrolls, and run
the pin and compact-wing checks at 800px where the narrow desktop dock still
exists.
2026-10-22 22:09:37 -07:00
Sameh Khamis fff16f3884 feat(ui): phone layout with a bottom tab bar and one panel sheet at a time
At 720px and narrower the globe stays full screen and a bottom tab bar (Layers,
Cameras, Context, Search, More) opens one bottom sheet at a time, presenting the
existing rail and dock panels in place. Sheets borrow panels without persisting
or sharing them and put back what they changed. Cockpit, recording and clean
view close the sheet; scene playback keeps STOP reachable. Focus moves into the
sheet and back to its tab, the required attribution stays visible above the
voice control and any sheet, and the page uses safe-area insets. Wider
viewports are unchanged.

Adapted from the phone tab-shell proposed in #203 by @blearymoth.
2026-10-22 22:09:37 -07:00
Sameh Khamis 224745bd0a Merge pull request #1321 from bilawalsidhu/chore/dead-code-cleanup
chore: remove dead code across the repository
2026-10-22 20:54:09 -07:00
Sameh Khamis 04f8c7fad0 chore: credit the changelog entry to the GitHub handle 2026-10-22 20:47:34 -07:00
Sameh Khamis f6a4b73459 docs: point remaining src/ui.js references at their owners
The README source tree now lists src/ui/ instead of the removed
src/ui.js. docs/CODE-BOUNDARIES.md names src/ui/applicationShell.js as
the owner of panel layout, persistence and share restoration, and
docs/CURRENT-STATE.md places GLOBAL_POST_DEFAULTS in
src/ui/visualPresets.js. QA script comments and test assertion messages
that still named ui.js are updated the same way.
2026-10-22 19:26:09 -07:00
Sameh Khamis 9623bc4d58 docs(changelog): add the dead-code cleanup entry 2026-10-22 19:19:03 -07:00
quanticsoul4772 ba2dda5beb Merge branch 'main' into upstream/wind-stale 2026-10-22 19:18:02 -07:00
Sameh Khamis f3a87777ee chore: remove the remaining unused local bindings
An ESLint no-unused-vars scan over src, server, scripts, tools, build
and pinokio, repeated until it is stable, reported these.

Runtime modules keep every call and drop only the unread binding:
- src/ui/cockpitInput.js: the unread result of enter()/exit().
- src/layers/launches/rendering.js: the unread entity returned by
  entities.add().
- src/layers/radio/rendering.js: the unread map key in the horizon scan,
  which now iterates the map's values.

QA scripts and tools drop an unused helper, two unused in-page module
imports, unused counters and destructured fields, and an unread wait
result. Tests drop source files read into variables that no assertion
uses, redundant StyleManager imports (the shell-source helper already
imports it), and unused destructured fields.

The one remaining report is the `_` loop variable in src/tools/schema.js,
which counts iterations and is intentional.
2026-10-22 19:08:10 -07:00
Sameh Khamis 46f7b74c8c chore: drop unused exports from modules QA harnesses load
QA harnesses import some modules in the page by path, for example
import('/src/data/radio.js'). Those calls name exactly which bindings
they use, and every other export of these modules has no importer:

- src/data/radio.js: 20 aliases of radio layer methods and two
  re-exported altitude constants. The radio layer keeps the methods.
- src/layers/transit/movement.js: tuning constants and course helpers
  used only inside the module lose `export`; FIX_HISTORY_MAX,
  DISPLAY_LAG_MIN_MS, DISPLAY_LAG_RESET_MS, FIX_NOISE_M, STOPPED_FIXES
  and courseBetween, which nothing reads, are deleted.
- src/overlays/worldOverlay.js, src/splitFlap.js,
  src/data/contactPlayback.js and src/voice/cloudVoiceAuth.js: helpers
  and constants used only locally lose `export`.
2026-10-22 19:07:46 -07:00
Sameh Khamis 86f271a78f chore: remove unused data and model assets
- src/data/local_data/dams/dams.geojson held the same 704 features as
  dams.geojsonl, feature for feature. Only the JSONL file is loaded
  (src/sources/infrastructureData.js), so the GeoJSON copy is dropped
  and the dataset README no longer mentions it.
- public/models/ship.glb is not loaded anywhere: no module, template,
  stylesheet or script names it. Its row in public/models/README.md
  goes with it.

Together these remove 960,345 bytes of tracked data.
2026-10-22 19:01:09 -07:00
Sameh Khamis 73e789489b chore: drop exports nothing imports
Knip, configured with the application, package exports, scripts, QA
harnesses, workers, tools and tests as entry points, reports exports
that no module imports. Each was checked by hand against string-based
and namespace imports.

- Symbols that are still used inside their own module lose only the
  `export` keyword (176 constants and helpers).
- Unused re-exports are removed from barrel modules (flight, military,
  vessel, CCTV, directions, transit history, local ADS-B and street-level
  policy modules).
- Declarations that nothing uses at all are deleted, among them
  AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText,
  DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band
  constants.
- Default exports that nothing imports are removed. The ones in
  src/data/earthquakes.js, src/data/telegeographySubmarineCables.js,
  src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a
  layer instance at import time that nothing used; those instances are
  no longer created.

Every export of a package.json entry stays, as does TOMTOM_CREDIT,
which the traffic layer reads from the credits namespace it is given.
2026-10-22 19:00:40 -07:00
Sameh Khamis 504f9342aa chore: remove unreachable legacy entry modules
Delete three modules that no entry point, package export, test or script
imports:

- src/ui.js, a StyleManager subclass that pre-selected the standalone
  catalog. Application controls are built through src/app/controls.js
  and src/standalone/controls.js.
- src/standalone/ui.js, a bare re-export of StyleManager.
- src/data/localLayers.js, a list of default infrastructure, cable and
  FIRMS layer instances. The catalog builds these layers itself.

With src/ui.js gone, the import-direction check keeps a single
compatibility exception (server/providers/local.js to
server/standalone/key-setup.js), and its test now asserts that a
src/ui.js importing standalone code is rejected. The boundary and
format registries and the docs that named these files are updated.
2026-10-22 18:53:56 -07:00
Sameh Khamis dbb20c72fe chore: drop unused local bindings
Remove locals that are assigned but never read: two unused
JulianDate.now() calls in the flights and military nearby queries, an
unread record lookup in the military snapshot renderer, unused axes from
the CCTV gizmo, an unused vessel state alias, and two unused ALPR
presentation handles. Each removed expression is side-effect free.
2026-10-22 18:09:40 -07:00
Sameh Khamis f60b9ef7c8 chore: remove unused imports and local helpers
Drop module-local helpers that nothing calls (linkAbort, summarize,
stringOption, summarizeEntity and the polygonCenter, propertyObject and
unwrapProperties helpers only it used, CALLOUT_DOT_SIZE_PX) and unused
import specifiers in runtime modules, tests, QA scripts and tools.

None of the removed bindings is exported. Re-exports such as the ALPR
policy constants and every package export are unchanged.
2026-10-22 18:09:37 -07:00
Sameh Khamis e479b6e91d chore(camera): remove unused camera presets
CAMERA_PRESETS and flyToPreset have no callers. Startup uses flyToAustin,
which stays, and city navigation uses flyToPresetLocation from
src/locations.js. Neither removed symbol is a package export.
2026-10-22 17:46:57 -07:00
Said Turksever 5d95e9adc4 Merge pull request #1276 from bilawalsidhu/codex/fix-terrain-request-bound
fix(terrain): bound each height request to one upstream chunk
2026-10-23 00:00:08 +01:00
Said Turksever 2abae6ee41 Merge branch 'main' into codex/fix-terrain-request-bound 2026-10-22 23:54:47 +01:00
Said Turksever 6e589a293d Merge pull request #1275 from bilawalsidhu/codex/fix-initial-forecast-supersession
fix(weather): preserve enable during first-load replacement
2026-10-22 23:48:26 +01:00
Said Turksever 1c4515731a Merge branch 'main' into codex/fix-initial-forecast-supersession 2026-10-22 23:40:04 +01:00
Said Turksever 62c319b575 Merge pull request #1271 from bilawalsidhu/codex/fix-share-style-keys
fix(share): reject inherited style keys during restore
2026-10-22 23:30:16 +01:00
Said Turksever 5cc250495f Merge branch 'main' into codex/fix-share-style-keys 2026-10-22 23:23:49 +01:00
Said Turksever a18dd29262 Merge pull request #1269 from bilawalsidhu/codex/fix-aircraft-refresh
fix(aircraft): coalesce refreshes and cool down OpenSky failures
2026-10-22 23:23:12 +01:00
Said Turksever 98d9b54bcd Merge branch 'main' into codex/fix-aircraft-refresh 2026-10-22 23:15:48 +01:00
Said Turksever 5bb0a210b7 Merge pull request #1265 from bilawalsidhu/codex/fix-docker-trusted-peers
fix(docker): honor trusted-peer environment overrides
2026-10-22 23:15:17 +01:00
Said Turksever 5b63bd5f7f Merge branch 'main' into codex/fix-docker-trusted-peers 2026-10-22 22:51:20 +01:00
43e8bfd257 Merge pull request #1298 from bilawalsidhu/german/hal-643-zh-cn
i18n: Simplified Chinese (zh-CN)

Add a complete Simplified Chinese translation and language picker support.
Improve Chinese typography, date formatting, and localized readouts.
Include translation guidelines and regression tests.

Based on translations by abiyeenzo (#914) and EDIT-Aeterna (#979).

Co-authored-by: German Khokhlov <[email protected]>
Co-authored-by: Enzo Abyie <[email protected]>
Co-authored-by: EDIT-Aeterna <[email protected]>
2026-10-22 22:40:52 +04:00
German Khokhlov 87369eaf0f Merge remote-tracking branch 'origin/main' into german/hal-643-zh-cn
# Conflicts:
#	scripts/package-boundaries.json
#	style.css
2026-10-22 22:31:23 +04:00
German Khokhlov 2c3b364069 docs: simplify Chinese localization notes 2026-10-22 22:29:24 +04:00
German Khokhlov 1416266bcb fix(i18n): avoid duplicate Chinese FIRMS age suffix 2026-10-22 22:07:09 +04:00
quanticsoul4772 a94677aed0 Merge branch 'main' into upstream/wind-stale 2026-10-22 06:18:13 -07:00
German Khokhlov 4bf8522985 i18n(zh-CN): apply the second-pass review findings
A fresh full review of the catalog (52 changes in 48 entries):

- Age readouts that render as bare durations got their missing 前
  (LIVE · updated {age}, POSITION {age}).
- Unified the rate-limit family on 已被限流 (12 entries had 已限流,
  正在限制请求频率 or 受到频率限制).
- Unified photorealistic on 照片级逼真 (planet strings, 3D Tiles).
- Round-1 stragglers: 括标 → 括号标记, FLIGHT/VESSEL WINDOW →
  监视窗口, mapped-ALPR loading strings, plain Talk → 脱口秀, the
  hardcoded Current style twin.
- Collocations: 与…同源, 不超过…上限, 单击放置顶点, 随语音分享.
- Mainland usage and consistency polish: 气象高度, 地面风, 标签页,
  已获取航迹 (acquire ≠ lock), 缩放级别 6, 发布时间, 独立视角,
  视野中心, 播报当前视野, and similar one-word fixes.

Kept deliberately: the 图像 (image) vs 影像 (imagery) distinction,
which mirrors the English and is consistent across the catalog.
2026-10-22 13:50:19 +04:00
German Khokhlov f651769110 i18n(zh-CN): apply the catalog review findings
Review of all 2,677 zh-CN entries (scripted checks + full meaning
review) confirmed the catalog fully Simplified and mechanically clean.
83 fixes in two passes:

Meaning and terms — PARTLY CLOUDY lost 'partly' (多云 → 晴间多云);
AIR/SEA WINDOW said scheduled flights (航班 → 空/海监视窗口); Mapped
ALPR Cameras collided with ALPR Cameras; globe terms unified on 地球
(球体地图/球体影像); later/earlier observation buttons read as future
actions (稍后/早前 → 较晚/较早); 从未叠加… misread as 'never'; 风流线
→ 风场流线; 访问飞行器 → 来访航天器; 目击 → 目击者; downrange is
落点距离 not 航程.

Polish — 加载 over 载入; 预警 for advisories; {amount}K/M/B 美元;
空载; 窗口 over 视窗; 分享的 {subject} matches its siblings; missing
CJK↔Latin spaces restored around {store} and {mode}; 拦截点击,
已被限流, 精选, 逼真世界, and other one-word mainland-usage fixes.
2026-10-22 13:14:40 +04:00
Sameh Khamis 7be5937fc3 Merge pull request #1317 from bilawalsidhu/fix/provider-response-hardening
Harden provider response handling and camera source validation
2026-10-21 16:18:50 -07:00
Sameh Khamis 9f5acf14db Name the changelog fragment for #1317 2026-10-21 16:07:42 -07:00
Sameh Khamis b75db874c6 Read API paths and forwarded requests as the router does
The router reads an absolute-form target's path without resolving dot
segments, so a target such as http://host/api/../x still reaches /api
middleware. Take that path the same way when deciding whether the API
response headers apply.

The Street View fallback now treats any forwarding header as forwarded, even
an empty one, before it spends the key.
2026-10-21 15:43:29 -07:00
Sameh Khamis afe05454d9 Require a directory prefix for camera URL pins
The URL parser already turns backslashes into separators, so only encoded
separators need refusing; a pin prefix without a trailing slash is treated as
a directory.
2026-10-21 15:33:14 -07:00
Sameh Khamis b1ed77c26c Keep camera routes reachable behind a reverse proxy
The CCTV routes now use the keyless cross-site checks, which refuse cross-site
browser requests but accept forwarding headers, so cameras work behind the
documented reverse proxy. The Street View fallback is the one step that spends
a key; a request with forwarding headers skips it and gets the placeholder
frame, as when no key is configured.
2026-10-21 15:33:14 -07:00
Sameh Khamis d80162bea2 Match API paths the way the router does
The router mounts /api without regard to letter case, accepts a '.' as well
as '/' after the prefix, and routes absolute-form request targets, so the
API response headers were missing on those spellings. Match the same way, on
both the raw and the parsed path. The test now runs real dev and preview
servers.
2026-10-21 15:33:13 -07:00
Sameh Khamis 3e80afe713 Pin camera sources by parsed URL
Camera catalog image URLs were pinned by string prefix, so a URL that only
began with the agency's directory before dot segments or an encoded separator
were resolved still counted as inside it. The pin now parses the URL and
requires the same scheme, the exact host, no credentials, the default port and
a normalized path inside the pinned directory. Redirects on a pinned host must
stay inside the same directory.

Document the response format checks and API response headers alongside the
existing server hardening notes.
2026-10-21 15:07:40 -07:00
Sameh Khamis 0ad75095a7 Validate provider response formats
CCTV frames and the Street View fallback accept only JPEG, PNG, GIF, WebP and
AVIF, parsed from the media type essence and sent under the canonical type;
any other type is cancelled and falls through to the next fallback as a
failed upstream does. Live media must match its feed kind (video/mp4,
video/webm, or multipart/x-mixed-replace with a boundary) before the camera
is reported healthy or any byte is relayed, and its redirects now stay on the
registered origin like frames. Every /api/cctv route uses the same-site gate.

The GBFS proxy relays only bodies that parse as JSON, re-serialized under a
fixed application/json type.
2026-10-21 15:07:20 -07:00
Sameh Khamis 72e1469968 Apply consistent API response headers
Every /api response, including errors, partial responses, Host refusals and
unknown routes, now carries a sandboxing Content-Security-Policy and
X-Content-Type-Options: nosniff on the dev and preview servers. Vite's server
and preview headers only reach the documents and assets it serves itself, so
a small plugin sets them ahead of the Host check. A route's own policy is sent
as an additional policy rather than replacing it.
2026-10-21 15:07:00 -07:00
German Khokhlov 275513bf2c Merge pull request #1243 from bilawalsidhu/claude/hal-627-starfield-cb6fa2
feat(sky): catalogue stars and a soft Milky Way instead of the blocky sky box
2026-10-21 21:59:02 +04:00
German Khokhlov a1a9a36f54 Merge remote-tracking branch 'origin/main' into claude/hal-627-starfield-cb6fa2 2026-10-21 21:39:46 +04:00
Mazeyar Moeini Feizabadi e84e74ca3a Merge pull request #1309 from bilawalsidhu/codex/shared-control-css-ownership
Clarify CSS ownership for shared native fields
2026-10-21 18:52:01 +02:00
Mazeyar Moeini Feizabadi 4116082cb9 docs: add release note for field CSS ownership 2026-10-21 18:41:39 +02:00
Mazeyar Moeini Feizabadi 7103c29194 refactor(ui): clarify shared field CSS ownership 2026-10-21 18:41:17 +02:00
Said TurkseverandClaude Opus 5.5 82c722e6af chore(format): keep format-scope in step with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:40:49 +01:00
Said TurkseverandClaude Opus 5.5 eaf14e2937 Merge branch 'main' into codex/fix-docker-trusted-peers
Resolve scripts/format-scope.json by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:39:47 +01:00
Mazeyar Moeini Feizabadi a20673e78d Merge pull request #1306 from bilawalsidhu/codex/shared-status-badges
Unify read-only status badges in CCTV and Weather
2026-10-21 18:18:47 +02:00
Mazeyar Moeini Feizabadi 83471ea6d7 docs: add release note for shared status badges 2026-10-21 18:10:46 +02:00
Mazeyar Moeini Feizabadi f70bc0cabb refactor(ui): share read-only status badges 2026-10-21 18:10:09 +02:00
Mazeyar Moeini Feizabadi 8ed1bbbd0f Merge pull request #1305 from bilawalsidhu/codex/shared-form-controls
refactor(ui): share native dropdowns and numeric fields
2026-10-21 17:31:57 +02:00
Mazeyar Moeini Feizabadi 3f903f0b18 Merge main and preserve shared control surfaces
# Conflicts:
#	src/ui/templates/layer-panels.html
#	style.css
2026-10-21 17:25:25 +02:00
Mazeyar Moeini Feizabadi 4deb32a9a3 Merge pull request #1302 from bilawalsidhu/codex/shared-action-buttons
refactor(ui): share ordinary Scene and CCTV action buttons
2026-10-21 17:25:05 +02:00
Mazeyar Moeini Feizabadi 49735d9307 Merge main for shared action button integration 2026-10-21 17:18:18 +02:00
Mazeyar Moeini Feizabadi b6623f65cd docs: add shared native fields changelog entry 2026-10-21 17:11:56 +02:00
quanticsoul4772andClaude Opus 5.5 80292fab72 docs(changelog): reword fragment for #1303
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 07:20:56 -07:00
Said Turksever f3bb697ed4 Merge pull request #1264 from bilawalsidhu/codex/fix-overpass-request-admission
fix(overpass): bound request validation and reject cross-site POSTs
2026-10-21 15:20:25 +01:00
quanticsoul4772andClaude Opus 5.5 6d3e7c6c67 docs(changelog): fragment for #1303
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 07:09:38 -07:00
Said TurkseverandClaude Opus 5.5 e925f75a4a Merge branch 'main' into codex/fix-hidden-tab-polling
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:54 +01:00
Said TurkseverandClaude Opus 5.5 b493418ed1 Merge branch 'main' into codex/fix-traffic-render-hold
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:52 +01:00
Said TurkseverandClaude Opus 5.5 a2968af45e Merge branch 'main' into codex/fix-launch-hover-throttle
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:48 +01:00
Said TurkseverandClaude Opus 5.5 635fb55514 Merge branch 'codex/fix-overpass-request-admission' into codex/fix-overpass-cache-singleflight
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:45 +01:00
Said TurkseverandClaude Opus 5.5 c192c3b8b5 Merge branch 'main' into codex/fix-terrain-request-bound
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:39 +01:00
Said TurkseverandClaude Opus 5.5 3d75e0a731 Merge branch 'main' into codex/fix-initial-forecast-supersession
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:36 +01:00
Said TurkseverandClaude Opus 5.5 2a0f78d169 Merge branch 'main' into codex/fix-kml-root-scanner
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:33 +01:00
Said TurkseverandClaude Opus 5.5 6477f9c910 Merge branch 'main' into codex/fix-military-history-failures
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:31 +01:00
Said TurkseverandClaude Opus 5.5 a451c1ba6f Merge branch 'main' into codex/fix-share-style-keys
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:26 +01:00
Said TurkseverandClaude Opus 5.5 76ee07ee6e Merge branch 'main' into codex/fix-space-provider-cooldowns
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:23 +01:00
Said TurkseverandClaude Opus 5.5 f07a52cb00 Merge branch 'main' into codex/fix-aircraft-refresh
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:21 +01:00
Said TurkseverandClaude Opus 5.5 023b6a1991 Merge branch 'main' into codex/fix-radio-tag-normalization
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:18 +01:00
Said TurkseverandClaude Opus 5.5 2cf36534d9 Merge branch 'main' into codex/fix-docker-trusted-peers
Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:04:15 +01:00
Said Turksever 54e7dc7c30 Merge branch 'main' into codex/fix-overpass-request-admission 2026-10-21 15:00:44 +01:00
Said Turksever ee1e91781a Merge branch 'main' into claude/hal-627-starfield-cb6fa2 2026-10-21 14:54:23 +01:00
Said Turksever 2da8ced642 Merge pull request #1286 from asturksever/fix/since-note-inline
fix(street-level): SINCE note on the value's line: ANY DATE (by upload date)
2026-10-21 14:50:45 +01:00
Said Turksever a397c94892 Merge branch 'main' into fix/since-note-inline 2026-10-21 14:36:24 +01:00
Mazeyar Moeini Feizabadi 7932259dc4 refactor(ui): share native dropdown and numeric field surfaces 2026-10-21 15:35:49 +02:00
Mazeyar Moeini Feizabadi 5a11bd9515 docs: add shared action buttons changelog entry 2026-10-21 15:22:21 +02:00
Mazeyar Moeini Feizabadi 538f2f1bc0 refactor(ui): share Scene and CCTV action buttons 2026-10-21 15:22:00 +02:00
Mazeyar Moeini Feizabadi 5a1598fd9e Merge pull request #1287 from bilawalsidhu/codex/shared-sliders
refactor(ui): unify ordinary slider components and styling
2026-10-21 14:19:55 +02:00
Mazeyar Moeini Feizabadi bf0e1d7c79 Merge current main into shared sliders 2026-10-21 14:10:40 +02:00
Mazeyar Moeini Feizabadi 415edbcfdf Merge pull request #1301 from bilawalsidhu/codex/shared-segmented-selectors
Unify segmented selectors and fix compact control spacing
2026-10-21 13:27:12 +02:00
Mazeyar Moeini Feizabadi 4bfee31fdb Reserve full height for imagery segmented controls 2026-10-21 13:21:23 +02:00
Mazeyar Moeini Feizabadi 7df8b369e4 Add changelog for shared segmented selectors 2026-10-21 13:08:35 +02:00
Mazeyar Moeini Feizabadi 40394ff76e Unify segmented selectors and fix compact panel spacing 2026-10-21 13:07:51 +02:00
German KhokhlovandGPT-6 27d4c0ba9c fix(sky): preserve dark model lighting and address review
Merge origin/main and retain both format-scope additions.
Give StarrySky a separate black environment cube map and release it on destroy.
Guard anime updates when StarrySky is absent, require a visible Normal sky
before anime QA, and compare a lit airplane against black and white controls.
Document the sky background contract and its verification.

Co-Authored-By: GPT-6 (Codex) <[email protected]>
2026-10-21 14:21:14 +04:00
German KhokhlovandCodex fd78bc3884 fix(zh-CN): localize readouts and keep short labels intact
Preserve Chinese unit casing and spacing in the HUD and cockpit, localize FIRMS cache ages, CCTV health and radio counts, and keep imagery resolution and route modes readable. Add regression coverage for localized output and unchanged English.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-21 13:36:38 +04:00
German KhokhlovandCodex d6c4d58a2d i18n(zh-CN): apply the language review
Correct weather, imagery, scene, voice, and status wording after the Chinese UI review. Align the translation glossary with the revised terminology.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-21 13:35:52 +04:00
6a40b30d12 docs(changelog): credit the zh-CN translators by GitHub handle
Enzo's commit email is not linked to his GitHub account, so credit his #914
translation here as well.

Co-authored-by: Enzo Abyie <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:02:34 +04:00
German KhokhlovandClaude Opus 5.5 da1dd928ef docs(changelog): Simplified Chinese (#1298)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:55:21 +04:00
German KhokhlovandClaude Opus 5.5 f3fa1fa022 i18n(zh-CN): translate the strings from the latest main
Gemini Live voice and the street-level coverage hints; drop the entries the
code no longer uses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:54:55 +04:00
German Khokhlov c75007bb13 Merge remote-tracking branch 'origin/main' into german/hal-643-zh-cn
# Conflicts:
#	scripts/package-boundaries.json
#	src/i18n/locales/es.json
#	src/i18n/locales/ru.json
2026-10-21 12:53:43 +04:00
German KhokhlovandClaude Opus 5.5 c4cc499d58 i18n(zh-CN): complete and ship Simplified Chinese
- Translate the remaining 910 strings and review the whole catalog (meaning,
  terms, punctuation, label length) against the screens.
- Register zh-CN: any zh tag maps to it except Traditional ones (Hant, TW,
  HK, MO), which keep English until a Traditional catalog ships.
- Hemisphere letters get their own context so the HUD sector line reads
  21.33N, not a compass word; the Bhote Koshi imagery dates follow
  year-first languages (2021年10月16日).
- cjk.css: tight tracking for Chinese text and a 9px floor on the dock voice
  labels and layer buttons; Latin HUD readouts keep their look.
- docs/translations/zh-CN.md: style, plurals and glossary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:52:34 +04:00
EDIT-Aeterna db16d8ed4c i18n(zh-CN): Simplified Chinese strings from #979
The Simplified Chinese localization by EDIT-Aeterna (#979), converted to the
English-keyed catalog: 823 more entries for UI text that #914 did not cover.
2026-10-21 12:52:34 +04:00
Enzo Abyie 3eb585fe49 i18n(zh-CN): Simplified Chinese strings from #914
The Simplified Chinese locale by abiyeenzo (#914), converted from its
namespaced JS catalogs to the English-keyed zh-CN.json: 891 entries whose
English still matches the current UI.
2026-10-21 12:52:34 +04:00
quanticsoul4772andClaude Opus 5.5 fb88dd6e1b fix(tools): say when get_wind answers from a stale forecast run
When the model upstream fails, the wind proxy keeps serving its last
run, however old, with manifest.stale set. The wind layer shows that,
but get_wind ignored it and gave the old run's wind as current.

get_wind now returns stale and adds "(forecast may be stale)" to its
summary, the way get_weather and the space tools flag stale data.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 15:25:45 -07:00
Sameh Khamis 713285b554 Merge pull request #1205 from manjunath22466/fix/cctv-tool-routing
fix(voice): report actual cctv toggle results
2026-10-20 10:15:33 -07:00
Sameh Khamis 13b474d302 Merge branch 'main' into fix/cctv-tool-routing 2026-10-20 10:05:39 -07:00
Sameh Khamis 23feb16b0f Merge pull request #1235 from manjunath22466/fix/location-resolution
fix(analyst): disambiguate region names and retain boundary provenance
2026-10-20 10:05:27 -07:00
Sameh Khamis 3d75b9e7ee Merge branch 'main' into fix/location-resolution 2026-10-20 09:53:07 -07:00
Sameh Khamis da9beb9324 Merge branch 'main' into fix/cctv-tool-routing 2026-10-20 09:49:00 -07:00
Sameh Khamis 803cddd5cb docs(analyst): describe region resolution as current state 2026-10-20 09:48:49 -07:00
Sameh Khamis 5f99cd25ec docs(changelog): credit the GitHub handle for #1205 2026-10-20 09:48:49 -07:00
Said 56be69d065 test: restore minimized Transit windows before screenshots 2026-10-20 15:12:33 +01:00
Said 168d491038 test: keep Traffic interception off workers and restore screenshot windows 2026-10-20 15:12:32 +01:00
Said 116f28a525 test: isolate keyed and keyless Traffic browser sessions 2026-10-20 14:55:18 +01:00
Said 77ed0a7c31 test: run Traffic fixtures with normal browser security 2026-10-20 14:53:08 +01:00
Said 72f1625257 test: avoid concurrent format-scope append conflicts 2026-10-20 14:43:01 +01:00
Said 5b9c726f33 test: prove hidden-tab polling in a real browser 2026-10-20 14:37:57 +01:00
Said eb2ac9197d test: keep weather teardown browser actively rendering 2026-10-20 14:37:05 +01:00
Said 2c8b3593d1 test: observe a poll transition in the Transit anchor probe 2026-10-20 14:19:11 +01:00
Said f16fda2350 test: exercise traffic with current vector and terrain fixtures 2026-10-20 14:18:23 +01:00
Said e4955f351f test: qualify Transit visibility and city feed ownership 2026-10-20 14:07:13 +01:00
Said d6f89e3cfd fix: release weather camera listeners while disabled 2026-10-20 14:01:22 +01:00
Said 9fd434438b test: target mounted voice input in radio takeover gate 2026-10-20 13:50:42 +01:00
Mazeyar Moeini Feizabadi 64fd9ba1c2 docs: add shared slider changelog fragment 2026-10-20 14:49:06 +02:00
Mazeyar Moeini Feizabadi f1c617ad27 refactor(ui): share ordinary slider components 2026-10-20 14:48:42 +02:00
Said TurkseverandClaude Opus 5.5 f6006358ed fix(street-level): SINCE note on the value's line: ANY DATE (by upload date)
The slim-down in #974 dropped --sl-text-small, which .sl-since-note still
used, so with KartaView lit "KartaView: by upload date" rendered on its
own line larger than the value. The note now sits beside the value in the
same mono type and letter spacing, a size smaller (0.5rem), as
"(by upload date)". One source is lit at a time, so the provider name is
dropped. es/ru catalogs, tests, README, CURRENT-STATE and the registry
JSDoc follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:23:44 +01:00
German KhokhlovandClaude Opus 5.5 8c33c6f349 docs(sky): shorten the sky comments and README, fix three stale ones
The far-plane comment said z = w (the code puts z just under w), the Milky
Way constant is a brightness, not an opacity, and the CDS acknowledgement
lives in the Data attribution popover, not DATA_SOURCES.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:56:44 +04:00
German KhokhlovandClaude Opus 5.5 b00c7c12c8 refactor(sky): cut what the sky does not need
- qa-anime-sky keeps only the Milky Way case; the satellites and keyless
  cases checked an anime shader this branch no longer changes.
- Drop qa-sky-sun-moon.mjs: the viewer-options unit test covers it.
- VisualEffects sets the sky's visibility directly: the viewer always
  carries a StarrySky and never outlives the effects.
- Drop the unused star intensity knob, the malformed-row tolerance for
  our own pack, the test-only Transforms parameter and a private-name test.
- DATA_SOURCES points to sky/README.md instead of repeating it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:45:36 +04:00
German KhokhlovandClaude Opus 5.5 d94cc35c81 fix(sky): keep the Milky Way out of model lighting, test the sky wiring
Scene reads skyBox._cubeMap as the environment map that lights models, in
the TEME, flipY layout of Cesium's own sky box; the ICRF Milky Way would
light them rotated. Rename the field so models keep Cesium's default.

Test the anime fade on a real StarrySky and that the viewer gets the sky
as an option (skyBox: false would drop the Sun and Moon). Correct three
comments on the star limit and the Milky Way levels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:37:43 +04:00
German KhokhlovandClaude Opus 5.5 86ae5e9cf4 fix(sky): blur the faint Milky Way glow wider so unresolved stars leave no clumps
The fine 0.4 degree blur kept the residual glow of unresolved stars as
mottled patches at the band's edges. Blend towards a 2 degree blur where
the coarse copy is faint (smoothstep over 0.5-8 % of the range), so the
core keeps its dust lanes and the faint sky goes smooth. Faces 85 -> 62 kB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:02:48 +04:00
manjunath b48fcc42ae fix(voice): refuse direct cctv toggles outside the shared lifecycle 2026-10-20 10:00:24 +05:30
manjunath b920d5f404 merge main into fix/cctv-tool-routing 2026-10-20 10:00:13 +05:30
manjunath b0e0945360 fix(analyst): restore exact region matches and remove search rescue 2026-10-20 06:31:50 +05:30
Said eabff9005d docs: record polling fix 2026-10-20 01:26:21 +01:00
Said ec0985618c fix(layers): pause periodic network updates in hidden tabs 2026-10-20 01:25:40 +01:00
Said 34b064fc62 docs: record traffic fix 2026-10-20 01:22:32 +01:00
Said 122b511d1e fix(traffic): hold rendering only for animated content 2026-10-20 01:21:53 +01:00
Said bc0c4b8489 docs: record launch fix 2026-10-20 01:15:33 +01:00
Said 08e90e8182 fix(missions): throttle hover picks and clear stale cursors 2026-10-20 01:15:19 +01:00
Said 133d82621e docs: record overpass fix 2026-10-20 01:12:53 +01:00
Said 5980a08533 fix(overpass): reserve singleflight before disk cache reads 2026-10-20 01:12:36 +01:00
Said f436d72d1b docs: record terrain fix 2026-10-20 00:57:54 +01:00
Said 7860cbf59e fix(terrain): bound each height request to one upstream chunk 2026-10-20 00:57:24 +01:00
Said 2f23bbe87f docs: record forecast fix 2026-10-20 00:56:00 +01:00
Said 0cb6857229 fix(weather): preserve enable during first-load replacement 2026-10-20 00:55:40 +01:00
Said 9541963aa6 docs: record import fix 2026-10-20 00:48:45 +01:00
Said 6552a89ddd fix(import): scan the KML root delimiter in bounded time 2026-10-20 00:48:25 +01:00
Said Turksever 69a87fe238 test: observe aircraft arrival before idle refresh 2026-10-20 00:44:51 +01:00
Said 5d9eabc919 docs: record history fix 2026-10-20 00:44:15 +01:00
Said 5f3d1eaa2f fix(military): retire unavailable history and keep local trails 2026-10-20 00:43:47 +01:00
Said 53b6eae122 docs: record share fix 2026-10-20 00:38:18 +01:00
Said d49877ad2e fix(share): reject inherited style keys during restore 2026-10-20 00:38:09 +01:00
Said 8602999056 docs: record space fix 2026-10-20 00:29:24 +01:00
Said 9d3d5164fe fix(space): respect provider cooldowns and bound TLE groups 2026-10-20 00:28:00 +01:00
Said e20a56bcb0 docs: record aircraft fix 2026-10-20 00:25:43 +01:00
Said ed2f0685e0 fix(aircraft): coalesce refreshes and cool down OpenSky failures 2026-10-20 00:25:17 +01:00
Said 34507f606a docs: record radio fix 2026-10-20 00:23:34 +01:00
Said 32b0304c2c fix(radio): normalize station tags and retain valid mixed rows 2026-10-20 00:23:10 +01:00
Said Turksever 84d96971df fix: let keyless browser validation settle 2026-10-20 00:15:49 +01:00
Said Turksever 2f25ac0784 Merge branch 'main' into codex/fix-overpass-request-admission 2026-10-20 00:06:07 +01:00
Said f11fd3cb05 docs: add changelog for Docker trusted-peer fix 2026-10-19 23:49:59 +01:00
Said 37acb4842f fix(docker): honor trusted-peer environment overrides 2026-10-19 23:23:52 +01:00
Said c5086f8d6d docs: add changelog entry for PR 1264 2026-10-19 23:14:52 +01:00
Said ac9cd4ab84 fix(overpass): bound query validation and reject cross-site requests 2026-10-19 23:02:46 +01:00
German KhokhlovandClaude Opus 5.5 e8fd1f2d01 fix(sky): ease the Milky Way black level in at the median sky
Away from the galactic plane the ESO panorama keeps a mottled glow of
unresolved stars and dust. With the black level at the darkest quarter of
the sky and a hard cut, it showed as grey clouds with black holes around
the globe. The black level now sits at the median sky and eases in with a
soft knee on luminance, keeping each pixel's hue: high galactic latitudes
are black, the band keeps its glow. Faces shrink from 247 to 85 KB.

The changelog fragment also notes the anime fade and the new asset weight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 00:42:23 +04:00
German KhokhlovandClaude Opus 5.5 8deb47560e fix(sky): restore the Milky Way on teardown, tidy the anime QA script
- VisualEffects.destroy() puts skyBox.milkyWayVisibility back to 1, as it
  already restores the borrowed bloom stage, so a surviving viewer does not
  keep a faded sky.
- Rename scripts/qa-anime-space-mask.mjs to qa-anime-sky.mjs: rewrite the
  header for the current approach, drop --space-expr and --repo, import
  puppeteer as a package, reject unknown cases and count the milkyway case
  as reached only while the anime stage is on.
- Put both sky QA scripts under format:check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 00:42:22 +04:00
German Khokhlov 65b98b4971 Merge remote-tracking branch 'origin/main' into claude/hal-627-starfield-cb6fa2 2026-10-19 23:06:49 +04:00
German KhokhlovandClaude Opus 5.5 e652e50d5c fix(anime): fade the Milky Way out instead of masking the sky by depth
The far-plane mask in the anime shader read the post-process depth, which
cannot tell the sky from far geometry: with a layer that clamps the near
plane to 0.1 m (satellites) the whole Earth lost its cel bands, keyless
terrain above the ellipsoid horizon did too, and the atmosphere, Sun and
Moon stopped banding.

Restore the anime shader from main and fade the Milky Way glow with the
style instead: VisualEffects sets skyBox.milkyWayVisibility to
1 - anime intensity. scripts/qa-anime-space-mask.mjs checks all three
cases headless and that the anime sky shows no bands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:27:28 +04:00
knottyandClaude Opus 5.5 6bb2856403 docs(changelog): add the terrain test cache isolation fragment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BNTtHAa65nobzJ7jp2Ltr4
2026-10-19 10:30:41 -06:00
knottyandClaude Opus 5.5 657fd589d7 test(terrain): give every terrain-heights proxy in the bounds tests its own cache
Seven tests built terrainHeightsProxy() with no cachePath, so the proxy fell
back to this checkout's .gev-cache/terrain-heights.json. A point the app had
already cached there answered a request the test expected to reach the
stubbed upstream: after running the dev server near Austin, the cross-site
test failed with 1 !== 2.

isolatedCachePath(t) gives each test a mkdtemp cache file removed in
t.after, as the over-large cache test already did; that test now uses it too.

Fixes #1242.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BNTtHAa65nobzJ7jp2Ltr4
2026-10-19 10:30:17 -06:00
German KhokhlovandClaude Opus 5.5 adeee12209 docs(changelog): add the starfield sky entry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 20:11:32 +04:00
German Khokhlov 7d4da26905 Merge remote-tracking branch 'origin/main' into claude/hal-627-starfield-cb6fa2
# Conflicts:
#	scripts/format-scope.json
2026-10-19 20:09:55 +04:00
German KhokhlovandClaude Opus 5.5 fb81e155b7 fix(sky): keep the Sun and Moon, settle outside the ICRF tables
- Pass the custom sky box to the Viewer instead of `skyBox: false`, which
  also stopped CesiumWidget from creating the Sun and the Moon. Guarded by
  scripts/qa-sky-sun-moon.mjs.
- Outside the IAU 2006 tables (before 1975, after ~2050) the preload
  resolves without data; remember that span and stay on TEME instead of
  preloading and rendering every frame.
- The anime style no longer cel-shades the far plane, where four bands
  turned the faint Milky Way into blotches.
- Paint the planets out after the star-removing opening and with the
  ring median, so no brighter plateau is left; faces rebuilt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 19:43:25 +04:00
manjunath a6f229d870 Merge branch 'main' of https://github.com/bilawalsidhu/gods-eye-view into fix/cctv-tool-routing 2026-10-19 21:12:55 +05:30
manjunath 1eded0468f Merge upstream main into fix/location-resolution 2026-10-19 21:12:47 +05:30
manjunath 3b3895f82e Merge upstream main into fix/location-resolution 2026-10-19 20:30:51 +05:30
manjunath d09d1ddad1 Merge branch 'main' of https://github.com/bilawalsidhu/gods-eye-view into fix/cctv-tool-routing 2026-10-19 20:28:18 +05:30
German KhokhlovandClaude Opus 5.5 6549344946 feat(sky): replace the blocky default sky box with catalogue stars
Cesium's default Tycho-2 sky box is six 1024² JPEGs stretched 3–6× around
the whole-globe view, so space read as an over-compressed JPEG (HAL-627).

The scene now draws its own sky in the Scene#skyBox slot: the 9,096 stars
of the Yale Bright Star Catalogue as point sprites sized in CSS pixels
(magnitude → width and opacity, B−V → colour), over a soft, star-free
Milky Way cube map built from ESO/S. Brunier's panorama (CC BY 4.0). Both
layers share one ICRF→Earth-fixed rotation at the scene clock, with the
TEME rotation filling in until Cesium's IAU 2006 tables load.

Assets total 482 KB, built by scripts/build-bright-stars.mjs and
scripts/build-milky-way.mjs. Frame time and the idle render governor are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:08:46 +04:00
manjunath 1763143337 docs(changelog): record location resolution fix 2026-10-19 11:02:30 +05:30
manjunath a331616a9d fix(location): preserve geocoded places and region provenance 2026-10-19 11:01:18 +05:30
manjunath e20e858ba8 docs(changelog): record cctv routing fix 2026-10-18 11:15:09 +05:30
manjunath 9b1967a59e fix(voice): report actual cctv toggle results 2026-10-18 11:13:30 +05:30
495 changed files with 30614 additions and 4220 deletions

No files matched your search

+5 -2
View File
@@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live
# Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY.
MAPILLARY_CLIENT_TOKEN=
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired
# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously.
# Default: oauth — requires client credentials below
# Set to "anon" to skip aircraft auth (rate-limited but works)
OPENSKY_AUTH_MODE=oauth
@@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET=
# Optional paid Patreon tokens raise request limits; there is no free API key.
LL2_API_TOKEN=
# Optional: OpenSky credentials JSON file path (alternative to above)
# Optional: OpenSky credentials JSON file path, used when the two values above
# are empty: the credentials.json your OpenSky account page downloads when you
# create an API client. Use an absolute path for compatibility with launchers.
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json
# Optional: dev server config
+9
View File
@@ -6,6 +6,15 @@
never derived: an aircraft that has not transmitted a rate reads as `null`
rather than a level-flight `0`.
- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted
where it could go — https, an allowlisted host, a station feed path, no
redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted
how often, so the dev server would make unlimited requests at a third-party
bikeshare operator on any caller's behalf. The route now admits 120
requests/minute per client address, refusing the rest with 429 and
`Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a
minute with every catalogued system in range, so ordinary use is unchanged.
- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region,
YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own
licence quote and attribution. A feed can now name its own modes, so Toronto's
+1 -1
View File
@@ -116,7 +116,7 @@ The highest-leverage places to jump in:
## Architecture in one minute
- **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/).
- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/<layer>.js` entries retain compatibility; new code belongs with its focused owner.
- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/<layer>.js` entries retain compatibility; new code belongs with its focused owner.
- Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers/<family>/source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details.
- **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
- `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first.
+8 -1
View File
@@ -177,7 +177,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **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`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. 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.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
@@ -205,6 +205,8 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
@@ -277,6 +279,11 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
wiring (see below).
### Sky background (`sky/`)
Sources, licences, the changes CC BY asks to state, hashes and build commands
are in [`sky/README.md`](src/data/local_data/sky/README.md).
### DataSF Analysis Neighborhoods (`neighborhoods/`)
`neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official
+16 -2
View File
@@ -177,6 +177,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in,
When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address.
```bash
GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up
```
Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your
shell or the project's `.env`; an exported shell value takes precedence. You can
also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting.
Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly
disables it. The startup log confirms that no additional peers are trusted.
<details>
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
@@ -333,6 +343,10 @@ Thirty tools, four jobs — the commands below come straight from the product's
**🎥 Direct it** — drone-operator camera verbs:
“Rotate the globe” spins Earth to the right continuously; ask for left, a speed,
or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance;
camera input stops rotation.
> 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_
**🖊️ Annotate it** — a whiteboard over the real world:
@@ -425,7 +439,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
@@ -478,7 +492,7 @@ How the globe handles live data:
```
src/
├── main.js # Bootstrap: Google 3D tiles, layer registration
├── ui.js # Runtime UI — panels, HUD, styles, control facade
├── ui/ # Runtime UI — panels, HUD, styles, controls
├── hud.js # Intelligence HUD + AI scene summary
├── keySetup.js # POWER UP panel — in-app provider keys (dev server only)
├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks
+8 -1
View File
@@ -69,11 +69,13 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
- **Response-size caps and timeouts** on proxied responses.
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
@@ -98,6 +100,11 @@ The dev server is a **key broker**: every server-side key above is spendable by
`compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN
client arrives from the same gateway address, which hands them Provider
Settings too.** Treat publishing beyond loopback as the LAN opt-in above.
Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from
the shell or project `.env` before the launcher runs. Set trusted peers to
`none` when widening the publish: it becomes an explicit empty list before
Vite loads configuration, so the gateway cannot regain Provider Settings
access. Unset or blank values preserve automatic gateway discovery.
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
Pinokio release re-reads sharing state when an app registers its Open URL and
+72 -1
View File
@@ -47,6 +47,75 @@ export const BROWSER_HEADERS = Object.freeze({
'X-Content-Type-Options': 'nosniff',
});
/**
* Content-Security-Policy for every `/api` response. API routes return data,
* never a document, so a response opened directly in a tab gets no origin and
* can load nothing.
*/
export const API_CSP = "default-src 'none'; sandbox";
/** Response headers every `/api` response carries. */
export const API_HEADERS = Object.freeze({
'Content-Security-Policy': API_CSP,
'X-Content-Type-Options': 'nosniff',
});
/**
* Whether a request reaches an `/api` mount, by the router's own rule: the
* prefix compared without case and followed by the end, `/` or `.`. The router
* reads an absolute-form target's path without resolving dot segments, so the
* path is taken here the same way (scheme and authority dropped, backslashes as
* slashes), alongside the raw and the fully parsed path; any match counts.
*/
function isApiPath(url) {
const raw = String(url || '/');
const target = raw
.trim()
.split(/[?#]/, 1)[0]
.replace(/\\/g, '/')
.replace(/^[a-z][a-z0-9+.-]*:(?:\/\/[^/]*)?/i, '');
let parsed = '';
try {
parsed = new URL(raw, 'http://localhost').pathname;
} catch {
/* the unparsed paths still decide */
}
return [raw.split(/[?#]/, 1)[0], target, parsed].some((path) =>
/^\/api(?:$|[/.])/i.test(path),
);
}
/**
* Vite plugin that sets API_HEADERS on every API response before any other
* middleware runs, so success, error, partial, refused and unknown-route
* answers all carry them. Vite's `server.headers` and `preview.headers` only
* reach the documents and assets Vite serves itself. A route may still add
* headers; a route's own Content-Security-Policy is sent as an additional
* policy, which can only restrict further, never replace this one.
*/
export function apiResponseHeadersPlugin() {
const install = (server) => {
server.middlewares.use((req, res, next) => {
if (!isApiPath(req.url)) return next();
for (const [name, value] of Object.entries(API_HEADERS))
res.setHeader(name, value);
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) =>
String(name).toLowerCase() === 'content-security-policy' &&
String(value) !== API_CSP
? setHeader(name, `${API_CSP}, ${value}`)
: setHeader(name, value);
next();
});
};
return {
name: 'api-response-headers',
enforce: 'pre',
configureServer: install,
configurePreviewServer: install,
};
}
/** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({
plugins = [],
@@ -61,7 +130,9 @@ export function createBrowserViteConfig({
} = {}) {
return {
plugins: [
// First, so no provider route answers a Host the server does not allow.
// First, so every API response, including a Host refusal, carries them.
apiResponseHeadersPlugin(),
// Next, so no provider route answers a Host the server does not allow.
hostCheckPlugin(),
cesium(),
applicationHtmlPlugin(),
+3
View File
@@ -0,0 +1,3 @@
- Report actual outcomes when voice enables or disables CCTV through the shared
layer lifecycle, including failed and cancelled changes (manjunath22466,
#1205).
+2
View File
@@ -0,0 +1,2 @@
- Add continuous globe rotation with direction and speed controls and a globe-fit
check before starting (manjunath22466, #1225).
+8
View File
@@ -0,0 +1,8 @@
- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server
now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's
account page downloads) on every platform, not only through the macOS
launcher, and no longer sends retired username/password Basic auth:
`OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to
set instead (quanticsoul4772, #1229).
- Credential-file warnings omit parser excerpts and filesystem error details;
the path example also works with the macOS launchers.
+3
View File
@@ -0,0 +1,3 @@
- Require a geocoded containment anchor for ambiguous analyst region names,
preserve boundary provenance and cancellation safety, and retain exact-name
precedence for natural outlines (manjunath22466, #1235).
+5
View File
@@ -0,0 +1,5 @@
- Replace the blocky JPEG sky around the globe with the 9,096 stars of the
Yale Bright Star Catalogue, drawn as sharp points where they are at the
scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun
and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh,
#1243).
@@ -0,0 +1,4 @@
- The terrain-heights bounds tests no longer read the checkout's
`.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so
`npm test` no longer fails after the dev server has cached terrain near
Austin (KnottyDyes, #1244).
@@ -0,0 +1,9 @@
- Prevent malformed Overpass filters from blocking the local server, and reject
cross-site or excessive query requests before reading their bodies
(asturksever, #1264; fixes #1248).
- Release unread responses from the optional Google-token probe so keyless
browser validation can settle; the Overpass gate recognizes only that probe's
expected 404 as an unavailable capability (asturksever, #1264).
- Make tracking arrival checks respect the aircraft update cadence while checking
orientation on the first reveal, avoiding false failures at faster render
rates (asturksever, #1264).
+3
View File
@@ -0,0 +1,3 @@
- Honor Docker Compose trusted-peer and allowed-host settings from the shell
or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway
access to Provider Settings before Vite starts (asturksever, #1265).
+2
View File
@@ -0,0 +1,2 @@
- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268).
- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268).
+1
View File
@@ -0,0 +1 @@
- Coalesce refreshes and cool down opensky failures (asturksever, #1269).
+1
View File
@@ -0,0 +1 @@
- Respect provider cooldowns and bound tle groups (asturksever, #1270).
+1
View File
@@ -0,0 +1 @@
- Reject inherited style keys during restore (asturksever, #1271).
+1
View File
@@ -0,0 +1 @@
- Retire unavailable history and keep local trails (asturksever, #1273).
+1
View File
@@ -0,0 +1 @@
- Scan the kml root delimiter in bounded time (asturksever, #1274).
+3
View File
@@ -0,0 +1,3 @@
- Preserve enable during first-load replacement (asturksever, #1275).
- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275).
- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275).
+1
View File
@@ -0,0 +1 @@
- Bound each height request to one upstream chunk (asturksever, #1276).
+1
View File
@@ -0,0 +1 @@
- Reserve singleflight before disk cache reads (asturksever, #1278).
+1
View File
@@ -0,0 +1 @@
- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279).
+8
View File
@@ -0,0 +1,8 @@
- Hold rendering only for animated content (asturksever, #1281).
- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281).
- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281).
- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281).
- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281).
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281).
- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281).
- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281).
+7
View File
@@ -0,0 +1,7 @@
- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282).
- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282).
- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282).
- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282).
- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282).
- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282).
- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282).
+4
View File
@@ -0,0 +1,4 @@
- The Street Level SINCE note sits on the value's line, in its type and a
size smaller: with KartaView lit it reads ANY DATE (by upload date). It
had lost its size variable in the Street Level slim-down and rendered
larger than the value, on a line of its own (asturksever, #1286).
+3
View File
@@ -0,0 +1,3 @@
- Give ordinary sliders a shared native control and consistent track, thumb,
value, focus and disabled styling while preserving feature behavior and
specialized controls (mazy1998, #1287).
@@ -1,10 +0,0 @@
- Local ADS-B filters implausible altitude changes and holds the accepted
value consistently for markers, cards and models. Browser refusals count
actual altitude frames rather than unrelated aircraft messages. Decoder
feeds use a conservative five-second recovery with three advancing
snapshots and aircraft message activity, explicitly marked as inferred.
Held values retain their altitude source and measurement time through
context and analyst results; feeds report an unknown measurement time.
Cached snapshots and transport jitter cannot recount refusals. Ground
reports reset the airborne reference, and no absolute altitude ceiling is
imposed (Pedro Lobato's original filter, #801, with maintainer corrections).
+4
View File
@@ -0,0 +1,4 @@
- Add Simplified Chinese (zh-CN) to the language picker, with localized dates
and readable Chinese labels. Based on translations by @abiyeenzo (#914)
and @EDIT-Aeterna (#979).
(German, @gekh, #1298).
@@ -0,0 +1,5 @@
- Unify segmented selectors for model coverage, detection allocation, drawing
shape, Street View and imagery comparison while preserving their existing
behavior. Keep compact labels such as FILL and FLAT on one line, and let
collapsed Weather release space to the open rail panel. Reserve the full mode
button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301).
@@ -0,0 +1,3 @@
- Give ordinary Scene and CCTV actions shared native buttons with consistent
default, destructive and compact styling while preserving their existing
behavior and specialized controls (mazy1998, #1302).
+2
View File
@@ -0,0 +1,2 @@
- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a
cached model run (quanticsoul4772, #1303).
@@ -0,0 +1,2 @@
- Point distances wrap longitude differences across the antimeridian, in voice
proximity checks and `places_nearby` (quanticsoul4772, #1304).
@@ -0,0 +1,3 @@
- Share native dropdown and numeric-field styling across Scene, CCTV, Display
and Radio controls, with a shared CCTV number-input builder that preserves
existing precision, validation and commit behavior (mazy1998, #1305).
+4
View File
@@ -0,0 +1,4 @@
- Share read-only status badge presentation across CCTV source/frame labels,
calibration provenance and Weather coverage, preserving status meanings,
palettes, translations and announcements while preparing the view contract
for a future Vue migration (mazy1998, #1306).
@@ -0,0 +1,3 @@
- Clarify shared field CSS ownership: CCTV owns calibration editor sizing,
while Radio and Cyber supply explicit field palettes without changing the
current appearance or native control behavior (mazy1998, #1309).
@@ -0,0 +1 @@
- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317).
+6
View File
@@ -0,0 +1,6 @@
- Remove dead code across the repository: three modules nothing imports
(`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports
and locals nothing reads, a duplicate copy of the bundled dams data and an
unused ship model. The app no longer builds an unused default wind layer
and Bhote Koshi locator layer when it loads. Package exports are unchanged
(samehkhamis, #1321).
@@ -0,0 +1,8 @@
- Export the KartaView and Panoramax Street Level providers, so a host composing
its own provider list can offer the keyless providers without Mapillary
(samehkhamis, #1324).
- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the
token itself (such as a same-origin proxy). Its sequence lookups go there
through the source's transport, and the photo viewer uses MapillaryJS's
Graph provider at that endpoint, so no client token is needed in the page
(samehkhamis, #1324).
+4
View File
@@ -0,0 +1,4 @@
- Add a phone layout for viewports 720px and narrower: the globe stays full
screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one
panel sheet at a time, keeping attribution visible and scene STOP reachable.
Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325).
@@ -0,0 +1,4 @@
- Improve touch selection of aircraft and vessels through the shared contact
picker, reveal tracked aircraft details when opening Context, support downward
swipe dismissal of phone drawers while preserving scrolling and controls, and
enlarge the phone logo (samehkhamis, #1338).
@@ -0,0 +1,3 @@
- Reject impossible KartaView photo timestamps, so 31 February is unknown
instead of rolling forward into March and passing the imagery date filter
(Rudy Celekli, @rudycelekli, #1341).
@@ -0,0 +1,3 @@
- Keep an impossible wildfire acquisition date off the detection clock, so a
fire is not labeled with a later day it did not happen
(Rudy Celekli, @rudycelekli, #1342).
@@ -0,0 +1,3 @@
- Convert NHC cyclone movement speed from miles per hour to knots, so
`movement.speedKt` in cyclone query results is no longer about 15% high
(rudycelekli, #1350).
@@ -0,0 +1,6 @@
- Keep a tracked vessel's speed, course, heading and fix time when AISStream
static data arrives; the static message only merges name, type, destination
and IMO (rudycelekli, #1351).
- Apply changed static metadata immediately, retaining known fields when a
later static report omits them, without refreshing the vessel's position
(samehkhamis, #1351).
@@ -0,0 +1,3 @@
- Read a destination heading that rounds to north as 000. 359.6° was shown as
360° on the destination bearing while the compass tape already uses 000
(Rudy Celekli, @rudycelekli, #1352).
@@ -0,0 +1,3 @@
- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW
detection was labeled 10.0 MW because the one-decimal cutoff ran before
rounding (Rudy Celekli, @rudycelekli, #1354).
+5
View File
@@ -5,6 +5,11 @@ services:
# lands in the bind-mounted repo, so run as you, not root. UID/GID are
# unset on Docker Desktop, where the 1000 fallback is ignored anyway.
user: "${UID:-1000}:${GID:-1000}"
# Forward explicit shell/.env policy before the launcher discovers a gateway.
# Unset values stay unset so the default loopback-only install still works.
environment:
- GEV_KEY_SETUP_TRUSTED_PEERS
- GEV_ALLOWED_HOSTS
ports:
# Publish to loopback only. Docker's default ("4173:4173") would bind
# every host interface, which quietly turns the LAN warning in the README
+43 -7
View File
@@ -79,7 +79,6 @@ moves it. Do not label all ingestion modules platform-independent by filename.
| Retained entry | Owner and current reason |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `src/ui.js` | UI facade; direct callers retain standalone StyleManager defaults. Normal assembly uses `src/ui/composition.js` |
| `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately |
| `src/data/<layer>.js` | Legacy default instances; new assembly calls layer factories with supplied sources |
| `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly |
@@ -87,9 +86,8 @@ moves it. Do not label all ingestion modules platform-independent by filename.
| `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances |
| `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports |
The two exact standalone-import exceptions are `src/ui.js` to
`src/standalone/catalog.js` and `server/providers/local.js` to
`server/standalone/key-setup.js`. They do not authorize new compatibility
The one exact standalone-import exception is `server/providers/local.js` to
`server/standalone/key-setup.js`. It does not authorize new compatibility
back-edges. Voice actions use plain `src/data/feedState.js`, not the manager
facade. Settings filesystem hardening belongs in
`server/standalone/key-setup-hardening.mjs`; its settings policy is unchanged.
@@ -237,7 +235,7 @@ DOM elements and callbacks; importing it creates no browser state. `destroy()`
removes owned listeners and cancels pending work without changing saved state
or moving focus. Call it before removing or replacing the controls.
`src/ui.js` retains panel layout, persistence, share restoration and application
`src/ui/applicationShell.js` retains panel layout, persistence, share restoration and application
reactions to state changes. Map Source selection and Location draft cleanup are
provided through callbacks. The component imports no globe, data, application
or server modules. Package checks and scoped formatting cover this entry.
@@ -374,6 +372,15 @@ renderer, persistence or services. The facade retains panel visibility, share
restore claims and render scheduling. Both controls are destroyed before the
facade's asynchronous teardown can yield.
## Ordinary action buttons
`src/ui/actionButton.js` is a native, listener-free view helper used by Scene
presentation and sharing-toolbar owners. It receives translated text and explicit
presentation/accessibility properties. Feature controllers own action dispatch,
state, persistence and cleanup. Static Scenes/CCTV templates use the same CSS
surface and preserve IDs and layout hooks. The helper imports no application,
renderer, storage or services; it belongs to the consuming package closures.
## Display controls
`ui/display` owns Display button, selector and slider subscriptions. It receives
@@ -461,8 +468,8 @@ release listeners; pending action feedback is limited to its current owner.
## UI assembly and styles
`ui/shell` assembles controls from supplied existing layer, navigation, terrain,
rendering, HUD and share operations. `src/standalone/ui.js` provides the running
application's instances; `src/ui.js` remains the compatibility entry. The shell
rendering, HUD and share operations. `src/standalone/controls.js` provides the running
application's instances. The shell
imports no standalone bootstrap or concrete live layer implementation.
Panel layout, position/drag, notices, recording and deferred UI work have separate
owners with synchronous cleanup. Existing scene, share and HUD engines retain
@@ -729,3 +736,32 @@ sources supply `{records, droppedCount, status, saturated}`; viewport filtering,
exact-bound retry and rendering stay in the layer. Legacy cache saturation is
decoded in the source. Source and geometry modules remain independent of Cesium,
DOM and platform middleware.
### Native form fields
`src/ui/formField.js` constructs native numeric inputs without listeners or
application state. Static selects and number inputs use the same `gev-field`
surface, with explicit compact and inline densities. Feature owners retain
option identity/order, bounds, parsing, draft values, input/change timing and
teardown. CCTV effective-pose conversion and tuner/device operations remain
feature responsibilities. A Vue replacement can reuse this contract and CSS;
it replaces DOM construction, rather than adding a second state owner.
The shared stylesheet owns field surfaces and disabled opacity; feature and theme
palettes use `--gev-field-background`, `--gev-field-border-color`,
`--gev-field-color` and `--gev-field-disabled-opacity`. Radio retains its own
compact density and arrow space. CCTV owns calibration editor width in
`cctv.css`; the shared inline density does not impose a feature-specific width.
Cyber supplies a shared-field palette explicitly and keeps its legacy native
field rules separate, so Radio's palette remains an intentional exception.
### Read-only status badges
`src/ui/statusBadge.js` constructs/updates ordinary badges without listeners,
status inference or live-region policy. It consumes translated text and controlled
visibility; appearance variants share CSS rather than mapping machine states to
universal severity. CCTV owns frame/source and calibration rules and their data
attributes, while Weather owns coverage labels. The keyed rail-card owner keeps
node identity, header focus, disclosure and teardown. The shared adapter never
creates another state store or announces all card updates. A Vue view can replace
this adapter and retain these presentation inputs and styles.
+267 -67
View File
@@ -1,5 +1,34 @@
# God's Eye View Current State
Civil aircraft share one delayed render timestamp per fleet update. Extrapolated
fixes reuse their local coordinate frame until their coordinates change, including
in-place ground-height corrections. Update cadence, contact counts, interpolation,
turn rates and tracked-aircraft motion are unchanged. Cached frames are weakly
keyed by each position fix and do not retain retired history.
Touch-first devices use three times the default Cesium zoom sensitivity, so
pinching traverses globe and street scales with fewer gestures. Cesium retains
its distance-aware zoom, collision handling and minimum/maximum distance limits.
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
Analyst region queries resolve unique Natural Earth names offline without
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
Duplicate names require a geocoded containment anchor; when several entries for
the same name contain that anchor, the lookup stays unresolved rather than
selecting the largest entry. Natural outlines try exact names before aliases and
broader suffix variants. Follow-ups retain boundary provenance, and cancelled
lookups preserve the previous analyst cohort and boundary source. Navigation
search keeps a successful geocode, with no search or view-based region recovery.
Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals.
Terrain height requests accept at most 64 points, matching the browser service's
existing batch size and one bounded upstream chunk. Larger requests return HTTP
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
The browser continues to split larger workloads into sequential 64-point requests.
Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
@@ -21,6 +50,16 @@ time. This clears obsolete contacts and settles startup without manufacturing
position timestamps. Stale, incomplete and malformed empty results retain the
existing recovery behavior.
Every `/api` response on the dev and preview servers carries
`Content-Security-Policy: default-src 'none'; sandbox` and
`X-Content-Type-Options: nosniff`. `apiResponseHeadersPlugin` sets them ahead of
the Host check and matches `/api` the way the router mounts it (any letter case, a
`/` or `.` boundary, absolute-form targets), so errors, partial responses, Host
refusals and unknown routes are covered too; a route's own policy is sent as an
additional policy, never in place of it. The app document's policy is unchanged.
The GBFS proxy relays only bodies that parse as JSON, re-serialized as
`application/json; charset=utf-8`; anything else answers 502.
Catalog source contracts and their consuming layers are declared in
`src/app/sourceComposition.js`. Omitted or null acquisition sources preserve layer
registration and serialization, report unavailable, and are refused by the existing
@@ -251,6 +290,7 @@ catalog construction owns each instance.
Wind animates one forecast without advancing forecast time. Separate Weather
observation layers provide radar and satellite history; none claims measured cloud volume.
Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work.
Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each
observed product is instead a raised, translucent shell: one rectangle over the
product bounds at a fixed height (global infrared 5.5 km, regional infrared
@@ -388,6 +428,8 @@ than relabeling older geometry. The cone represents forecast center uncertainty,
not storm size or the full hazard area. Successful empty and unavailable states
are distinct. One owned native Cesium data source preserves polygon holes and
geographic seams and releases on disable; forecast animation is not implied.
NHC movement speed is converted from miles per hour to rounded knots by the
proxy; advisory wind intensity is already in knots and is passed through.
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
Consult the linked official advisory for safety decisions.
@@ -424,6 +466,14 @@ than a reading. Heading keeps its existing separate check for its own sentinel.
Genuine readings are unchanged, including a stopped vessel's zero and the
highest speed and course the fields can encode.
For an already tracked vessel, AISStream static messages update name, type,
destination and IMO without changing its position, motion, fix timestamps,
position freshness or trail. Non-empty static values replace earlier values;
omitted, empty or unavailable fields retain known metadata across Class B's
separate reports and in the cache used by the next position update. Position
reports still prefer their own metadata name. Invalid Class B parts are ignored;
absent fields prefer the latest live value over the static cache.
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.
The scheme is declared on the controls rather than on the document root, so
@@ -624,6 +674,14 @@ The standalone app offers OpenAI and Gemini Live through an explicit provider ch
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
layer visibility lifecycle. Results distinguish successful changes from
manager-reported failures or cancellation and report enabled state and lifecycle
uncertainty. A caller-supplied abort signal reaches the manager; a late abort
preserves an already committed change. Missing-layer errors and other CCTV
actions retain their existing behavior. The exported `controlCctv` helper refuses direct
enable/disable requests and directs callers to `set_layer_visibility`.
`src/voice/layerManifest.js` declares each catalog layer's voice capabilities: spoken aliases, `toggle`, in-view `context`, `track`, and analyst `query` fields with type and unit, or `noQuery` with the reason. The `set_layer_visibility`, `show_data_layers_menu`, `get_entity_context` and `analyst_query` layer enums, the runner's alias table and the analyst field hints are generated from it. `src/voice/layerManifest.test.mjs` fails when a catalog layer is neither listed nor named in `VOICE_OFF_LAYERS` (today only the two scene-driven Bhote Koshi layers). Global Context is listed with `toggle: false`; `set_context_mode` reaches it. `set_panel_open` also opens `weather-panel` and `recent-imagery-panel`.
Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged.
@@ -795,6 +853,15 @@ standalone entry supplies application-owned scene, ground and activation service
Each layer owns its state and visibility listener; destruction cancels source
reads and pending initialization. Malformed health responses retain prior health.
Existing catalog fallback, camera poses, frame pacing and coverage controls remain.
CCTV frames and the Street View fallback accept only JPEG, PNG, GIF, WebP and
AVIF, sent under the canonical type; any other type falls through to the next
fallback. Live media must match its feed kind (`video/mp4`, `video/webm`, or
`multipart/x-mixed-replace` with a boundary) before health reports it connected,
and follows redirects only on the camera's origin. Catalog image URLs, and
redirects on those hosts, are pinned by parsed URL to the agency's image
directory. Every `/api/cctv` route refuses cross-site browser requests but
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
request skips the key-bearing Street View fallback and gets the placeholder.
Traffic owns its rendered-surface observer only while enabled, removes it on disable/destroy, and caches surface identity without allocating on unchanged frames.
@@ -1162,6 +1229,24 @@ snapshots, lifecycle actions and row descriptors. Remount and teardown remove
listeners and row subscriptions; obsolete completions do not repaint old rows.
The clear control presents busy state while its existing action owns the transaction.
## Sky background
`src/sky/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale
Bright Star Catalogue stars and a soft ESO/S. Brunier Milky Way. Both layers
rotate from ICRF/J2000 into the Earth-fixed frame at the scene clock, with
a TEME fallback while Cesium's IAU 2006 tables load. Bundled assets and
their provenance are documented in `src/data/local_data/sky/README.md`.
Anime fades out the Milky Way with its style intensity to avoid cel-shaded
bands; the catalogue stars remain. A separate black 1×1 cube map preserves
model environment lighting independently of the visible sky. The adapter
is validated against Cesium 1.138.0 and must be revalidated on upgrades.
`scripts/qa-anime-sky.mjs` checks asset readiness, visible Normal sky and
Anime band coverage against a Vite dev server.
`scripts/qa-sky-model-lighting.mjs` compares a lit `airplane.glb` against
black and white environment maps in isolated scenes.
## Model atmosphere on Apple Metal
Cesium's per-vertex model atmosphere is kept out of the pipeline on devices
@@ -1207,6 +1292,84 @@ The application supplies actions and retains effect settings, restore claims and
rendering. Native keyboard editing, model modes and current defaults are preserved.
Destroying the UI removes these listeners before asynchronous teardown.
Ordinary sliders share the native range component in `ui/slider.js` and the
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader
parameters, volume, replay speed, weather settings/history, imagery opacity and
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
Generated controls use `createSlider`; static templates apply the same class.
Feature owners retain bounds, steps, formatted values, input/change timing and
listener cleanup. The radio tuning dial, imagery comparison divider and event
comparison/progression controls keep their specialized presentation and behavior.
## Shared control CSS ownership
Shared styles own ordinary control surfaces and interaction states; feature CSS
owns surrounding layout and context-specific sizing. CCTV's calibration editor
width lives in `ui/styles/cctv.css`, while the shared inline field variant owns
only density and spinner treatment. Radio supplies its field palette and disabled
opacity through the `--gev-field-*` variables, retaining compact typography and
arrow space locally. Cyber supplies shared-field palette variables explicitly;
its legacy native-field palette rules exclude `.gev-field`. These ownership
boundaries preserve the current Tactical/Cyber appearance and native behavior.
## Ordinary status badges
CCTV source/frame labels, calibration provenance and Weather coverage labels use
`ui/statusBadge.js` and the `gev-status-badge` CSS surface. The listener-free
adapter accepts already-translated `label` and `hidden` values; pill, box and plain
appearances describe shape/density only. Static CCTV markup uses the same classes
as generated rail-card badges. Repeated identical updates do not rewrite text or
replace nodes. Empty coverage remains hidden.
Feature owners retain the status rules, machine-state attributes, translations
and announcement policy. CCTV frame acquisition (`idle/loading/error/ready`)
remains separate from provider status: retained pixels do not imply a healthy
feed. Calibration still distinguishes manually saved, catalog-curated and raw
prior poses; unsaved edits override the label until save/reset. Coverage labels
remain geographic scope, not health claims. Existing colors and the source
badge's polite live region are preserved; coverage and calibration do not gain
live regions. Interactive layer toggles, progress indicators and world overlays
retain their specialized ownership. Vue can reuse the view contract and CSS while
replacing DOM construction. Browser acceptance:
`QA_BASE_URL=http://127.0.0.1:4189 node scripts/qa-status-badges.mjs`.
## Ordinary action buttons
Scene panel actions, shot LOAD/DEL actions, the EDIT DETAILS / SHARE SCENE
bar and one-shot CCTV actions use `ui/actionButton.js` and the shared
`gev-action-button` surface in `ui/styles/action-button.css`. Static templates
apply the same classes as generated buttons. The native builder accepts translated
text, default/danger variants, default/compact sizes and disabled/accessibility
properties; it installs no listeners and owns no application state. Feature
controllers retain callbacks, enable/disable rules, confirmation prompts,
persistence and teardown. The smaller shot actions retain their existing sizing
and hover treatment. Feature CSS still owns panel layout and translated wrapping.
CCTV enable, coverage, auto-hop, projection and adjustment toggles, editable
calibration-value chips and the scene authoring dialogs retain their specialized
controls. Existing `scene-btn` hooks remain compatible with Street Level and
legacy toggles. The shared CSS, explicit input/action boundary and behavior tests
can carry to a Vue view while Vue replaces native DOM construction.
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs`;
`qa-scene-controls.mjs` also covers real Scene authoring/playback with native Space capture.
## Segmented selectors
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
their feature-owned values, actions and persistence. Painting a restored value
does not emit a change or move focus. Existing keyboard policies remain explicit:
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
its controlled selection, and Display keeps its existing native button behavior.
Pure value navigation is separate from DOM rendering for reuse by a future view.
Segments keep their word minimum width: compact inline groups such as FIT/FILL
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
the same exclusive-rail hiding rule as the other readouts.
The imagery mode row reserves 26px for the 24px buttons and group borders so
the complete button and inset keyboard-focus outline remain visible.
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`.
## Application shortcuts and shader parameter controls
`ui/input` supplies the bubbling application shortcut listener and generated
@@ -1241,6 +1404,47 @@ The right rail skips panels hidden by other CSS (computed `display: none`), but
keeps the collapsed panels its own exclusive presentation hides, so automatic
collapse can still be undone.
## Phone layout
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),
Cameras (CCTV and Street Level), Context (Global Context, plus Weather and
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
or Escape closes it, and focus moves into the sheet and back to its tab. A
downward swipe of at least 64px on non-control sheet content also closes it,
when every scroll container under the finger starts at the top. Ordinary
vertical scrolling, horizontal gestures, controls and multitouch stay native;
a cancelled gesture or a sheet/viewport change cannot dismiss another sheet. Panels
are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock
as the sheet, and the dock's Location and Presets trays render inline. The voice
control floats above the tab bar while no sheet is open.
The phone logo is 58px wide (about 39px high), aligned with the top controls.
The shared contact picker preserves exact picks. For a coarse primary pointer
that misses onto the map surface, it searches at most eight hits in a 14px square
and accepts the first registered layer hit, stopping at non-map objects. Civil
and military aircraft and vessels share this rule; fine pointers stay exact.
Manually opening Context with a tracked civilian or military aircraft enters
the existing Contacts mode when Context is off. Contacts adopts that aircraft
without restarting its tracking; an active or entering Space Missions mode is
left alone. Programmatic sheet changes and closing the tab do not activate a mode.
Sheets borrow panels. Opening one expands its panels without persisting them or
claiming a user choice, and share links keep reporting each borrowed panel's
state from before the sheet; closing puts back what opening changed, while a
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
not apply inside a sheet.
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
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
voice control, and above an open sheet. The page draws under the notch and
system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets.
Wider viewports are unchanged, and crossing to them closes any open sheet.
## Surface keyboard lifecycle
Surface Tab cycling excludes controls with a negative tabindex, native disabled
@@ -1727,6 +1931,10 @@ Updated: September 23, 2026
## Aircraft and vessel server modules
OpenSky credential-file failures emit a fixed warning once per process; neither
JSON parser excerpts nor filesystem error messages are logged. The environment
example uses an absolute path compatible with the macOS launchers.
Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky
state vectors and regional fallback, adsb.lol military positions, ADSBDB
enrichment and track backfill. Vessel routes and websocket/watchdog setup live
@@ -2756,11 +2964,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> first later successful fetch. Smaller chunks reduce timeout risk; complete
> camera batches still wait for their sequential requests, and unresolved
> placement continues to use the existing prior until real heights arrive.
> - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests,
> then checks fresh memory, identical in-flight work, and fresh disk entries
> before invoking its local 90/min limiter. Cache and single-flight responses
> therefore do not spend quota; upstream-bound misses retain the existing
> limiter, configured-upstream, stale, and sanitization behavior. An unconfigured transport serves dated stale data or a non-retryable capability error.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
> POSTs through the shared local-request gate, then limits requests to 300/min
> per socket peer and 900/min globally before reading or validating bodies.
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
> It then checks fresh memory, identical in-flight work, and fresh disk entries
> before invoking its separate local 90/min upstream limiter. Cache and
> single-flight responses therefore do not spend upstream quota; upstream-bound
> misses retain the existing limiter, configured-upstream, stale, and sanitization
> behavior. An unconfigured transport serves dated stale data or a non-retryable
> capability error.
> - **CCTV world-click focus:** clicking an in-world CCTV icon or ambient card
> activates it and routes the camera flight through the panel FOCUS policy.
> Aircraft/satellite tracking releases outside cockpit; cockpit retains the
@@ -3401,7 +3614,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives in
Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
@@ -3412,7 +3625,8 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
value). English has no catalog; every other locale is
`src/i18n/locales/<code>.json`, and a missing entry falls back to English.
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
`navigator.languages` → English. `?lang=` is not stored, and share links drop
`navigator.languages` → English. Chinese tags map to `zh-CN`; tags containing
`Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop
it, so a recipient keeps their own language. The globe picker in the
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
`?lang=` (keeps it only when storage is blocked) and reloads. It asks first
@@ -3451,7 +3665,7 @@ Historical planning documents may not match runtime behavior.
## Runtime Stack
- Vite + CesiumJS app with Google Photorealistic 3D Tiles
- Scene/HUD/style systems in `src/ui.js` and `src/hud.js`
- Scene/HUD/style systems in `src/ui/` and `src/hud.js`
- Layer management in `src/data/manager.js`
- Map stack switching in `src/mapStackController.js`
- Voice control in `src/voice/` (OpenAI Realtime over WebRTC)
@@ -3483,8 +3697,8 @@ its criteria cannot be silently ignored.
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
@@ -3668,8 +3882,8 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
`fieldOfView`) and upload date and checks each answer the same way (when
photos are near but none passes, the panel says so), and the cones pass on
each photo's projection and upload date. The provider declares
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
`sinceBasis: 'upload'`, so while it is lit the panel shows "(by
upload date)" beside the SINCE value, as ANY DATE (by upload date) (`sinceNote` in the UI state). Tiles come uncompressed and
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
KartaView is lit (`createTileCache`, dropped when it is switched off); one
that does not answer in 20 s is asked once more, and one that is not a line
@@ -4022,6 +4236,8 @@ animation. Polls, surface completions and trail fades request individual renders
Disable clears vehicles, detection references, requests, timers and holds;
destruction also releases collections and registrations.
The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped.
**Acceptance harness.** Run against an already running development server:
```sh
@@ -4438,7 +4654,7 @@ silently demoting every later lookup for the session.
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW).
- **CCTV v2 foundation:** a pitched
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
@@ -4541,7 +4757,7 @@ silently demoting every later lookup for the session.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
### Optional Overpass configuration
@@ -4576,6 +4792,12 @@ Retry-After; absent delays back off from 30 seconds to five minutes. Empty
remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized.
Consumer capability misses never schedule automatic retries.
Keyless browser validation also probes the optional Google tiles-token route.
The token client cancels rejected response bodies before returning unavailable,
so they do not remain pending in browser network observation. The Overpass gate
accepts only that same-origin route's expected 404 console diagnostic; other
HTTP and JavaScript errors still fail acceptance.
Operator-configured Overpass area and focus-footprint queries use body-level
`out geom` (with `center` for focus), preserving relation member outlines.
Member geometry is printed only inside a box around the point (6 km for
@@ -4793,7 +5015,7 @@ and unreachable upstream (502/504) separately from road geometry.
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
@@ -5014,7 +5236,7 @@ pins and share links already supply complete boxes.
- **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing.
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
- Civilian and military 2D aircraft use the established distance scale: `3×` near
the camera and a `0.5×` floor from 8,000 km outward. A standard 20 px ambient
@@ -5024,7 +5246,7 @@ pins and share links already supply complete boxes.
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
### TR-3B conversion Easter egg (August 2026)
@@ -5099,7 +5321,7 @@ pins and share links already supply complete boxes.
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail).
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
@@ -5186,7 +5408,7 @@ pins and share links already supply complete boxes.
- **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 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 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; 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`, `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 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 trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
@@ -5223,7 +5445,7 @@ demand with square-root demand scaling and semantic layer weights
(`labelArbiter.js:181`). First-run default: ELASTIC.
**A default has THREE surfaces, and a PARSE fallback that is not one of them.**
The value literals (engine constant, markup value, markup readout, `ui.js`
The value literals (engine constant, markup value, markup readout, `src/ui/visualPresets.js`
`GLOBAL_POST_DEFAULTS`, the share generator's starting state) must all move
together, because a fresh boot runs no restore and those literals ARE the startup
state. The `scf` / `ko` PARSE fallbacks deliberately do NOT move: they answer
@@ -5271,7 +5493,7 @@ easier to meet (detection is now on more often), but does not create it.
- **Detection OUTSIDE opacity: `1%`** (moved `5% → 3% → 1%` on 2026-08-24).
`KEYHOLE_OUTSIDE_OPACITY_DEFAULT` in `src/celestialRing.js`, mirrored by
`#detection-opacity-slider`'s markup value AND readout,
`GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in `ui.js`, and
`GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in `src/ui/visualPresets.js`, and
`_detectionOutsideOpacityPct` in `sharelink.js`. The slider's `step` is now
`1`, so 1–4 % are reachable at all (at the previous step of 5 the entire
sub-default range was one stop wide). `AIRCRAFT_BRACKET_FLOOR_ANCHOR` in
@@ -5314,6 +5536,11 @@ easier to meet (detection is now on more often), but does not create it.
short-way-round longitude span and wraps the padded edges, so a dateline-
crossing box stays its true width. Raw subtraction inflated a 0.41° metro box
to 86.7° and a 60° territory to 132°.
- **Point distances take the short way round 180°.** The annotation
resolver's `approximateDistanceM` (voice "point out" proximity checks) and
`places_nearby` distances wrap the longitude difference. Raw subtraction put
Savusavu 38,148 km from a camera over the Lau Islands, so the proximity check
refused it, and sorted a place 2 km across 180° last.
- CCTV calibration persists at `godsEyeView.cctv.calibration.v2` (v2 rebuild;
the store was wiped clean, no import from the old `v1` key).
- CCTV v3 floor QA pins are: zero samples for heading-only edits; zero transient
@@ -5689,46 +5916,6 @@ on purpose:
Refused fixes are counted (`positionsRejected` in the SDR state; the layer
applies the same check to merged feed records and reports the total as
`rejectedPositions` in its stats). Three refusals in a row re-anchor.
- **Altitude filter.** A corrupt frame can pass CRC, and a decoder feed can
relay one: a DC-9 was logged at 108,800 ft. The browser decoder cannot
produce that value, since it decodes only 25 ft (Q=1) altitudes and those
stop at 50,175 ft, so the check runs in the layer where both paths merge
(`gateLocalAdsbAltitude` in `src/sources/adsbRecords.js`). Each new report
must be reachable from the last accepted altitude at 1.5 × the larger
reported vertical rate + 2,000 ft/min (12,500 ft/min without a rate) over
the elapsed time + 1 s, plus 300 ft. A refused report holds the last
accepted altitude for the marker height, the card, the model ceiling and
route plausibility alike, and is counted as `rejectedAltitudes` in the
layer stats. Browser tracks carry the altitude frame's own receive time,
sequence and track start: identification/velocity messages and renderer
re-reads never refresh an accepted altitude or recount a refusal. Equal
altitudes in distinct frames are real observations, including frames from
one USB block with the same timestamp. Three observed refusals in a row
re-anchor; a sequence gap counts as only one observed report.
Decoder feeds expose no altitude-specific measurement age. A changed value
is judged once and keeps its receipt-time reference through unrelated
messages. Feed allowance is capped at a 60 s relative gap, since fresh
positions can keep an old altitude alive indefinitely. Recovery from an
implausible feed value requires a coherent candidate stream for at least
5 s and three advancing source snapshots with increasing aircraft message
counts from one receiver. Cached snapshots and transport jitter cannot
supply that progress. Each receiver keeps separate recovery progress when
the freshest position alternates between inputs; a new accepted altitude
resets pending recovery against the old reference. This is
an inference: a stable corrupt feed can also satisfy it. Without source
clock/count/identity metadata, automatic feed recovery is disabled, so a
held altitude can persist while positions remain fresh. A surface report
clears the airborne reference, because a feed's `ground` is 0 and not a
barometric reading. The next airborne value seeds a new reference, just
like the first observation of a new aircraft. There is no absolute ceiling:
balloons really do fly above 100,000 ft.
Accepted/held records carry `altitudeQuality` (`accepted`,
`held-after-rejection`, `feed-estimate` or `inferred-reanchor`),
`altitudeObservedAt` (the actual browser measurement time, null for feeds),
and `altitudeSource` (the accepted value's source, which can differ from the
position source). These accompany altitude through context metadata and
final analyst items, including altitude-only queries. Missing/surface
altitudes carry no airborne quality claim.
- **Trail.** A selected aircraft draws a magenta trail of the positions the
receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft),
with the tracked-flight trail look and a live head segment. No network
@@ -5747,12 +5934,10 @@ aircraft join detection boxes, labelled only with a decoded callsign.
Receivers publish one record shape from `src/sources/adsbRecords.js`
(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`,
`altitudeObservedAt`, browser `altitudeSequence`/`altitudeSessionAt`, feed
`feedId`/`feedSnapshotAt`,
`groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`,
`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is
`webusb` (browser SDR) or `feed` (decoder feed).
`normalizeDump1090Aircraft(json, nowMs, { band, feedId })` maps a dump1090/readsb/
`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/
skyaware978 `aircraft.json` document into the same records. Voice
`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver",
"my antenna").
@@ -5777,8 +5962,6 @@ second only while the layer is enabled and only while the route reports
`configured`; the Local RTL-SDR card makes one probe request at startup to show
its read-only "Decoder feeds: 1090 live · 978 live" line. Feed records are
rebased to the browser clock and kept up to 60 s after their last message.
The opaque configured `feedId` and raw `feedSnapshotAt` source clock are
preserved without rebasing; neither exposes the receiver URL.
`mergeLocalAdsbRecords` merges both inputs by ICAO, keeping the newest position
(tie: newest message) and the bands/sources heard in the last 60 s; the card's
receiver line names them ("Heard by your receiver · 1090 MHz + 978 MHz UAT ·
@@ -5854,7 +6037,7 @@ closed. Performance and live agency reliability require recorded validation.
## Wind forecast models
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results.
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. Last-good data is marked stale; the wind row shows it, and `get_wind` returns `stale` and adds "(forecast may be stale)" to its summary.
### Local Weather review candidate
@@ -5939,3 +6122,20 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye
Military fragments stitch by source identity. Different mapped parcels merge only
when their footprints touch or contain one another, never across a seam tolerance gap.
### Shared native dropdowns and numeric fields
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
draw colour retain compact density. Radio filter, tuner gain and FM frequency
use the same base while retaining their feature-specific skins and layout.
- The CCTV inline number editor uses `createNumberInput` with its existing
precision and inline density. Enter/blur commit, Escape cancellation, frozen
camera base, camera-switch guards and disposal remain with CCTV controls.
- Options, values, native keyboard/typeahead, disabled attributes and browser
validity remain native. Frequency bounds, tuner commit timing and calibration
parsing are unchanged. Specialized tuner graphics and calibration chips
retain their own controls. Provider settings and scene-authoring dialogs are
outside this change.
- `scripts/qa-form-fields.mjs` exercises native selections, number validity,
calibration commit/cancel and stale-camera/disposal guards against an installed
controller with fixture ports, plus default/Cyber and narrow translated layouts.
-20
View File
@@ -18,26 +18,6 @@ smoothly between fixes, while local receiver aircraft are shown as heard. An
aircraft in both layers therefore draws twice, the magenta local marker ahead
of the public one; both markers are drawn on purpose.
Airborne altitude changes are checked against the last accepted value and a
vertical-rate allowance. A refused value holds the accepted altitude for the
marker, card and model. Browser SDR recovery counts actual altitude frames;
identification and velocity messages cannot make a held altitude look new.
Decoder feeds have no altitude measurement timestamp. Their changed values
are estimates; recovery after a refusal requires at least five seconds and
three advancing source snapshots with increasing aircraft message counts
from the same receiver, with coherent altitude changes. Repeated cached
snapshots and transport delays do not count. This recovery is inferred, so a
stable corrupt feed can still qualify. Feeds missing progression metadata
cannot automatically recover a held value, and fresh positions can keep that
held altitude alive. The rate check caps its elapsed allowance at 60 seconds
for feeds; there is no absolute altitude ceiling.
Context and analyst records include `altitudeQuality`, `altitudeObservedAt`
and `altitudeSource`. Held values retain the accepted value's source and
measurement time; feed measurement times are null. Ground reports clear the
airborne reference and its quality metadata.
## Which to use
- **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR.
+2 -2
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`).
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`).
## How it works
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## 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). 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`: [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.
+15 -9
View File
@@ -3,10 +3,11 @@
God's Eye View uses explicit auth modes for `/api/flights`:
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
- `OPENSKY_AUTH_MODE=basic`
- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous)
- `OPENSKY_AUTH_MODE=anon`
`OPENSKY_AUTH_MODE=basic` is retired (see below).
Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow.
## Quick Start (OAuth Client JSON)
@@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order:
2. `OPENSKY_CREDENTIALS_FILE`
3. Keychain (`opensky-network` / `client_id`, `client_secret`)
## Basic Auth (Legacy)
The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and
Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any
platform. It reads the file on each token refresh.
Basic auth is still supported for now, but OpenSky is moving away from it.
## Basic Auth (Retired)
```bash
OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
```
OpenSky stopped accepting username/password (HTTP Basic) sign-in on
2026-03-18; its API now takes only OAuth2 client credentials. With
`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` /
`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and
logs once what to set instead. Create an API client on your OpenSky
account page and use its `credentials.json` as above.
## Troubleshooting
@@ -61,6 +67,6 @@ Useful reasons:
- `oauth_invalid_or_missing`: OAuth client not found/usable
- `oauth_invalid_credentials`: OAuth client rejected by OpenSky
- `basic_invalid_credentials`: username/password rejected
- `missing_basic_creds`: basic mode missing user/pass
- `missing_oauth_creds`: auto mode found no OAuth client
- `basic_retired`: basic mode requested; answered anonymously
- `rate_limited`: OpenSky throttling
+77
View File
@@ -0,0 +1,77 @@
# Simplified Chinese (`zh-CN`)
Catalog: `src/i18n/locales/zh-CN.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979).
## Style
- Mainland usage and Simplified characters. Address the user neutrally, without 您/你 where a plain imperative works: 「点击地图」, 「重试」.
- Chinese has no case: an UPPERCASE English HUD label becomes the same short Chinese label. Keep labels as short as the English: 「实时」, 「过期」, 「关」.
- Full-width punctuation in Chinese text: ,。:;?!()“”. A dash is 「——」 without spaces; keep `·` as the HUD separator and `…` for ellipsis.
- One space between Chinese and Latin letters or digits: 「AIS 船舶」, 「24 小时」, 「{count} 个」. No space next to full-width punctuation.
- Units: km, m, km/h, m/s, kn, ft, MW, °, % stay as written; time words are 小时, 分钟, 秒 after a number (「5 分钟前」).
- Product names, provider names and codes stay as they are: Google, Cesium, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, CONUS.
## Plurals
`Intl.PluralRules('zh-CN')` has one category, `other`: a `tn()` entry has only that form. Use a measure word next to the number where Chinese needs one: 「{count} 个密钥」, 「{count} 架飞机」, 「{count} 艘船」.
## Glossary
| English | 中文 |
| ------------------------------------- | --------------------------- |
| layer / data layer | 图层 / 数据图层 |
| feed | 数据源 |
| source / data source | 来源 / 数据源 |
| LIVE (status) / live data | 实时 / 实时数据 |
| SIMULATED | 模拟 |
| stale / degraded / unavailable | 过期 / 降级 / 不可用 |
| retry | 重试 |
| key (API key) / POWER UP | 密钥(API 密钥)/ 增强 |
| Provider Settings | 服务商设置 |
| globe / map / map source / basemap | 地球 / 地图 / 地图源 / 底图 |
| aircraft / flight / military flights | 飞机 / 航班 / 军用航班 |
| vessel, ship | 船舶 |
| satellite | 卫星 |
| launch (rocket) / space mission | 发射 / 太空任务 |
| contact (tracked object) | 目标 |
| installation, site | 设施 |
| camera / CCTV camera | 摄像头 / CCTV 摄像头 |
| earthquake / active fire | 地震 / 活跃火点 |
| scene / shot | 场景 / 镜头 |
| briefing / panel / dock | 简报 / 面板 / 停靠栏 |
| preset / visual preset | 预设 / 视觉预设 |
| Display / HUD layout | 显示 / HUD 布局 |
| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 |
| Scope (circular viewport mask) / keyhole | 视域 |
| Clean UI | 纯净界面 |
| cockpit | 座舱 |
| heading / altitude / ground speed | 航向 / 高度 / 地速 |
| map heading / North up | 方位 / 正北朝上 |
| Street Level / Street View (Google) | 街景 / 街景(Google) |
| Recent Imagery | 近期影像 |
| Global Context / Context (panel) | 全局态势 / 态势 |
| radio (station) | 电台 |
| transit / bikeshare | 公共交通 / 共享单车 |
| Previous / Next | 上一个 / 下一个 |
| compass points (N, NNE, …) | 北, 北东北, … |
| weather field / FIELD (selector) | 气象场 / 要素 |
| weather model / forecast lead hours | 模式 / 预报时效 |
| valid (forecast time) | 有效时间 |
| scene cloud / mosaic | 整景云量 / 镶嵌图 |
| pin (Recent Imagery) | 固定 |
| THIS (voice pointer) | 所指 |
| outline / trace an outline | 轮廓 / 绘制轮廓 |
| mark (map annotation) | 标注 |
| camera badge | 摄像头标记 |
| provider / embedded provider controls | 服务商 / 平台控件 |
| share (a view or link) | 分享 |
| AI agent | AI 智能体 |
| stage (rocket) / downrange | 子级 / 航程 |
| capture shot / camera move | 保存镜头 / 运镜 |
| story beat / evidence beat | 事件节点 / 证据节点 |
| camera pose | 位姿 |
| forecast uncertainty cone | 预报不确定范围 |
| seed (catalog data) | 预置 |
| frame (bring into view) | 将…纳入视野 |
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers_clear,light_mode,local_fire_department,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,skip_next,skip_previous,upload_file,view_in_ar,visibility,visibility_off" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<!-- gev:template scene-chrome -->
+2
View File
@@ -272,6 +272,8 @@
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js",
"./layers/street-level/kartaview": "./src/layers/streetLevel/providers/kartaview/index.js",
"./layers/street-level/panoramax": "./src/layers/streetLevel/providers/panoramax/index.js",
"./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js",
-1
View File
@@ -8,7 +8,6 @@ under the license listed below.
|---|---|---|---|---|
| `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. |
| `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. |
| `ship.glb` | “Low Poly Cargo Ship” by [Javier_Fernandez](https://sketchfab.com/Javier.Fernandez) | [Sketchfab model](https://sketchfab.com/3d-models/low-poly-cargo-ship-4c22cbaf01c1427f8ab60b3a07b1b32c) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized and repackaged as a glTF binary for the project's vessel visualization. |
| `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
| `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
| `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
/**
* Build src/data/local_data/sky/bright_stars.json from the Yale Bright Star
* Catalogue, 5th Revised Edition (Hoffleit & Warren 1991).
*
* Source: CDS VizieR catalogue V/50, https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50
* Fetched: https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz
* Format: fixed-width ASCII, 197 bytes per record, byte layout in
* https://cdsarc.cds.unistra.fr/ftp/V/50/ReadMe
* License: public domain (Yale University Observatory / NASA ADC); CDS asks
* for an acknowledgement, which the Data attribution popover carries.
*
* Transform (deterministic):
* 1. J2000 RA/Dec (bytes 76-90), V magnitude (103-107) and B−V (110-114).
* 2. The 14 records without a J2000 position (novae and other objects later
* found not to be stars) are dropped.
* 3. RA/Dec rounded to 3 decimals of a degree (3.6″ — a fraction of a pixel
* at any field of view the globe uses), magnitudes to 2.
* 4. Missing B−V is stored as null; the renderer gives it a neutral colour.
* 5. Records keep catalogue (HR) order for a stable diff.
*
* Usage:
* node scripts/build-bright-stars.mjs [catalog | catalog.gz]
* With no argument it downloads the catalogue from CDS.
*/
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
const SOURCE_URL = 'https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz';
const OUT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'src',
'data',
'local_data',
'sky',
'bright_stars.json',
);
/** Slice 1-based inclusive byte columns the way the CDS ReadMe numbers them. */
function columns(line, from, to) {
return line.slice(from - 1, to).trim();
}
/** Parse a fixed-width float; blank fields are null. */
function parseField(text) {
if (text === '') return null;
const value = Number(text);
return Number.isFinite(value) ? value : null;
}
/**
* Parse one BSC5 record. Returns null for records without a J2000 position
* or V magnitude.
*/
export function parseBscRecord(line) {
const hr = parseField(columns(line, 1, 4));
const raH = parseField(columns(line, 76, 77));
const raM = parseField(columns(line, 78, 79));
const raS = parseField(columns(line, 80, 83));
const sign = columns(line, 84, 84);
const deD = parseField(columns(line, 85, 86));
const deM = parseField(columns(line, 87, 88));
const deS = parseField(columns(line, 89, 90));
const vmag = parseField(columns(line, 103, 107));
const bv = parseField(columns(line, 110, 114));
if (
hr === null ||
raH === null ||
raM === null ||
raS === null ||
deD === null ||
deM === null ||
deS === null ||
vmag === null ||
(sign !== '+' && sign !== '-')
)
return null;
const raDeg = (raH + raM / 60 + raS / 3600) * 15;
const decDeg = (sign === '-' ? -1 : 1) * (deD + deM / 60 + deS / 3600);
return { hr, raDeg, decDeg, vmag, bv };
}
const round = (value, decimals) => {
const scale = 10 ** decimals;
return Math.round(value * scale) / scale;
};
/** Build the committed pack from the raw catalogue text. */
export function buildStarPack(text, { fetched = null } = {}) {
const stars = [];
let dropped = 0;
for (const line of text.split('\n')) {
if (line.trim() === '') continue;
const record = parseBscRecord(line);
if (!record) {
dropped += 1;
continue;
}
stars.push(
round(record.raDeg, 3),
round(record.decDeg, 3),
round(record.vmag, 2),
record.bv === null ? null : round(record.bv, 2),
);
}
return {
meta: {
source:
'Yale Bright Star Catalogue, 5th Revised Ed. (Hoffleit & Warren 1991), CDS V/50',
url: SOURCE_URL,
license: 'public domain',
fetched,
frame: 'ICRF/J2000, epoch 2000.0',
columns: ['raDeg', 'decDeg', 'vmag', 'bv'],
count: stars.length / 4,
dropped,
},
stars,
};
}
async function readCatalogue(file) {
if (!file) {
const response = await fetch(SOURCE_URL);
if (!response.ok) throw new Error(`${SOURCE_URL}: HTTP ${response.status}`);
return zlib
.gunzipSync(Buffer.from(await response.arrayBuffer()))
.toString('latin1');
}
const bytes = fs.readFileSync(file);
return (file.endsWith('.gz') ? zlib.gunzipSync(bytes) : bytes).toString(
'latin1',
);
}
async function main() {
const [file] = process.argv.slice(2);
const text = await readCatalogue(file);
const pack = buildStarPack(text, {
fetched: new Date().toISOString().slice(0, 10),
});
// One star per line keeps the JSON compact and the diff readable.
const rows = [];
for (let i = 0; i < pack.stars.length; i += 4)
rows.push(JSON.stringify(pack.stars.slice(i, i + 4)).slice(1, -1));
const body = `{"meta":${JSON.stringify(pack.meta)},\n"stars":[\n${rows.join(',\n')}\n]}\n`;
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, body);
console.log(
`wrote ${pack.meta.count} stars (${pack.meta.dropped} dropped) → ${path.relative(process.cwd(), OUT)} (${body.length} bytes)`,
);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env node
/**
* Build the soft Milky Way cube map in src/data/local_data/sky/ from the ESO
* all-sky Milky Way panorama.
*
* Source page: https://www.eso.org/public/images/eso0932a/
* Fetched: https://cdn.eso.org/images/original/eso0932a.tif
* (6000×3000 8-bit sRGB TIFF, 27.7 MB; sha256 in README.md)
* Credit: ESO/S. Brunier
* License: CC BY 4.0 (https://www.eso.org/public/outreach/copyright/).
* Modified: stars removed, blurred, reprojected to ICRF cube
* faces and tone-mapped, as below.
* Frame: plate carrée in galactic coordinates, centred on the Galactic
* Centre, galactic longitude increasing to the left.
*
* Transform (deterministic):
* 1. Resample to WORK_WIDTH×WORK_WIDTH/2, sRGB to linear light.
* 2. Remove stars with a morphological opening: the renderer draws
* catalogue stars itself, sharp and in the right place.
* 3. Paint out the planets the 2008–2010 exposures caught (PLANETS).
* 4. Blur, round on the sphere: σ = BLUR_DEG, widening to FAINT_BLUR_DEG
* in the faint glow so unresolved stars leave no clumps.
* 5. Resample into six WebGL cube faces (uploaded without flipY) with the
* ICRF axes: +X to the March equinox, +Z to the north celestial pole.
* 6. Median sky to black with a soft knee, brightest 0.01 % to ~97 %
* white, on luminance to keep the hue; gamma 1/2.2, dither, WebP.
*
* Usage:
* node scripts/build-milky-way.mjs <eso0932a.tif>
* Needs the sharp dev dependency.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const FACE_SIZE = 1024;
export const BLUR_DEG = 0.4;
// Faint glow is grain of unresolved stars and needs the wide blur; bright
// parts keep the fine one and their dust lanes. FAINT_RANGE (fraction of
// black-to-white) is where the two blend.
export const FAINT_BLUR_DEG = 2;
const FAINT_RANGE = [0.005, 0.08];
const WORK_WIDTH = 4096;
const OPEN_RADIUS = 3;
const WEBP_QUALITY = 96;
// The median sky is black: above it the glow away from the plane reads as
// grey blotches. SOFT_KNEE eases the cut in so it draws no edges.
const BLACK_PERCENTILE = 0.5;
const SOFT_KNEE = 0.03;
const WHITE_PERCENTILE = 0.9999;
const OUT_DIR = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'src',
'data',
'local_data',
'sky',
);
/**
* Bright planets in the panorama, galactic longitude/latitude in degrees and
* the radius painted over with the surrounding sky. By position both are
* Jupiter, in Sagittarius (2008) and Aquarius (2010); no catalogue star lies
* under either.
*/
export const PLANETS = Object.freeze([
{ l: 13.4, b: -16.6, radiusDeg: 2.5 },
{ l: 69.0, b: -58.4, radiusDeg: 2.5 },
]);
/** Cube faces in WebGL upload order. */
export const CUBE_FACES = Object.freeze([
'positiveX',
'negativeX',
'positiveY',
'negativeY',
'positiveZ',
'negativeZ',
]);
export const FACE_FILE_SUFFIX = Object.freeze({
positiveX: 'px',
negativeX: 'nx',
positiveY: 'py',
negativeY: 'ny',
positiveZ: 'pz',
negativeZ: 'nz',
});
/**
* ICRF (J2000 equatorial) → galactic rotation, rows = galactic axes
* (Hipparcos catalogue, ESA SP-1200 vol. 1 §1.5.3).
*/
export const ICRF_TO_GALACTIC = Object.freeze([
[-0.0548755604162154, -0.873437090234885, -0.4838350155487132],
[0.4941094278755837, -0.4448296299600112, 0.7469822444972189],
[-0.867666149019004, -0.1980763734312015, 0.4559837761750669],
]);
/**
* Direction (not normalized) that the WebGL cube-map lookup sends to texel
* coordinate (s, t) ∈ [0, 1]² of `face`, t = 0 being the first image row.
* Inverse of the OpenGL ES 3.0 §3.8.10 major-axis table.
*/
export function cubeFaceDirection(face, s, t) {
const sc = 2 * s - 1;
const tc = 2 * t - 1;
switch (face) {
case 'positiveX':
return [1, -tc, -sc];
case 'negativeX':
return [-1, -tc, sc];
case 'positiveY':
return [sc, 1, tc];
case 'negativeY':
return [sc, -1, -tc];
case 'positiveZ':
return [sc, -tc, 1];
case 'negativeZ':
return [-sc, -tc, -1];
default:
throw new RangeError(`Unknown cube face ${face}`);
}
}
/** Rotate an ICRF direction into galactic coordinates. */
export function icrfToGalactic([x, y, z]) {
return ICRF_TO_GALACTIC.map((row) => row[0] * x + row[1] * y + row[2] * z);
}
/**
* Map a direction to (u, v) ∈ [0, 1)² of a plate carrée centred on
* longitude 0 with longitude increasing to the left and v = 0 at +90°.
*/
export function directionToPlateCarree(x, y, z) {
const longitude = Math.atan2(y, x);
const latitude = Math.atan2(z, Math.hypot(x, y));
let u = 0.5 - longitude / (2 * Math.PI);
u -= Math.floor(u);
return [u, 0.5 - latitude / Math.PI];
}
/** Box widths whose three passes approximate a Gaussian of `sigma` (Kovesi). */
function boxRadii(sigma) {
const ideal = Math.sqrt((12 * sigma * sigma) / 3 + 1);
let lower = Math.floor(ideal);
if (lower % 2 === 0) lower -= 1;
const upper = lower + 2;
const m = Math.round(
(12 * sigma * sigma - 3 * lower * lower - 12 * lower - 9) /
(-4 * lower - 4),
);
return [0, 1, 2].map((i) => ((i < m ? lower : upper) - 1) / 2);
}
/** Index along a line, wrapped or clamped at the ends. */
function lineIndex(i, length, wrap) {
if (wrap) return ((i % length) + length) % length;
return i < 0 ? 0 : i >= length ? length - 1 : i;
}
/** Running-sum box blur of one strided line. */
function boxLine(src, dst, offset, stride, length, radius, wrap) {
if (radius < 1) {
for (let i = 0; i < length; i++)
dst[offset + i * stride] = src[offset + i * stride];
return;
}
const r = wrap ? Math.min(radius, Math.floor((length - 1) / 2)) : radius;
const at = (i) => src[offset + lineIndex(i, length, wrap) * stride];
let sum = 0;
for (let i = -r; i <= r; i++) sum += at(i);
const scale = 1 / (2 * r + 1);
for (let i = 0; i < length; i++) {
dst[offset + i * stride] = sum * scale;
sum += at(i + r + 1) - at(i - r);
}
}
/** Minimum or maximum over a (2r+1) window of one strided line. */
function extremumLine(src, dst, offset, stride, length, radius, wrap, pick) {
for (let i = 0; i < length; i++) {
let value = src[offset + lineIndex(i - radius, length, wrap) * stride];
for (let k = -radius + 1; k <= radius; k++)
value = pick(
value,
src[offset + lineIndex(i + k, length, wrap) * stride],
);
dst[offset + i * stride] = value;
}
}
/** Separable minimum or maximum filter over a plate-carrée plane, in place. */
function extremumPlane(plane, width, height, radius, pick) {
const scratch = new Float32Array(plane.length);
for (let y = 0; y < height; y++)
extremumLine(plane, scratch, y * width, 1, width, radius, true, pick);
for (let x = 0; x < width; x++)
extremumLine(scratch, plane, x, width, height, radius, false, pick);
}
/** Blur one plate-carrée channel in place, round on the sphere. */
function blurPlane(plane, width, height, sigmaPx) {
const scratch = new Float32Array(plane.length);
for (let y = 0; y < height; y++) {
const latitude = (0.5 - (y + 0.5) / height) * Math.PI;
const sigmaX = Math.min(
width / 4,
sigmaPx / Math.max(Math.cos(latitude), 1e-3),
);
for (const radius of boxRadii(sigmaX)) {
boxLine(plane, scratch, y * width, 1, width, Math.round(radius), true);
plane.set(scratch.subarray(y * width, (y + 1) * width), y * width);
}
}
for (const radius of boxRadii(sigmaPx)) {
for (let x = 0; x < width; x++)
boxLine(plane, scratch, x, width, height, Math.round(radius), false);
plane.set(scratch);
}
}
/** Unit vector for galactic longitude and latitude in degrees. */
function galacticVector(l, b) {
const lr = (l * Math.PI) / 180;
const br = (b * Math.PI) / 180;
return [
Math.cos(br) * Math.cos(lr),
Math.cos(br) * Math.sin(lr),
Math.sin(br),
];
}
/**
* Replace a disc of the sky with the median of the ring just outside it, per
* channel. A median, not a mean: the planet's diffraction spikes reach far
* past the disc and would lift a mean well above the sky. The blur that
* follows hides the seam.
*/
function paintOut(planes, width, height, { l, b, radiusDeg }) {
const centre = galacticVector(l, b);
const inner = Math.cos((radiusDeg * Math.PI) / 180);
const outer = Math.cos(((radiusDeg + 1) * Math.PI) / 180);
const disc = [];
const ring = [];
const span = Math.ceil(((radiusDeg + 1) / 180) * height);
const row = Math.round((0.5 - b / 180) * height);
for (let y = Math.max(0, row - span); y < Math.min(height, row + span); y++) {
const latitude = 90 - ((y + 0.5) * 180) / height;
for (let x = 0; x < width; x++) {
const longitude = (0.5 - (x + 0.5) / width) * 360;
const p = galacticVector(longitude, latitude);
const cos = p[0] * centre[0] + p[1] * centre[1] + p[2] * centre[2];
if (cos >= inner) disc.push(y * width + x);
else if (cos >= outer) ring.push(y * width + x);
}
}
for (const plane of planes) {
const values = Float32Array.from(ring, (index) => plane[index]).sort();
const median = values[values.length >> 1];
for (const index of disc) plane[index] = median;
}
}
/** Bilinear sample with longitude wrap and latitude clamp. */
function samplePlane(plane, width, height, u, v) {
const fx = u * width - 0.5;
const fy = Math.min(Math.max(v * height - 0.5, 0), height - 1);
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const ax = fx - x0;
const ay = fy - y0;
const xa = ((x0 % width) + width) % width;
const xb = (xa + 1) % width;
const yb = Math.min(y0 + 1, height - 1);
const top = plane[y0 * width + xa] * (1 - ax) + plane[y0 * width + xb] * ax;
const bottom =
plane[yb * width + xa] * (1 - ax) + plane[yb * width + xb] * ax;
return top * (1 - ay) + bottom * ay;
}
/** Deterministic hash noise in [-0.5, 0.5) for dithering. */
function dither(index, channel) {
let h = (index * 374761393 + channel * 668265263) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296 - 0.5;
}
/**
* Black level with a soft knee: 0 at and below the level, then a quadratic
* ease that joins the straight line, so faint sky fades out instead of
* ending in a contour.
*/
export function softBlack(x, knee = SOFT_KNEE) {
return x <= 0 ? 0 : (x * x) / (x + knee);
}
export function smoothstep(edge0, edge1, x) {
const t = Math.min(Math.max((x - edge0) / (edge1 - edge0), 0), 1);
return t * t * (3 - 2 * t);
}
/**
* Per pixel, keep `fine` where `weight(luminance of coarse)` is 1 and take
* `coarse` where it is 0, blending in between. The weight follows the coarse
* copy, so it is smooth itself and draws no seams along dark lanes. Writes
* into `fine`.
*/
function blendByBrightness(fine, coarse, weight) {
const [r] = fine;
for (let i = 0; i < r.length; i++) {
const w = weight((coarse[0][i] + coarse[1][i] + coarse[2][i]) / 3);
for (let c = 0; c < 3; c++)
fine[c][i] = coarse[c][i] + (fine[c][i] - coarse[c][i]) * w;
}
}
/** Value at `fraction` of the sorted luminance. */
function luminancePercentile(planes, fraction) {
const [r, g, b] = planes;
const lum = new Float32Array(r.length);
for (let i = 0; i < lum.length; i++) lum[i] = (r[i] + g[i] + b[i]) / 3;
lum.sort();
return lum[Math.min(lum.length - 1, Math.floor(fraction * lum.length))];
}
const srgbToLinear = (value) =>
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
async function decodePanorama(sharp, file) {
const width = WORK_WIDTH;
const height = WORK_WIDTH / 2;
const raw = await sharp(file)
.resize(width, height, { fit: 'fill', kernel: 'lanczos3' })
.removeAlpha()
.raw()
.toBuffer();
const lut = Float32Array.from({ length: 256 }, (_, i) =>
srgbToLinear(i / 255),
);
const planes = [0, 1, 2].map(() => new Float32Array(width * height));
for (let i = 0; i < width * height; i++)
for (let c = 0; c < 3; c++) planes[c][i] = lut[raw[i * 3 + c]];
return { width, height, planes };
}
async function main() {
const [file] = process.argv.slice(2);
if (!file) throw new Error('usage: build-milky-way.mjs <eso0932a.tif>');
const { default: sharp } = await import('sharp');
const { width, height, planes } = await decodePanorama(sharp, file);
const sigmaPx = (BLUR_DEG / 180) * height;
for (const plane of planes) {
extremumPlane(plane, width, height, OPEN_RADIUS, Math.min);
extremumPlane(plane, width, height, OPEN_RADIUS, Math.max);
}
// After the opening: it sinks the starry sky to the dark gaps between
// stars, and a fill taken before it would stay brighter than the sky.
for (const planet of PLANETS) paintOut(planes, width, height, planet);
const wide = planes.map((plane) => Float32Array.from(plane));
for (const plane of planes) blurPlane(plane, width, height, sigmaPx);
for (const plane of wide)
blurPlane(plane, width, height, (FAINT_BLUR_DEG / 180) * height);
// Levels from the fine blur, so the blend weight and the output share
// them; the blend moves the median only in the faint sky it smooths.
const black = luminancePercentile(planes, BLACK_PERCENTILE);
const white = luminancePercentile(planes, WHITE_PERCENTILE);
blendByBrightness(planes, wide, (luminance) =>
smoothstep(
FAINT_RANGE[0],
FAINT_RANGE[1],
(luminance - black) / (white - black),
),
);
let total = 0;
for (const face of CUBE_FACES) {
const pixels = Buffer.alloc(FACE_SIZE * FACE_SIZE * 3);
for (let j = 0; j < FACE_SIZE; j++) {
for (let i = 0; i < FACE_SIZE; i++) {
const direction = cubeFaceDirection(
face,
(i + 0.5) / FACE_SIZE,
(j + 0.5) / FACE_SIZE,
);
const [u, v] = directionToPlateCarree(...icrfToGalactic(direction));
const index = j * FACE_SIZE + i;
const rgb = [0, 1, 2].map((c) =>
samplePlane(planes[c], width, height, u, v),
);
const luminance = (rgb[0] + rgb[1] + rgb[2]) / 3;
const level = softBlack((luminance - black) / (white - black));
for (let c = 0; c < 3; c++) {
// Scale every channel by the luminance curve to keep the hue.
const linear = luminance > 0 ? (rgb[c] / luminance) * level : 0;
const encoded = Math.min(linear, 1) ** (1 / 2.2) * 255;
pixels[index * 3 + c] = Math.min(
255,
Math.max(0, Math.round(encoded + dither(index, c))),
);
}
}
}
const out = path.join(OUT_DIR, `milkyway_${FACE_FILE_SUFFIX[face]}.webp`);
const info = await sharp(pixels, {
raw: { width: FACE_SIZE, height: FACE_SIZE, channels: 3 },
})
.webp({ quality: WEBP_QUALITY, smartSubsample: true, effort: 6 })
.toFile(out);
total += info.size;
console.log(`${path.relative(process.cwd(), out)}: ${info.size} bytes`);
}
console.log(`total ${total} bytes`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+3 -4
View File
@@ -24,11 +24,10 @@ const renderer = (file) =>
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
file,
);
// Two compatibility composition entries intentionally select standalone defaults.
// One compatibility composition entry intentionally selects standalone defaults.
const compatibilityEdge = (from, to) =>
(from === 'server/providers/local.js' &&
to === 'server/standalone/key-setup.js') ||
(from === 'src/ui.js' && to === 'src/standalone/catalog.js');
from === 'server/providers/local.js' &&
to === 'server/standalone/key-setup.js';
const portableExport = (key) =>
key.startsWith('./sources/') ||
/\/source$/.test(key) ||
+8 -4
View File
@@ -48,7 +48,8 @@ export function defaultGatewayFromRouteTable(text) {
/**
* Decide this launch's trusted-peer list. An operator value always wins, so a
* compose override stays authoritative; otherwise the gateway alone is
* compose override stays authoritative; `none` trusts no additional peers.
* An unset or blank value means the gateway alone is
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
* exactly as under `npm run dev`.
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
@@ -56,6 +57,7 @@ export function defaultGatewayFromRouteTable(text) {
*/
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
if (explicit.toLowerCase() === 'none') return '';
if (explicit) return explicit;
return defaultGatewayFromRouteTable(routeTable) ?? '';
}
@@ -109,15 +111,17 @@ async function start() {
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
);
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
// Set even an empty list before Vite loads .env: the resolved launcher policy
// must remain authoritative when there is no trusted gateway.
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
if (trustedPeers) {
// Set before Vite is imported: the dev server snapshots process.env during
// configuration, and the admission gate reads it per request.
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
console.log(
fromOperator
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
);
} else if (fromOperator) {
console.log('[Docker] Provider Settings trusts no additional peers (GEV_KEY_SETUP_TRUSTED_PEERS=none).');
} else {
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
}
+28 -3
View File
@@ -2,8 +2,11 @@
".prettierrc.json",
"package.json",
"scripts/format-scope.json",
"src/ui/styles/form-field.css",
"scripts/qa-form-fields.mjs",
"scripts/package-boundaries.json",
"scripts/format.mjs",
"scripts/qa-layer-visibility.mjs",
"scripts/next-layer-state-token.mjs",
"scripts/check-layer-state-tokens.mjs",
"scripts/qa-layer-token-twochar.mjs",
@@ -119,6 +122,7 @@
"server/providers/cctv/normalize.js",
"src/data/cctvMissingNumbers.test.mjs",
"server/providers/cctv/sources.js",
"src/data/cctvSourcePins.test.mjs",
"server/providers/radio.js",
"server/providers/radio/catalog.js",
"server/providers/radio/constants.js",
@@ -190,7 +194,10 @@
"src/ui/visualInput.js",
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs",
"scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs",
"src/ui/styles/segmented-control.css",
"src/ui/displayControls.test.mjs",
"src/ui/cameraOrientationControls.js",
"src/ui/cameraOrientationControls.test.mjs",
@@ -278,8 +285,7 @@
"src/ui/scenePresentation.js",
"src/ui/sceneControls.test.mjs",
"scripts/qa-scene-controls.mjs",
"src/ui.js",
"src/standalone/ui.js",
"scripts/qa-action-buttons.mjs",
"src/ui/applicationShell.js",
"src/ui/panelLayoutController.js",
"src/ui/panelPositionControls.js",
@@ -301,6 +307,8 @@
"src/ui/styles/status.css",
"src/ui/styles/layers.css",
"src/ui/styles/controls.css",
"src/ui/styles/slider.css",
"src/ui/styles/action-button.css",
"src/ui/styles/cctv.css",
"src/ui/styles/foundation.css",
"src/ui/styles/first-run.css",
@@ -1064,5 +1072,22 @@
"src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs",
"src/data/routeSegment.test.mjs",
"src/terrainHeightsBounds.test.mjs"
"src/terrainHeightsBounds.test.mjs",
"scripts/build-bright-stars.mjs",
"scripts/build-milky-way.mjs",
"src/sky/stars.test.mjs",
"src/sky/starrySky.test.mjs",
"src/tooling/skyAssets.test.mjs",
"src/tooling/skyAssets.paintOut.test.mjs",
"scripts/qa-anime-sky.mjs",
"scripts/qa-sky-model-lighting.mjs",
"server/providers/overpass/requestAdmission.test.mjs",
"src/tooling/trackArrivalObservation.test.mjs",
"src/ui/styles/status-badge.css",
"src/ui/statusBadge.test.mjs",
"src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs",
"src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs"
]
+115 -30
View File
@@ -22,7 +22,14 @@
},
"viewer": {
"exports": ["./application/viewer"],
"modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"],
"modules": [
"src/app/viewer.js",
"src/app/atmosphereCompat.js",
"src/sky/starrySky.js",
"src/sky/stars.js",
"src/data/bundledJson.js",
"src/renderGovernor.js"
],
"external": ["cesium"]
},
"vite-build": {
@@ -96,6 +103,9 @@
"modules": [
"server/providers/space.js",
"server/providers/space/celestrak.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"server/providers/space/cooldown.js",
"server/providers/space/launch-library.js",
"server/providers/common/http.js",
"src/data/spaceProviderRequests.js",
@@ -187,6 +197,8 @@
"exports": ["./server/providers/cctv"],
"modules": [
"server/providers/cctv.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
@@ -252,6 +264,7 @@
"server/providers/common/http.js",
"server/providers/common/rate-limit.js",
"server/providers/common/request.js",
"server/providers/common/same-site.js",
"server/providers/overpass.js",
"server/providers/overpass/cache.js",
"server/providers/overpass/constants.js",
@@ -261,6 +274,7 @@
"server/providers/places/routes.js",
"src/data/placeProviderPayloads.js",
"src/data/routeSteps.js",
"src/localRequestGate.mjs",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/overpass.js",
@@ -422,16 +436,18 @@
"src/ui/visualInput.js",
"src/ui/applicationShortcuts.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
"display-controls": {
"exports": ["./ui/display"],
"modules": ["src/ui/displayControls.js"],
"modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"],
"external": []
},
"visual-effects": {
@@ -450,7 +466,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -466,7 +483,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -480,6 +498,7 @@
"src/ui/weatherPanel.js",
"src/ui/railCards.js",
"src/ui/railCardBlocks.js",
"src/ui/slider.js",
"src/ui/chipGroup.js",
"src/ui/rowList.js",
"src/ui/railTimeline.js",
@@ -494,9 +513,11 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js",
"src/ui/statusText.js"
"src/ui/statusText.js",
"src/ui/statusBadge.js"
],
"external": []
},
@@ -512,7 +533,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -527,7 +549,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -537,6 +560,7 @@
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
"src/ui/formField.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
@@ -545,7 +569,9 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/ui/statusBadge.js"
],
"external": []
},
@@ -569,6 +595,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/layerNames.js"
],
"external": []
@@ -602,6 +629,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -612,10 +640,12 @@
"src/ui/scenes.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": []
@@ -624,6 +654,8 @@
"exports": ["./ui/shell"],
"modules": [
"src/i18n/marker.js",
"src/ui/mobileShell.js",
"src/exclusiveSurfaces.js",
"src/ui/placeNames.js",
"src/hudLayouts.js",
"src/hudLayoutPolicy.js",
@@ -673,6 +705,7 @@
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
"src/ui/formField.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
@@ -701,6 +734,7 @@
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js",
"src/ui/displayControls.js",
"src/ui/segmentedControl.js",
"src/ui/effects.js",
"src/ui/frameRateMonitor.js",
"src/ui/layerPanel.js",
@@ -734,11 +768,13 @@
"src/ui/rightPanelRail.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/ui/scenes.js",
"src/ui/shareRestoration.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
@@ -764,10 +800,12 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js",
"src/ui/layerNames.js",
"src/ui/statusText.js"
"src/ui/statusText.js",
"src/ui/statusBadge.js"
],
"external": ["cesium"]
},
@@ -845,6 +883,7 @@
"src/ui/scenes.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/contextModePolicy.js",
"src/director/clock.js",
"src/director/timeline.js",
@@ -881,6 +920,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -946,6 +986,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -991,7 +1032,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1051,7 +1093,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1080,6 +1123,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js"
],
@@ -1108,7 +1152,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1139,6 +1184,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium", "satellite.js"]
@@ -1170,7 +1216,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1212,6 +1259,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/layers/installations/classLabel.js"
],
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
@@ -1246,6 +1294,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -1294,7 +1343,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
},
@@ -1322,7 +1372,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1369,7 +1420,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["hls.js", "cesium"]
},
@@ -1378,7 +1430,9 @@
"./layers/street-level",
"./layers/street-level/mapillary",
"./layers/street-level/mapillary/source",
"./layers/street-level/google"
"./layers/street-level/google",
"./layers/street-level/kartaview",
"./layers/street-level/panoramax"
],
"modules": [
"src/layers/streetLevel/index.js",
@@ -1430,6 +1484,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/layers/streetLevel/providers/mapillary/policy.js"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
@@ -1457,7 +1512,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
},
@@ -1496,7 +1552,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1517,7 +1574,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1572,7 +1630,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -1628,6 +1687,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js",
"src/voice/providerCommands.js",
"src/voice/providerCapabilities.js",
@@ -1716,6 +1776,8 @@
"src/app/surfaceServices.js",
"src/app/tools.js",
"src/app/viewer.js",
"src/sky/starrySky.js",
"src/sky/stars.js",
"src/app/atmosphereCompat.js",
"src/bloom.js",
"src/camera.js",
@@ -1797,7 +1859,6 @@
"src/data/localGeojson.js",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/localLayers.js",
"src/data/local_data/natural_earth/marine.json",
"src/data/local_data/natural_earth/regions.json",
"src/data/local_data/neighborhoods/san-francisco.json",
@@ -1851,6 +1912,7 @@
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/firstRunExperience.js",
"src/exclusiveSurfaces.js",
"src/hud.js",
"src/hudLocality.js",
"src/hudLocalityFormat.js",
@@ -2138,6 +2200,8 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
"src/ui/mobileShell.js",
"src/exclusiveSurfaces.js",
"src/ui/localePicker.js",
"src/ui/cyberSonarControls.js",
"src/ui/locationNavigation.js",
@@ -2147,6 +2211,7 @@
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
"src/ui/formField.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
@@ -2176,6 +2241,7 @@
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js",
"src/ui/displayControls.js",
"src/ui/segmentedControl.js",
"src/ui/effects.js",
"src/ui/frameRateMonitor.js",
"src/ui/layerPanel.js",
@@ -2209,11 +2275,13 @@
"src/ui/rightPanelRail.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/actionButton.js",
"src/ui/scenes.js",
"src/ui/shareRestoration.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
@@ -2483,6 +2551,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js",
"src/ui/keyGuidance.js",
@@ -2500,7 +2569,8 @@
"src/voice/providerCapabilities.js",
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js"
"src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js"
],
"external": [
"hls.js",
@@ -3057,6 +3127,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js",
"src/layers/installations/classLabel.js",
@@ -3163,6 +3234,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/index.js"
],
"external": ["cesium"]
@@ -3183,7 +3255,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -3207,15 +3280,18 @@
"src/ui/applicationShortcuts.js",
"src/ui/effects.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
"src/ui/visualInput.js",
"src/ui/visualPresets.js",
"src/ui/visualSettings.js",
"src/ui/segmentedControl.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -3238,7 +3314,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": []
},
@@ -3329,6 +3406,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -3383,6 +3461,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js",
"src/voice/providerCapabilities.js"
],
@@ -3593,6 +3672,7 @@
"src/data/labelArbiter.js",
"src/data/meshFloorSampler.js",
"src/data/pickRegistry.js",
"src/data/scenePick.js",
"src/data/spriteOrder.js",
"src/data/terrainHeights.js",
"src/data/transit.js",
@@ -3635,7 +3715,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
@@ -3677,7 +3758,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium", "pbf"]
},
@@ -3729,7 +3811,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
],
"external": ["cesium"]
},
@@ -3745,6 +3828,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js",
"src/ui/layerNames.js"
],
@@ -3937,6 +4021,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js",
"src/ui/layerNames.js"
],
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env node
/**
* Scenes/CCTV action-button acceptance against dev or production preview:
* QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs
* Uses the installed CCTV controller with fixture camera/action ports to prove
* native dispatch/disabled/teardown without providers or world calibration writes.
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
*/
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: ['--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, passed, detail) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) {
failures++;
console.log(JSON.stringify(detail));
}
};
const shots = 'qa-shots/action-buttons';
fs.mkdirSync(shots, { recursive: true });
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4179'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._cctvControls &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const inventory = await page.evaluate(() => {
const ids = [
'scene-new-btn',
'scene-delete-btn',
'scene-capture-btn',
'scene-update-shot-btn',
'scene-start-btn',
'scene-stop-btn',
'scene-next-btn',
'scene-export-btn',
'scene-import-btn',
'scene-download-btn',
'cctv-nearest-btn',
'cctv-prev-btn',
'cctv-next-btn',
'cctv-focus-btn',
'cctv-calib-save-btn',
'cctv-calib-reset-btn',
];
const ordinary = ids.map((id) => document.getElementById(id));
const generated = [
...document.querySelectorAll(
'.scene-shot-btn, [data-director-authoring] button',
),
];
const toggles = [
'cctv-enable-btn',
'cctv-coverage-btn',
'cctv-auto-hop-btn',
'cctv-projection-btn',
'cctv-adjust-btn',
].map((id) => document.getElementById(id));
return {
shared: [...ordinary, ...generated].every(
(node) =>
node?.type === 'button' &&
node.classList.contains('gev-action-button'),
),
generated: generated.length,
layout: ordinary.every((node) => {
const style = getComputedStyle(node);
return style.minWidth === '72px' && style.flexGrow === '1';
}),
togglesSeparate: toggles.every(
(node) => node && !node.classList.contains('gev-action-button'),
),
compactDanger: !!document.querySelector(
'.scene-shot-danger.gev-action-button--compact.gev-action-button--danger',
),
};
});
check(
'static and generated actions share the native component',
inventory.shared && inventory.generated >= 4 && inventory.compactDanger,
inventory,
);
check(
'persistent CCTV toggles retain their existing controls',
inventory.togglesSeparate,
);
check('ordinary actions retain their panel sizing', inventory.layout);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const owner = manager._cctvControls;
owner._cctvUnsubscribe?.();
owner._cctvUnsubscribe = null;
window.__qaButtonCalls = [];
const record = (...args) => window.__qaButtonCalls.push(args);
owner.actions = {
...owner.actions,
isEnabled: () => true,
toggleEnabled: async () => {
record('enable');
return true;
},
runExplicitFocus: (select, focus) => focus(select()),
setParams: (params, options) => record('params', params, options),
showToast: () => {},
};
owner.cctv = {
focusNearest: () => {
record('nearest');
return 'a';
},
cycleCamera: (direction) => {
record('cycle', direction);
return 'b';
},
focusCamera: (id, seconds) => record('focus', id, seconds),
};
window.__qaCameraState = {
enabled: true,
activeCameraId: 'a',
cameras: [
{ id: 'a', city: 'Fixture', name: 'A' },
{ id: 'b', city: 'Fixture', name: 'B' },
],
activeCamera: { id: 'a', name: 'A', basePose: {} },
};
owner._renderCctvState(window.__qaCameraState);
manager.setPanelCollapsed('cctv-panel', false, {
persist: false,
syncShare: false,
});
});
await page.focus('#cctv-next-btn');
await page.keyboard.press('Space');
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
check(
'native Space dispatches Next and its focus once',
await page.evaluate(
() =>
JSON.stringify(window.__qaButtonCalls) ===
JSON.stringify([['enable'], ['cycle', 1], ['focus', 'b', 1.4]]),
),
);
check(
'native keyboard activation keeps a visible focus ring',
await page.$eval(
'#cctv-next-btn',
(node) =>
document.activeElement === node &&
getComputedStyle(node).outlineStyle === 'solid',
),
);
await page.evaluate(() => {
window.__qaButtonCalls = [];
});
await page.focus('#cctv-prev-btn');
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
check(
'native Enter dispatches Previous once',
await page.evaluate(
() =>
JSON.stringify(window.__qaButtonCalls) ===
JSON.stringify([['enable'], ['cycle', -1], ['focus', 'b', 1.4]]),
),
);
for (const [id, expected] of [
['cctv-nearest-btn', [['enable'], ['nearest'], ['focus', 'a', 1.8]]],
[
'cctv-focus-btn',
[
['enable'],
['focus', 'a', 1.9],
['params', { selectedCameraId: 'a' }, { origin: 'user' }],
],
],
]) {
await page.evaluate(() => {
window.__qaButtonCalls = [];
});
await page.click(`#${id}`);
await page.waitForFunction(() => window.__qaButtonCalls.length === 3);
const calls = await page.evaluate(() => window.__qaButtonCalls);
check(
`${id} retains its existing action and focus timing`,
JSON.stringify(calls) === JSON.stringify(expected),
calls,
);
}
await page.evaluate(() => {
window.__qaButtonCalls = [];
});
await page.click('#cctv-calib-save-btn');
await page.click('#cctv-calib-reset-btn');
const calibration = await page.evaluate(() => window.__qaButtonCalls);
check(
'Save and Reset retain camera identity and user-origin dispatch',
JSON.stringify(calibration) ===
JSON.stringify([
[
'params',
{ selectedCameraId: 'a', calibration: { cameraId: 'a', save: true } },
{ origin: 'user' },
],
[
'params',
{
selectedCameraId: 'a',
calibration: { cameraId: 'a', reset: true },
},
{ origin: 'user' },
],
]),
calibration,
);
await page.evaluate(() => {
window.__qaButtonCalls = [];
window.__godsEyeView.styleManager._cctvControls._renderCctvState({
enabled: false,
});
});
await page.click('#cctv-next-btn');
await page.waitForFunction(
() => {
const node = document.getElementById('cctv-next-btn');
return node.disabled && Number(getComputedStyle(node).opacity) === 0.45;
},
{ timeout: 3000 },
);
check(
'disabled actions stay inert and keep disabled presentation',
await page.$eval(
'#cctv-next-btn',
(node) =>
node.disabled &&
getComputedStyle(node).opacity === '0.45' &&
window.__qaButtonCalls.length === 0,
),
);
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
for (const panel of ['scene-panel', 'cctv-panel']) {
await page.evaluate((panel) => {
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
}, panel);
const fit = await page.$eval(`#${panel}`, (root) => {
const buttons = [...root.querySelectorAll('.gev-action-button')];
const rect = root.getBoundingClientRect();
return {
count: buttons.length,
fits:
buttons.length > 0 &&
buttons.every((button) => {
const box = button.getBoundingClientRect();
return (
box.width > 0 &&
box.left >= rect.left &&
box.right <= rect.right + 1 &&
box.left >= 0 &&
box.right <= innerWidth + 1 &&
button.scrollWidth <= button.clientWidth
);
}),
};
});
check(`${panel} actions fit at ${width}px`, fit.fits, fit);
if (width !== 620)
await page.screenshot({ path: `${shots}/${panel}-${width}.png` });
}
}
await page.evaluate(() => {
const owner = window.__godsEyeView.styleManager._cctvControls;
owner.destroy();
document.getElementById('cctv-next-btn').disabled = false;
window.__qaButtonCalls = [];
});
await page.click('#cctv-next-btn');
check(
'destroyed controllers revoke action listeners',
await page.evaluate(() => window.__qaButtonCalls.length === 0),
);
const localized = new URL(page.url());
localized.searchParams.set('lang', 'ru');
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._cctvControls &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
for (const panel of ['scene-panel', 'cctv-panel']) {
await page.evaluate((panel) => {
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
}, panel);
const translated = await page.$eval(`#${panel}`, (root) => {
const buttons = [...root.querySelectorAll('.gev-action-button')];
return (
document.documentElement.lang === 'ru' &&
buttons.length > 0 &&
buttons.every((node) => node.scrollWidth <= node.clientWidth)
);
});
check(`Russian ${panel} action labels fit at 390px`, translated);
await page.screenshot({ path: `${shots}/${panel}-ru-390.png` });
}
check('no uncaught browser errors', errors.length === 0, errors);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* qa-anime-sky — the anime style must leave the sky without bands.
*
* Anime's luminance bands turn the faint Milky Way into blotches, so
* src/ui/visualEffects.js fades the glow out with the style. Headless against
* a Vite dev server, both sky assets must be ready. Normal must show the
* Galactic Centre glow (at least 2 % of pixels at luma >= 10/255), then
* Anime must retain stars while at most 0.5 % reaches the first cel band.
* The Normal baseline measured 43 % in headless Chromium at 1000×700;
* the 2 % floor leaves room for GPU differences while rejecting a black sky.
*
* Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/]
* Exit: 0 pass, 1 fail, 2 the anime style was not reached.
*/
import puppeteer from 'puppeteer';
const i = process.argv.indexOf('--url');
const URL_ =
i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/';
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
});
try {
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', (request) => {
const host = new URL(request.url()).hostname;
if (host.endsWith('halfpixel.ai') || host.includes('worldfx'))
request.abort();
else request.continue();
});
page.on('pageerror', (e) => console.log('PAGEERR', String(e).slice(0, 300)));
await page.setViewport({ width: 1000, height: 700 });
await page.evaluateOnNewDocument(() =>
localStorage.setItem('gev:first-run-mission:v1', 'suppressed'),
);
await page.goto(URL_, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, {
timeout: 120000,
});
await page.waitForFunction(
() => {
const sky = window.__godsEyeView.viewer.scene.skyBox;
return (
sky?._milkyWayCubeMap &&
sky?._starCommand &&
document.getElementById('loading-screen')?.classList.contains('hidden')
);
},
{ timeout: 60000 },
);
const r = await page.evaluate(async () => {
const entry = performance
.getEntriesByType('resource')
.map((e) => e.name)
.find((n) => /\.vite\/deps\/cesium\.js/.test(n));
if (!entry)
throw new Error(
'run against the Vite dev server (needs .vite/deps/cesium.js)',
);
const C = await import(entry),
g = window.__godsEyeView,
v = g.viewer,
s = v.scene;
g.styleManager.setStyle('normal', { userInitiated: true });
const t = v.clock.currentTime;
await C.Transforms.preloadIcrfFixed(
new C.TimeInterval({
start: C.JulianDate.addDays(t, -1, new C.JulianDate()),
stop: C.JulianDate.addDays(t, 1, new C.JulianDate()),
}),
);
const m = C.Transforms.computeIcrfToFixedMatrix(t);
const ra = C.Math.toRadians(266.4),
dec = C.Math.toRadians(-29);
const look = C.Matrix3.multiplyByVector(
m,
new C.Cartesian3(
Math.cos(dec) * Math.cos(ra),
Math.cos(dec) * Math.sin(ra),
Math.sin(dec),
),
new C.Cartesian3(),
);
const north = C.Matrix3.multiplyByVector(
m,
C.Cartesian3.UNIT_Z,
new C.Cartesian3(),
);
const right = C.Cartesian3.normalize(
C.Cartesian3.cross(look, north, new C.Cartesian3()),
new C.Cartesian3(),
);
s.camera.cancelFlight();
s.tweens.removeAll();
s.camera.setView({
destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()),
orientation: {
direction: look,
up: C.Cartesian3.cross(right, look, new C.Cartesian3()),
},
});
const gl = s.context._gl,
w = gl.drawingBufferWidth,
h = gl.drawingBufferHeight,
px = new Uint8Array(w * h * 4);
const measure = async () => {
await new Promise((res) => setTimeout(res, 2500));
s.requestRender();
s.render();
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
let banded = 0;
let visible = 0;
for (let i = 0; i < px.length; i += 4) {
const luma = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
if (luma >= 51) banded++;
if (luma >= 10) visible++;
}
return { banded, visible };
};
const normal = await measure();
g.styleManager.setStyle('anime', { userInitiated: true });
const anime = await measure();
let animeOn = false;
for (let i = 0; i < s.postProcessStages.length; i++) {
const stage = s.postProcessStages.get(i);
if (stage.name === 'godsEyeView_anime')
animeOn = stage.enabled && stage.uniforms.intensity > 0.99;
}
return {
total: px.length / 4,
normal,
...anime,
assetsReady: Boolean(s.skyBox._milkyWayCubeMap && s.skyBox._starCommand),
visibility: s.skyBox.milkyWayVisibility,
animeOn,
};
});
const tag =
!r.animeOn || !r.assetsReady
? 'UNREACHED'
: r.normal.visible >= 0.02 * r.total &&
r.banded > 0 &&
r.banded <= 0.005 * r.total &&
r.visibility === 0
? 'PASS'
: 'FAIL';
console.log(
`${tag}: ${r.banded} of ${r.total} sky pixels at or above the first cel band (luma >= 0.2)\n ${JSON.stringify(r)}`,
);
process.exitCode = tag === 'PASS' ? 0 : tag === 'FAIL' ? 1 : 2;
} catch (error) {
console.error(`UNREACHED: ${error.message}`);
process.exitCode = 2;
} finally {
await browser.close();
}
+3
View File
@@ -370,6 +370,9 @@ try {
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
await page.setViewport({ width: 390, height: 844 });
await new Promise((resolve) => setTimeout(resolve, 700));
// Phones present CCTV in the Cameras tab's sheet.
await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]');
await new Promise((resolve) => setTimeout(resolve, 400));
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
check(
'narrow camera panel remains within the viewport',
+27
View File
@@ -18,3 +18,30 @@ export function chromeExecutablePath({
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
}
/** Restore an owned test window before capture: focus emulation alone does not
* unminimize the native compositor, so CDP screenshots can otherwise stall. */
export async function captureQaScreenshot(
page,
options,
{ headful = true } = {},
) {
if (headful) {
const session = await page.createCDPSession();
try {
const { windowId, bounds } = await session.send(
'Browser.getWindowForTarget',
);
if (bounds.windowState === 'minimized') {
await session.send('Browser.setWindowBounds', {
windowId,
bounds: { windowState: 'normal' },
});
}
await page.bringToFront();
} finally {
await session.detach();
}
}
return page.screenshot(options);
}
+1 -1
View File
@@ -12,7 +12,7 @@
* passed with the painter stubbed out — the number was right and nothing was
* drawn. Every assertion here now comes from the RENDERED path instead:
*
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui/shellFacade.js), the
* overlay's own published `visibleCount` / `labeledKeys`, so a contact only
* counts once the sweep, the arbiter and the placement solve have all run.
* plate scale — tapped off `CanvasRenderingContext2D` while the frame paints.
-4
View File
@@ -34,10 +34,6 @@
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
-1
View File
@@ -52,7 +52,6 @@ try {
id,
value,
);
const text = () => page.$eval('#cesium-credits', (e) => e.innerText);
const shot = async (name, osm, tiles) => {
await sleep(300);
const state = await page.$eval('#cesium-credits', (e) => {
+465
View File
@@ -0,0 +1,465 @@
#!/usr/bin/env node
/** Native form-field regression gate. CCTV ports are fixtures, never live writes. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: ['--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
page.setDefaultNavigationTimeout(60000);
let failures = 0;
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const check = (name, passed, detail) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) {
failures++;
console.log(JSON.stringify(detail));
}
};
fs.mkdirSync('qa-shots/form-fields', { recursive: true });
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4183'}/?welcome=0`,
{
waitUntil: 'domcontentloaded',
},
);
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._cctvControls &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const inventory = await page.evaluate(() => {
const selects = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
'radio-filter',
'sdr-gain-select',
].map((id) => document.getElementById(id));
return {
ids: selects.map((node) => node.id),
shared:
selects.length === 6 &&
selects.every((node) => node.classList.contains('gev-field')),
native: selects.every(
(node) =>
node instanceof HTMLSelectElement && !node.hasAttribute('role'),
),
dark: selects.every(
(node) => getComputedStyle(node).colorScheme === 'dark',
),
};
});
check(
'all six panel dropdowns retain native select semantics and shared surface',
inventory.shared && inventory.native,
inventory,
);
check('native option menus keep the dark scheme', inventory.dark);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
});
manager.setPanelCollapsed('scene-panel', false, {
persist: false,
syncShare: false,
});
manager.setPanelCollapsed('cctv-panel', false, {
persist: false,
syncShare: false,
});
window.__qaFormChanges = [];
document
.getElementById('scene-select')
.addEventListener('change', (event) =>
window.__qaFormChanges.push(event.target.value),
);
});
const ownerSetup = await page.evaluate(() => {
const owner = window.__godsEyeView.styleManager._cctvControls;
owner._cctvUnsubscribe?.();
owner._cctvUnsubscribe = null;
window.__qaFormCalls = [];
owner.actions = {
...owner.actions,
isEnabled: () => true,
toggleEnabled: async () => true,
setParams: (...args) => window.__qaFormCalls.push(args),
runExplicitFocus: (select, focus) => focus(select()),
showToast: () => {},
};
owner.cctv = {
setParams() {},
focusCamera: (id) => window.__qaFormCalls.push(['focus', id]),
selectCamera: (id) => id,
};
owner._cctvState = {
enabled: true,
activeCameraId: 'a',
cameras: [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
],
activeCamera: {
id: 'a',
headingDeg: 30,
pitchDeg: -10,
fovDeg: 45,
rangeM: 120,
mountHeightM: 8,
calibration: {},
basePose: {
headingDeg: 20,
pitchDeg: -10,
fovDeg: 45,
rangeM: 120,
mountHeightM: 8,
},
},
};
owner._renderCctvState(owner._cctvState);
const input = document.getElementById('sdr-frequency-input');
return {
min: input.min,
max: input.max,
step: input.step,
type: input.type,
shared: input.classList.contains('gev-field'),
};
});
check(
'FM frequency retains native bounds, precision and shared surface',
ownerSetup.min === '87.5' &&
ownerSetup.max === '108' &&
ownerSetup.step === '0.1' &&
ownerSetup.type === 'number' &&
ownerSetup.shared,
ownerSetup,
);
const validity = await page.$eval('#sdr-frequency-input', (input) => {
const original = input.value;
input.value = '87.4';
const below = input.validity.rangeUnderflow;
input.value = '108.1';
const above = input.validity.rangeOverflow;
input.value = '98.55';
const step = input.validity.stepMismatch;
input.value = '';
const empty = input.validity.valid;
input.value = original;
return below && above && step && empty;
});
check(
'native numeric validity preserves underflow, overflow, steps and empty drafts',
validity,
);
await page.select('#cctv-camera-select', 'b');
await page.waitForFunction(() => window.__qaFormCalls.length === 2);
const cameraSelection = await page.evaluate(() => window.__qaFormCalls);
check(
'camera dropdown retains selection, focus and user-origin dispatch',
cameraSelection[0][0] === 'focus' &&
cameraSelection[0][1] === 'b' &&
cameraSelection[1][0].selectedCameraId === 'b' &&
cameraSelection[1][1].origin === 'user',
cameraSelection,
);
await page.evaluate(() => {
window.__qaFormCalls = [];
document.getElementById('cctv-camera-select').disabled = true;
});
await page.focus('#scene-select');
await page.focus('#cctv-camera-select');
check(
'disabled native selects cannot receive keyboard focus',
await page.evaluate(
() => document.activeElement.id !== 'cctv-camera-select',
),
);
await page.evaluate(() => {
document.getElementById('cctv-camera-select').disabled = false;
});
const begin = async (field) => {
await page.evaluate((field) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
owner._beginCctvCalValueEdit(
document.querySelector(`[data-cal-field="${field}"]`),
);
}, field);
};
for (const [field, step, value] of [
['heading', '0.1', '30.0'],
['pitch', '0.1', '-10.0'],
['fov', '1', '45'],
['range', '1', '120'],
['height', '1', '8'],
['north', '0.1', '0.0'],
['east', '0.1', '0.0'],
]) {
await begin(field);
const result = await page.$eval('.cctv-cal-input', (input) => ({
step: input.step,
value: input.value,
native: input.type === 'number',
shared: input.classList.contains('gev-field'),
label: input.getAttribute('aria-label'),
focus: document.activeElement === input,
}));
check(
`${field} editor retains precision, initial value and focus`,
result.step === step &&
result.value === value &&
result.native &&
result.shared &&
!!result.label &&
result.focus,
result,
);
await page.keyboard.press('Escape');
}
check(
'Escape cancels each field without a calibration write',
await page.evaluate(() => window.__qaFormCalls.length === 0),
);
await begin('heading');
await page.keyboard.press('Backspace');
await page.type('.cctv-cal-input', '100');
await page.evaluate(() => {
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCamera.basePose.headingDeg = 60;
});
await page.keyboard.press('Enter');
const commit = await page.evaluate(() => window.__qaFormCalls);
check(
'Enter commits once against the frozen camera base',
commit.length === 1 &&
commit[0][0].calibration.patch.headingDeg === 80 &&
commit[0][0].calibration.cameraId === 'a' &&
commit[0][1].origin === 'user',
commit,
);
await begin('north');
await page.keyboard.press('Backspace');
await page.type('.cctv-cal-input', '12.5');
await page.$eval('.cctv-cal-input', (input) => input.blur());
const blur = await page.evaluate(() => window.__qaFormCalls);
check(
'blur commits a decimal offset exactly once',
blur.length === 2 && blur[1][0].calibration.patch.offsetNorthM === 12.5,
blur,
);
await begin('east');
await page.keyboard.press('Backspace');
await page.keyboard.press('Enter');
check(
'an empty numeric draft does not write calibration',
await page.evaluate(() => window.__qaFormCalls.length === 2),
);
await begin('heading');
await page.type('.cctv-cal-input', '90');
await page.evaluate(() => {
window.__godsEyeView.styleManager._cctvControls._cctvState.activeCameraId =
'b';
});
await page.keyboard.press('Enter');
check(
'a stale camera editor cannot commit to the next camera',
await page.evaluate(() => window.__qaFormCalls.length === 2),
);
await page.evaluate(() => {
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
for (const lang of ['en']) {
await page.evaluate((lang) => {
document.documentElement.lang = lang;
}, lang);
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
const layout = await page.evaluate(() => {
const ids = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
];
return ids.map((id) => {
const node = document.getElementById(id),
rect = node.getBoundingClientRect(),
panel = node.closest('.panel-collapsible')?.getBoundingClientRect();
return {
id,
width: rect.width,
fits: rect.width > 0 && rect.width <= (panel?.width || innerWidth),
radius: getComputedStyle(node).borderRadius,
};
});
});
check(
`${lang} fields fit their panels at ${width}px`,
layout.every((item) => item.fits),
layout,
);
if (width === 390)
await page.screenshot({
path: `qa-shots/form-fields/${lang}-${width}.png`,
});
}
}
await page.evaluate(() => {
document.documentElement.lang = 'en';
document.documentElement.dataset.uiTheme = 'cyber';
});
const cyber = await page.$eval(
'#scene-select',
(node) => getComputedStyle(node).backgroundColor,
);
check(
'Cyber keeps its native field theme',
cyber === 'rgba(26, 35, 39, 0.96)',
cyber,
);
await begin('heading');
await page.evaluate(() => {
const owner = window.__godsEyeView.styleManager._cctvControls;
window.__qaDetachedInput = document.querySelector('.cctv-cal-input');
owner.destroy();
window.__qaDetachedInput.dispatchEvent(new Event('blur'));
});
check(
'disposal removes the editor and prevents late commits',
await page.evaluate(
() =>
!document.querySelector('.cctv-cal-input') &&
window.__qaFormCalls.length === 2,
),
);
// Scene selection can restore application settings. Exercise it after the
// fixture-owned CCTV phase so those asynchronous effects cannot replace ports.
page.once('dialog', (dialog) => dialog.accept('Baseline selection fixture'));
await page.click('#scene-new-btn');
await page.waitForFunction(() =>
[...document.querySelectorAll('#scene-select option')].some(
(option) => option.textContent === 'Baseline selection fixture',
),
);
page.once('dialog', (dialog) => dialog.accept('QA selection fixture'));
await page.click('#scene-new-btn');
await page.waitForFunction(
() => document.querySelectorAll('#scene-select option').length > 1,
);
const targetSceneId = await page.$eval(
'#scene-select',
(node) =>
[...node.options].find(
(option) => option.textContent === 'QA selection fixture',
).value,
);
const baselineSceneId = await page.$eval(
'#scene-select',
(node) =>
[...node.options].find(
(option) => option.textContent === 'Baseline selection fixture',
).value,
);
await page.select('#scene-select', baselineSceneId);
await page.waitForFunction(
(id) => window.__godsEyeView.sceneDirector._getSelectedScene().id !== id,
{},
targetSceneId,
);
await page.evaluate(() => {
window.__qaFormChanges = [];
});
await page.focus('#scene-select');
// Typeahead is native even when headless Chrome does not drive OS popups.
await page.keyboard.press('q');
const sceneSelection = await page.evaluate(() => ({
id: document.getElementById('scene-select').value,
selected: window.__godsEyeView.sceneDirector._getSelectedScene().id,
changes: window.__qaFormChanges,
focus: document.activeElement.id,
outline: getComputedStyle(document.activeElement).outlineStyle,
}));
check(
'native typeahead reaches the Scene owner and keeps focus',
sceneSelection.id === targetSceneId &&
sceneSelection.selected === targetSceneId &&
sceneSelection.changes.at(-1) === targetSceneId &&
sceneSelection.focus === 'scene-select',
sceneSelection,
);
check('keyboard focus remains visible', sceneSelection.outline === 'solid');
const localized = new URL(page.url());
localized.searchParams.set('lang', 'ru');
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._cctvControls &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(() => {
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
await page.evaluate(() => {
for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
const translated = await page.evaluate(() => {
const nodes = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
].map((id) => document.getElementById(id));
return (
document.documentElement.lang === 'ru' &&
nodes.every(
(node) =>
node.clientWidth > 0 &&
node.getBoundingClientRect().width <=
node.closest('.panel-collapsible').getBoundingClientRect().width,
)
);
});
check('real Russian labels and fields fit at 390px', translated);
await page.screenshot({ path: 'qa-shots/form-fields/ru-390.png' });
check('no uncaught browser errors', errors.length === 0, errors);
} finally {
await browser.close();
}
process.exitCode = failures ? 1 : 0;
+1 -1
View File
@@ -1884,7 +1884,7 @@ async function runBrowserGroup(record) {
await step('C15', async () => {
// CRT/NVG/FLIR are DISPLAY LABELS; the real style ids are retro /
// surveillance / thermal (src/ui.js STYLE_STATUS_LABELS). Passing the
// surveillance / thermal (src/ui/visualPresets.js STYLE_STATUS_LABELS). Passing the
// labels made every call a silent no-op that still "passed" — so assert
// the returned visual state actually changed to the requested style.
const wanted = [['retro', 'CRT'], ['surveillance', 'NVG'], ['thermal', 'FLIR'], ['normal', 'Normal']];
-8
View File
@@ -86,14 +86,6 @@ function percentile(values, pct) {
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * pct) - 1)];
}
function parseJson(value, fallback) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function analyzeMembership(samples) {
let replacements = 0;
let exposure = 0;
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Real-browser proof for manager-owned periodic work and document visibility.
* Uses the app's development-only layer registration seam; no provider keys.
* Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173
* Hiding uses a real background tab, never a patched document.hidden getter.
*/
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = new URL(
(urlIndex >= 0 ? args[urlIndex + 1] : undefined) ||
process.env.QA_BASE_URL ||
'http://localhost:4173',
);
url.searchParams.set('welcome', '0');
const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(),
headless: true,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const check = (name, actual, expected) => {
assert.equal(actual, expected, name);
console.log(`PASS ${name}: ${actual}`);
};
try {
const page = await browser.newPage();
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager,
);
await page.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
window.__qaVisibilityUpdates = 0;
window.__gevQaRegisterLayer(manager, {
id: 'qa-document-visibility',
name: 'Visibility QA',
updateInterval: 750,
init() {},
enable() {},
disable() {},
destroy() {},
async update() {
window.__qaVisibilityUpdates++;
return true;
},
getStats() {
return { count: window.__qaVisibilityUpdates };
},
});
await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' });
});
const count = () => page.evaluate(() => window.__qaVisibilityUpdates);
await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3);
check('periodic updates run while visible', (await count()) >= 3, true);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
const cover = await browser.newPage();
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
const hiddenCount = await count();
await pause(2200);
check('hidden tab makes no periodic updates', await count(), hiddenCount);
check(
'explicit refresh remains available while hidden',
await page.evaluate(() =>
window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'),
),
true,
);
check('explicit hidden refresh runs once', await count(), hiddenCount + 1);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.waitForFunction(
() =>
document.visibilityState === 'visible' &&
window.__qaVisibilityUpdates > 0,
);
await pause(200);
check(
'resume performs one overdue refresh without a catch-up burst',
await count(),
hiddenCount + 2,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'qa-document-visibility',
false,
{ origin: 'qa' },
),
);
const stopped = await count();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await pause(1100);
check(
'disabled layer does not resume on visibility change',
await count(),
stopped,
);
check(
'QA layer destroys cleanly',
await page.evaluate(() =>
window.__gevQaUnregisterLayer(
window.__godsEyeView.dataManager,
'qa-document-visibility',
),
),
true,
);
console.log('RESULT: 7 passed');
} finally {
await browser.close();
}
+84 -3
View File
@@ -156,14 +156,50 @@ const pressTabs = async (count, backwards = false) => {
// The row scrolls smoothly when native Tab brings an edge button into view.
await new Promise((resolve) => setTimeout(resolve, 250));
};
// Phones (≤720px) present the dock's Presets inline in the More tab's sheet.
// It opens from the keyboard, as a keyboard user would, so the focus it moves
// into the sheet shows a ring.
const openPhoneMoreSheet = async () => {
const tab = '#mobile-tab-bar [data-mobile-sheet="more"]';
if (await page.evaluate(() => document.body.dataset.mobileSheet === 'more')) {
await page.focus(tab);
await page.keyboard.press('Enter');
}
await page.focus(tab);
await page.keyboard.press('Enter');
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
};
// Media-query changes reach the page on a rendering step; wait until the
// phone shell agrees with the viewport before measuring.
const waitForShellSettled = () => page.waitForFunction(() => (
document.body.classList.contains('mobile-shell')
=== matchMedia('(max-width: 720px)').matches
), { timeout: 10_000, polling: 100 });
// The dock slides back into place when the layout leaves the phone shell.
const waitForDockSettled = () => page.waitForFunction(() => (
!document.getElementById('command-dock').getAnimations({ subtree: true })
.some((animation) => animation.playState === 'running')
), { timeout: 5_000 });
const checkResponsiveStyleFocus = async (width) => {
// The disclosure is only the starting boundary. Every style target below is
// reached by actual Tab events, never by focusing the style under test.
const selectedMapBefore = await page.evaluate(() => (
window.__godsEyeView.styleManager.mapStackController.getActiveId()
));
await page.focus('#control-panel-toggle');
await pressTabs(2); // disclosure -> pin -> Normal
if (width <= 720) {
// Phones show Presets in the More tab's sheet, which focuses its first
// visible control (Normal) when it opens; the tray toggle and pin are
// desktop chrome there.
await openPhoneMoreSheet();
} else {
await page.focus('#control-panel-toggle');
await pressTabs(2); // disclosure -> pin -> Normal
}
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
if (index) await pressTabs(3);
const state = await styleFocusMetrics();
@@ -215,7 +251,16 @@ const layerFocusState = async (id) => ({
...await page.evaluate((layerId) => {
const manager = window.__godsEyeView.dataManager;
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
const list = document.getElementById('data-toggles');
// The list scrolls on its own on desktop; in a phone sheet the sheet
// scrolls instead. Measure whichever container actually scrolls.
let list = document.getElementById('data-toggles');
for (let node = list; node; node = node.parentElement) {
const overflow = getComputedStyle(node).overflowY;
if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) {
list = node;
break;
}
}
return {
lifecycle: manager.getLayerLifecycleState(layerId),
feedState: button?.dataset.feedState, label: button?.textContent.trim(),
@@ -1146,6 +1191,8 @@ try {
await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 });
await new Promise((resolve) => setTimeout(resolve, 180));
await waitForShellSettled();
await openPhoneMoreSheet();
const at620 = await trayMetrics();
check(
'620 px tray uses two rows without clipping',
@@ -1179,7 +1226,13 @@ try {
await page.screenshot({ path: path.join(shotsDir, '480-open.png') });
await checkResponsiveStyleFocus(480);
// Pinning and the compact wing are the narrow desktop dock (721–900 px);
// phones use the tab bar instead.
await page.setViewport({ width: 800, height: 900, deviceScaleFactor: 1 });
await waitForShellSettled();
await waitForDockSettled();
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
await waitForDockSettled();
await page.click('#control-panel-toggle');
const cdp = await page.createCDPSession();
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
@@ -1374,6 +1427,27 @@ try {
for (const width of [1000, 620, 480]) {
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
await waitForShellSettled();
if (width <= 720) {
// Phones present Location as the Search tab's sheet: the keyboard opens
// it onto the field, and Escape returns focus to the tab.
const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]';
await page.focus(searchTab);
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 250));
const field = await focusMetrics('#location-search');
check(`Search: ${width} px keyboard opens the sheet on the location field`,
field.focusedId === 'location-search' && field.visible && field.insideViewport,
JSON.stringify(field));
await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) });
await page.keyboard.press('Escape');
await new Promise((resolve) => setTimeout(resolve, 250));
const tab = await focusMetrics(searchTab);
const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet);
check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`,
closedSheet && hasVisibleControlFocus(tab), JSON.stringify({ closedSheet, tab }));
continue;
}
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false });
@@ -1489,6 +1563,12 @@ try {
if (dataSetup.ready) {
for (const width of [1000, 620, 480]) {
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
await waitForShellSettled();
if (width <= 720 && !(await page.evaluate(() => document.body.dataset.mobileSheet === 'layers'))) {
// Phones present Data Layers in the Layers tab's sheet.
await page.focus('#mobile-tab-bar [data-mobile-sheet="layers"]');
await page.keyboard.press('Enter');
}
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
// Resizing schedules rail placement on animation frames. Wait for that
// pass and its CSS transitions before comparing a focus rectangle with
@@ -1549,6 +1629,7 @@ try {
)));
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
await waitForShellSettled();
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
await page.focus('#data-panel .panel-collapse-btn');
const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`);
+10 -1
View File
@@ -306,7 +306,16 @@ try {
await observeNetwork(page);
page.on('pageerror', (error) => result.errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error' && result.consoleErrors.length < 30)
if (message.type() !== 'error') return;
// A keyless server does not offer optional Google tokens. Keep every other
// HTTP or JavaScript error visible, including failures of this route other
// than its expected capability-miss 404.
const optionalTokenMiss =
message.location().url === new URL('/api/google/tiles-token', url).href &&
/^Failed to load resource: the server responded with a status of 404\b/.test(
message.text(),
);
if (!optionalTokenMiss && result.consoleErrors.length < 30)
result.consoleErrors.push(message.text());
});
page.on('requestfailed', (request) => {
+6 -5
View File
@@ -1291,8 +1291,6 @@ async function main() {
const clusterBadge = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
const { radioStationIdFromPick } = await import('/src/data/radio.js');
const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision;
const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations');
@@ -2761,13 +2759,16 @@ async function main() {
const beforeSpace = radio.getUIState().audioState;
// Simulate an already-started hold-Space session; click-started open mic
// deliberately ignores Space takeover, and short taps never claim voice.
const priorPushToTalkMode = voice.pushToTalkMode;
voice.pushToTalkMode = true;
// The provider facade forwards reads, not writes. Set the mounted
// controller's input owner; assigning on the facade only shadows it.
const controller = voice.session.adapter.controller;
const priorPushToTalkMode = controller.pushToTalkMode;
controller.pushToTalkMode = true;
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
await new Promise((resolve) => setTimeout(resolve, 700));
const afterSpace = radio.getUIState().audioState;
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
voice.pushToTalkMode = priorPushToTalkMode;
controller.pushToTalkMode = priorPushToTalkMode;
await radio.play();
voice.setVoiceSpeaker('ai');
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
+5 -1
View File
@@ -66,7 +66,8 @@ try {
);
}),
);
await page.click('#scene-capture-btn');
await page.focus('#scene-capture-btn');
await page.keyboard.press('Space');
await page.waitForFunction(
() => document.querySelectorAll('.scene-shot-row').length === 1,
);
@@ -328,6 +329,9 @@ try {
await page.keyboard.press('Escape');
await page.screenshot({ path: path.join(shots, 'desktop.png') });
await page.setViewport({ width: 390, height: 844 });
// Phones present Scenes in the Layers tab's sheet.
await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]');
await new Promise((resolve) => setTimeout(resolve, 400));
await page.screenshot({ path: path.join(shots, 'narrow.png') });
check(
'Scene controls stay inside the narrow viewport',
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env node
/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
headless: true,
args: ['--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
const errors = [];
const failures = [];
page.on('pageerror', (error) => errors.push(error.message));
const check = (name, passed, detail = '') => {
console.log(
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
if (!passed) failures.push(name);
};
const base = process.env.QA_BASE_URL || 'http://localhost:4179';
const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls';
fs.mkdirSync(shots, { recursive: true });
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60000 },
);
await page.waitForFunction(
() =>
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
}),
);
const surface = await page.evaluate(() => {
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
const buttons = groups.flatMap((group) => [
...group.querySelectorAll('[role="radio"]'),
]);
return {
groups: groups.length,
buttons: buttons.length,
shared:
groups.every((g) => g.classList.contains('gev-segmented')) &&
buttons.every((b) => {
const style = getComputedStyle(b);
return (
b.classList.contains('gev-segment') &&
style.fontSize === '9px' &&
style.minHeight === '24px' &&
style.borderRadius === '0px'
);
}),
};
});
check(
'all six one-of-many groups use the shared surface',
surface.groups === 6 && surface.buttons === 15 && surface.shared,
JSON.stringify(surface),
);
await page.evaluate(() =>
document.getElementById('models3d-mode-all').click(),
);
check(
'model selection reaches both aircraft layers',
await page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
return (
['flights', 'military'].every(
(id) => dm.layers.get(id).module.getParams().models3dMode === 'all',
) &&
document
.getElementById('models3d-mode-all')
.getAttribute('aria-checked') === 'true'
);
}),
);
await page.focus('#models3d-mode-all');
await page.keyboard.press('ArrowLeft');
check(
'Display retains its click-only arrow policy',
await page.evaluate(
() =>
document
.getElementById('models3d-mode-all')
.getAttribute('aria-checked') === 'true',
),
);
await page.focus('#models3d-mode-proximity');
await page.keyboard.press('Space');
check(
'native Space selects model coverage',
await page.evaluate(
() =>
document
.getElementById('models3d-mode-proximity')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() =>
document.getElementById('detection-allocation-weighted').click(),
);
check(
'allocation selection still updates application tuning',
await page.evaluate(
() =>
window.__godsEyeView.styleManager._detectionAllocationPreference ===
'WEIGHTED' &&
document
.getElementById('detection-allocation-weighted')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() =>
document.querySelector('[data-shape="pin"]').click(),
);
check(
'drawing shape still uses its existing feature action',
await page.evaluate(
() =>
window.__gevDrawTool?.shape === 'pin' &&
document
.querySelector('[data-shape="pin"]')
.getAttribute('aria-checked') === 'true',
),
);
// Off-layer controls still own params; no catalog/provider request is needed.
await page.evaluate(() =>
document.querySelector('[data-sl-pano="flat"]').click(),
);
check(
'panorama choice reaches the Street Level layer',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getParams().pano === 'flat',
),
);
await page.evaluate(() =>
document.querySelector('[data-sl-pano="flat"]').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Home',
bubbles: true,
cancelable: true,
}),
),
);
check(
'Street Level Home retains selection and roving tab state',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getParams().pano === 'all' &&
document
.querySelector('[data-sl-pano="all"]')
.getAttribute('tabindex') === '0',
),
);
await page.evaluate(() =>
document.querySelector('[data-sl-render="fill"]').click(),
);
check(
'fit/fill keeps its stored render preference',
await page.evaluate(
() =>
localStorage.getItem('gev:street-level:render-mode') === 'fill' &&
document
.querySelector('[data-sl-render="fill"]')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() =>
document.querySelector('.ri-mode-btn[data-mode="ab"]').click(),
);
check(
'imagery mode reaches its controlled layer state',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('recent-imagery')
.module.getSnapshot().mode === 'ab',
),
);
await page.evaluate(() =>
document.getElementById('ri-mode').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
}),
),
);
check(
'imagery arrows wrap from the current selected value',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('recent-imagery')
.module.getSnapshot().mode === 'image' &&
document
.querySelector('.ri-mode-btn[data-mode="image"]')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager._setDetectionAllocation('ELASTIC', {
syncShare: false,
persist: false,
});
document
.getElementById('models3d-mode-proximity')
.scrollIntoView({ block: 'center' });
});
await page.screenshot({ path: `${shots}/desktop.png` });
await page.setViewport({ width: 620, height: 900 });
await page.evaluate(() =>
document
.getElementById('models3d-mode-proximity')
.scrollIntoView({ block: 'center' }),
);
await page.screenshot({ path: `${shots}/narrow.png` });
check(
'narrow selectors stay within their panel',
await page.evaluate(() =>
[...document.querySelectorAll('.pp-mode-seg')]
.filter((g) => g.getClientRects().length)
.every((group) => group.scrollWidth <= group.clientWidth + 1),
),
);
const cyber = await page.evaluate(() => {
const root = document.documentElement;
const previous = root.getAttribute('data-ui-theme');
root.setAttribute('data-ui-theme', 'cyber');
const selected = [
...document.querySelectorAll('.gev-segment[aria-checked="true"]'),
];
// Check the final theme colors, independent of software-GL frame cadence.
for (const button of selected)
for (const animation of button.getAnimations()) animation.finish();
const backgrounds = selected.map(
(button) => getComputedStyle(button).backgroundColor,
);
const radii = [...document.querySelectorAll('.gev-segmented')].map(
(group) => getComputedStyle(group).borderRadius,
);
const consistent =
selected.length === 6 &&
backgrounds.every((color) => color === backgrounds[0]) &&
radii.every((radius) => radius === '1px');
if (previous === null) root.removeAttribute('data-ui-theme');
else root.setAttribute('data-ui-theme', previous);
return { consistent, count: selected.length, backgrounds, radii };
});
check(
'Cyber theme styles every selected segment consistently',
cyber.consistent,
JSON.stringify(cyber),
);
await page.evaluate(async () => {
const { dataManager, styleManager } = window.__godsEyeView;
await dataManager.setEnabled('recent-imagery', true);
styleManager.setPanelCollapsed('recent-imagery-panel', false, {
persist: false,
syncShare: false,
});
});
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
await page.focus('.ri-mode-btn[aria-checked="true"]');
const modeLayout = await page.evaluate(() => {
const group = document.getElementById('ri-mode');
const bounds = group.getBoundingClientRect();
const style = getComputedStyle(group);
const top = bounds.top + parseFloat(style.borderTopWidth);
const bottom = bounds.bottom - parseFloat(style.borderBottomWidth);
const buttons = [...group.querySelectorAll('[role="radio"]')];
return {
visible: bounds.width > 0 && bounds.height > 0,
contained: buttons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.top >= top - 0.5 && rect.bottom <= bottom + 0.5;
}),
groupHeight: bounds.height,
buttonHeights: buttons.map(
(button) => button.getBoundingClientRect().height,
),
};
});
check(
`imagery mode buttons and focus outline fit their row at ${width}px`,
modeLayout.visible && modeLayout.contained,
JSON.stringify(modeLayout),
);
}
check('no uncaught browser errors', errors.length === 0, errors.join(' | '));
} catch (error) {
console.error('Browser errors:', errors);
await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {});
throw error;
} finally {
await browser.close();
}
console.log(`RESULT: ${failures.length} failures`);
process.exitCode = failures.length ? 1 : 0;
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* Lit airplane regression: StarrySky must match a black environment baseline.
* A white environment is a positive control: it must visibly brighten the
* same model. Each case uses a fresh scene and dynamic environment map.
* Usage: node scripts/qa-sky-model-lighting.mjs [--url http://localhost:4333/]
*/
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
const i = process.argv.indexOf('--url');
const base = i > 0 ? process.argv[i + 1] : 'http://localhost:4333/';
const url = new URL('/__qa_sky_model__', base);
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
});
try {
const page = await browser.newPage();
page.on('pageerror', (error) => console.log('PAGEERR', error.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
if (request.url() === url.href)
request.respond({
contentType: 'text/html',
body: '<!doctype html><html><body></body></html>',
});
else if (new URL(request.url()).origin !== url.origin) request.abort();
else request.continue();
});
await page.goto(url.href);
const results = await page.evaluate(async () => {
window.CESIUM_BASE_URL = '/cesium/';
// Use StarrySky's exact Vite dependency URL, including its version query.
const source = await fetch('/src/sky/starrySky.js').then((r) => r.text());
const cesiumUrl = source.match(
/from ["']([^"']*\/cesium\.js[^"']*)["']/,
)?.[1];
if (!cesiumUrl) throw new Error('run against the Vite dev server');
const C = await import(cesiumUrl);
const { StarrySky } = await import('/src/sky/starrySky.js');
const time = C.JulianDate.fromIso8601('2026-10-19T00:00:00Z');
const results = {};
for (const kind of ['black', 'starry', 'white']) {
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 300;
document.body.append(canvas);
const scene = new C.Scene({ canvas });
scene.rethrowRenderErrors = true;
let cubeMap;
try {
scene.backgroundColor = C.Color.BLACK;
scene.light = new C.DirectionalLight({
direction: new C.Cartesian3(1, 0, 0),
intensity: 0.2,
});
if (kind === 'starry') {
scene.skyBox = new StarrySky({ requestRender() {} });
scene.skyBox._loading = true; // visible sky is outside this probe
} else {
const value = kind === 'white' ? 255 : 0;
const face = {
width: 1,
height: 1,
arrayBufferView: new Uint8Array([value, value, value, 255]),
};
cubeMap = new C.CubeMap({
context: scene.context,
source: Object.fromEntries(
[...C.CubeMap.faceNames()].map((name) => [name, face]),
),
});
scene.skyBox = { _cubeMap: cubeMap, update() {}, destroy() {} };
}
const position = C.Cartesian3.fromDegrees(0, 0, 10000);
const model = await C.Model.fromGltfAsync({
url: '/models/airplane.glb',
modelMatrix: C.Transforms.eastNorthUpToFixedFrame(position),
});
scene.primitives.add(model);
scene.camera.lookAt(position, new C.HeadingPitchRange(0.4, -0.5, 45));
let framed = false;
for (let frame = 0; frame < 240; frame++) {
scene.initializeFrame();
scene.render(time);
if (model.ready && !framed) {
scene.camera.viewBoundingSphere(
model.boundingSphere,
new C.HeadingPitchRange(
0.4,
-0.5,
model.boundingSphere.radius * 3,
),
);
framed = true;
}
await new Promise(requestAnimationFrame);
}
if (!model.ready || !model.environmentMapManager.radianceCubeMap)
throw new Error(`${kind}: model or dynamic lighting did not load`);
scene.initializeFrame();
scene.render(time);
const gl = scene.context._gl;
const px = new Uint8Array(canvas.width * canvas.height * 4);
gl.readPixels(
0,
0,
canvas.width,
canvas.height,
gl.RGBA,
gl.UNSIGNED_BYTE,
px,
);
let total = 0;
let visible = 0;
for (let p = 0; p < px.length; p += 4) {
const luma = 0.299 * px[p] + 0.587 * px[p + 1] + 0.114 * px[p + 2];
total += luma;
if (luma > 1) visible++;
}
results[kind] = { total, visible };
} finally {
scene.destroy();
cubeMap?.destroy();
canvas.remove();
}
}
return results;
});
console.log(JSON.stringify(results));
assert.ok(results.black.visible > 100, 'the lit model must be visible');
assert.ok(
Math.abs(results.starry.total - results.black.total) <=
results.black.total * 0.01,
'StarrySky must match black environment lighting within 1%',
);
assert.ok(
results.white.total > results.black.total * 1.03,
'white environment control must brighten the model by at least 3%',
);
console.log(
'PASS: lit airplane matches black sky; white control is brighter',
);
} finally {
await browser.close();
}
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env node
/** Badge acceptance on dev/preview. Camera state is a disposable controller fixture. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: ['--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
page.setDefaultNavigationTimeout(60000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let checks = 0;
let failures = 0;
const check = (name, passed, detail) => {
checks++;
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) {
failures++;
console.log(JSON.stringify(detail));
}
};
const out = 'qa-shots/status-badges';
fs.mkdirSync(out, { recursive: true });
try {
for (const lang of ['en', 'ru']) {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://127.0.0.1:4189'}/?welcome=0&lang=${lang}`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.styleManager?._cctvControls &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { dataManager, styleManager: manager } = window.__godsEyeView;
await dataManager.setEnabled('wind', true);
manager.setPanelCollapsed('weather-panel', false, {
persist: false,
syncShare: false,
});
manager.setPanelCollapsed('cctv-panel', false, {
persist: false,
syncShare: false,
});
const owner = manager._cctvControls;
owner._cctvUnsubscribe?.();
owner._cctvUnsubscribe = null;
owner.actions = { ...owner.actions, isEnabled: () => true };
owner._renderCctvState({
enabled: true,
activeCameraId: 'badge-fixture',
cameras: [{ id: 'badge-fixture', city: 'QA', name: 'Badge fixture' }],
activeCamera: {
id: 'badge-fixture',
city: 'QA',
headingDeg: 30,
pitchDeg: -10,
fovDeg: 45,
rangeM: 120,
mountHeightM: 8,
sourceKind: 'snapshot',
sourceStatus: 'degraded',
calBadge: 'curated',
calDirty: false,
},
});
window.__qaBadgeText =
document.getElementById('cctv-source-badge').textContent;
});
await page.waitForSelector('.weather-coverage.gev-status-badge');
const inventory = await page.evaluate(() => {
const source = document.getElementById('cctv-source-badge');
const quality = document.getElementById('cctv-quality-chip');
const coverage = [...document.querySelectorAll('.weather-coverage')];
return {
shared: [source, quality, ...coverage].every((node) =>
node.classList.contains('gev-status-badge'),
),
live:
source.getAttribute('role') === 'status' &&
source.getAttribute('aria-live') === 'polite',
silent: [quality, ...coverage].every(
(node) =>
!node.hasAttribute('aria-live') &&
!node.hasAttribute('role') &&
!node.hasAttribute('tabindex'),
),
coverage:
coverage.length > 0 &&
coverage.every(
(node) =>
node.textContent &&
!node.hidden &&
node.classList.contains('gev-status-badge--plain'),
),
source: source.textContent,
};
});
check(
`${lang}: CCTV and populated Weather badges share the view`,
inventory.shared && inventory.coverage,
inventory,
);
check(
`${lang}: existing announcement and read-only semantics remain intact`,
inventory.live && inventory.silent,
inventory,
);
check(
`${lang}: source wording uses the active locale`,
lang === 'en'
? inventory.source === 'SNAPSHOT · DEGRADED'
: inventory.source !== 'SNAPSHOT · DEGRADED',
inventory,
);
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
const result = await page.evaluate((state) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
? 'true'
: '';
owner._cctvFrameWrap.classList.toggle(
'has-frame',
state === 'retained',
);
owner._syncCctvSourceBadge(
owner._cctvState.activeCamera,
state !== 'idle',
);
const badge = document.getElementById('cctv-source-badge');
return {
state: badge.dataset.frameState,
text: badge.textContent,
color: getComputedStyle(badge).color,
};
}, state);
const expected = {
loading: ['loading', 'rgb(255, 229, 155)'],
error: ['error', 'rgb(255, 176, 176)'],
retained: ['ready', 'rgb(157, 239, 255)'],
ready: ['ready', 'rgb(157, 239, 255)'],
idle: ['idle', 'rgb(157, 239, 255)'],
}[state];
check(
`${lang}: ${state} keeps its frame state and palette`,
result.state === expected[0] &&
result.color === expected[1] &&
(!['ready', 'retained'].includes(state) ||
result.text === inventory.source),
result,
);
}
for (const [calBadge, dirty, color] of [
['raw-prior', false, 'rgb(143, 231, 255)'],
['curated', false, 'rgb(255, 230, 162)'],
['calibrated', false, 'rgb(173, 255, 191)'],
['curated', true, 'rgb(255, 208, 138)'],
]) {
const result = await page.evaluate(
({ calBadge, dirty }) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
document.getElementById('cctv-next-btn').focus();
owner._renderCctvState({
...owner._cctvState,
activeCamera: {
...owner._cctvState.activeCamera,
calBadge,
calDirty: dirty,
},
});
const badge = document.getElementById('cctv-quality-chip');
return {
state: badge.dataset.calBadge,
color: getComputedStyle(badge).color,
focus: document.activeElement.id,
};
},
{ calBadge, dirty },
);
check(
`${lang}: ${dirty ? 'edited' : calBadge} keeps provenance color and control focus`,
result.state === (dirty ? 'edited' : calBadge) &&
result.color === color &&
result.focus === 'cctv-next-btn',
result,
);
}
for (const theme of ['tactical', 'cyber']) {
await page.evaluate(
(theme) => window.__godsEyeView.styleManager._setHudVariant(theme),
theme,
);
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
for (const panelId of ['cctv-panel', 'weather-panel']) {
await page.evaluate(async (panelId) => {
const manager = window.__godsEyeView.styleManager;
for (const other of ['cctv-panel', 'weather-panel']) {
if (other !== panelId)
manager.setPanelCollapsed(other, true, {
persist: false,
syncShare: false,
});
}
manager.setPanelCollapsed(panelId, false, {
persist: false,
syncShare: false,
});
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
}, panelId);
const layout = await page.evaluate((panelId) => {
const selector =
panelId === 'cctv-panel'
? '#cctv-source-badge, #cctv-quality-chip'
: '.weather-coverage';
return [...document.querySelectorAll(selector)].map((node) => {
const rect = node.getBoundingClientRect();
const panel = node.closest(`#${panelId}`).getBoundingClientRect();
return {
id: node.id || 'weather-coverage',
text: node.textContent,
width: rect.width,
height: rect.height,
scroll: node.scrollWidth,
client: node.clientWidth,
left: rect.left,
right: rect.right,
panelLeft: panel.left,
panelRight: panel.right,
fits:
rect.width > 0 &&
rect.height > 0 &&
rect.left >= panel.left - 1 &&
rect.right <= panel.right + 1 &&
node.scrollWidth <= node.clientWidth + 1,
};
});
}, panelId);
check(
`${lang}/${theme}: ${panelId} badges fit at ${width}px`,
layout.length >= (panelId === 'cctv-panel' ? 2 : 1) &&
layout.every((node) => node.fits),
layout,
);
if (width === 390)
await page.screenshot({
path: `${out}/${lang}-${theme}-${panelId}-${width}.png`,
});
}
}
}
const disposed = await page.evaluate(() => {
const owner = window.__godsEyeView.styleManager._cctvControls;
const badge = document.getElementById('cctv-quality-chip');
const text = badge.textContent;
owner.destroy();
owner._renderCctvState(null);
return badge.textContent === text;
});
check(`${lang}: disposal prevents late state painting`, disposed);
}
check('no uncaught browser errors', errors.length === 0, errors);
} finally {
await browser.close();
}
console.log(`${checks} checks; ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+41 -1
View File
@@ -30,7 +30,6 @@ import {
import {
answerKartaViewRequest,
describeKartaViewCall,
KV_LINE,
KV_SEQUENCE_ID,
kartaViewPhotos,
resetKartaViewFixture,
@@ -661,6 +660,32 @@ async function main() {
},
);
await step(
'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open',
async () => {
await page.click('[data-sl-render="fill"]');
await uiUntil(page, (u) => u.street.renderMode === 'fill');
assert.equal(
await page.evaluate(() =>
localStorage.getItem('gev:street-level:render-mode'),
),
'fill',
);
await page.keyboard.press('Home');
await uiUntil(page, (u) => u.street.renderMode === 'letterbox');
assert.deepEqual(
await page.$eval('[data-sl-render="letterbox"]', (node) => ({
selected: node.getAttribute('aria-checked'),
tabIndex: node.tabIndex,
focused: document.activeElement === node,
})),
{ selected: 'true', tabIndex: 0, focused: true },
);
await page.keyboard.press('End');
await uiUntil(page, (u) => u.street.renderMode === 'fill');
},
);
await step('× closes the photo and deselects its sequence', async () => {
await press(page, '#sl-viewer-close');
await uiUntil(
@@ -673,6 +698,21 @@ async function main() {
);
});
await step(
'panorama segments keep native arrow and Home navigation',
async () => {
await page.click('[data-sl-pano="flat"]');
await uiUntil(page, (u) => u.filter.pano === 'flat');
await page.keyboard.press('ArrowRight');
await uiUntil(page, (u) => u.filter.pano === 'all');
await page.keyboard.press('End');
await uiUntil(page, (u) => u.filter.pano === 'flat');
await page.keyboard.press('Home');
await uiUntil(page, (u) => u.filter.pano === 'all');
await waitForCoverage(page);
},
);
await keylessProviderStep({
id: 'kartaview',
name: 'KartaView',
+1 -3
View File
@@ -80,9 +80,7 @@ try {
page.evaluate(() => {
const { viewer, dataManager } = window.__godsEyeView;
let upper = 0,
lower = 0,
near = 0;
const camera = viewer.camera.positionWC;
lower = 0;
dataManager.layers
.get('traffic')
.module.visitMotionDots((id, key, p) => {
+57 -55
View File
@@ -25,12 +25,13 @@
* Visual proof saved to qa-shots/ (gitignored).
*
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
* Add --fixtures for synthetic roads and the recorded flow tile without a key;
* that mode does not qualify live source access or quota accounting.
* Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key;
* that mode does not qualify geographic terrain, live source access or quota accounting.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import { captureQaScreenshot } from './qa-chrome.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -44,10 +45,12 @@ const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4410');
const appUrl = new URL(getOpt('--url', 'http://localhost:4410'));
appUrl.searchParams.set('welcome', '0');
const APP_URL = appUrl.href;
const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures');
const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null;
const { installTrafficRoutes } = await import('./traffic-fixtures.mjs');
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -80,6 +83,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Enable traffic + teleport, then poll the layer until settled. */
async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
// The app deliberately stops rendering hidden pages. Programmatic camera
// changes do not activate a headful window the way a real UI click does.
await page.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'visible');
return page.evaluate(async (v, minC, tS) => {
const gev = window.__godsEyeView;
const dm = gev.dataManager;
@@ -97,6 +104,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
});
let s = null;
for (let i = 0; i < tS; i++) {
if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused');
await new Promise((r) => setTimeout(r, 1000));
s = mod.getStats();
// Retained roads from the previous city can satisfy count/loading before
@@ -122,8 +130,8 @@ async function main() {
return;
}
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; live quota is not qualified.');
if (!statusBefore?.hasKey) {
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
process.exitCode = 2;
@@ -135,8 +143,10 @@ async function main() {
headless: HEADFUL ? false : 'new',
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
args: [
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
'--disable-dev-shm-usage', '--disable-web-security',
'--no-sandbox', '--disable-setuid-sandbox',
...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []),
'--disable-backgrounding-occluded-windows',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--window-size=1440,900',
],
@@ -144,24 +154,22 @@ async function main() {
let exitCode = 0;
try {
const page = await browser.newPage();
const onFixture = (request) => {
const response = fixtureResponse(request);
if (response) void request.respond(response); else void request.continue();
};
if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); }
let page = await browser.newPage();
// Keep this visible-page regression deterministic even if the OS starts
// Chrome minimized. Use browser focus emulation, not patched DOM getters.
const focusSession = await page.createCDPSession();
await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.setViewport({ width: 1440, height: 900 });
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
// Track flow-tile requests for the (iii) budget check.
const flowRequests = [];
page.on('request', (req) => {
if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url());
});
const trafficLogs = [];
page.on('console', (msg) => {
const t = msg.text();
if (t.includes('[Data:Traffic]')) trafficLogs.push(t);
});
const pageErrors = [];
if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) });
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -194,7 +202,7 @@ async function main() {
`coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`);
if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1;
await sleep(1200);
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') });
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL });
}
// ── (iv) uncovered-roads param — hide vs sim ─────────────────────────────
@@ -227,7 +235,7 @@ async function main() {
(hid.flowBuckets?.sim || 0) === 0 && hid.count > 0,
`count=${hid.count} sim=${hid.flowBuckets?.sim}`);
if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1;
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') });
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL });
} else {
record('PARAM: hide mode renders zero sim (white) dots', null,
'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests');
@@ -235,30 +243,26 @@ async function main() {
// ── (ii) C4 oblique bounds ───────────────────────────────────────────────
console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...');
trafficLogs.length = 0;
await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
{
const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('['));
let ok = false; let detail = 'no fetch log captured';
if (fetchLine) {
const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/);
if (m) {
const [s, w, n, e] = m.slice(1).map(Number);
const cLat = (s + n) / 2; const cLon = (w + e) / 2;
const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2);
ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin)
detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`;
}
const bounds = oblique?.roadBounds;
let ok = false; let detail = 'no road bounds published';
if (bounds) {
const cLat = (bounds.south + bounds.north) / 2;
const cLon = (bounds.west + bounds.east) / 2;
const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180));
ok = dKm > 1 && dKm <= 13;
detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`;
}
record('C4: oblique fetch box centers on the look-at point', ok, detail);
if (!ok) exitCode = 1;
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') });
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL });
}
// ── (iii) budget honesty ─────────────────────────────────────────────────
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
{
const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json());
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
const ok = grew >= 0 && grew <= flowRequests.length;
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok,
@@ -268,25 +272,22 @@ async function main() {
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
if (FIXTURES) page.off('request', onFixture);
await page.setRequestInterception(true);
// Keyless is an independent fresh-session contract. Close the keyed page
// so its workers, terrain work and intercepted cache cannot leak into it.
await page.close();
page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
const keylessFocus = await page.createCDPSession();
await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
const keylessFlowReqs = [];
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/tomtom/status')) {
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) });
return;
}
if (url.includes('/api/tomtom/flow/')) {
keylessFlowReqs.push(url);
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
return;
}
const response = fixtureResponse(req);
if (response) { void req.respond(response); return; }
try { req.continue(); } catch { /* already handled */ }
await installTrafficRoutes(page, {
origin: appUrl.origin, fixtures: FIXTURES, keyless: true,
onFlow: url => keylessFlowReqs.push(url),
onError: error => pageErrors.push(error.message),
});
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
@@ -316,8 +317,9 @@ async function main() {
exitCode = 1;
}
await sleep(1000);
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') });
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL });
}
record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; '));
} catch (e) {
console.error('\x1b[31mHarness error:\x1b[0m', e);
exitCode = 3;
+5
View File
@@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) {
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest();
return {
pageVisibility: document.visibilityState,
total: state._vehicles.size,
visible: state._visible.size,
moving: state._moving.size,
@@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) {
};
});
console.log(` ${label}: ${JSON.stringify(result)}`);
if (result.pageVisibility !== 'visible')
throw new Error(
`${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`,
);
if (result.renderLoop === false || result.renderErrors.length)
throw new Error(
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
+5
View File
@@ -1,6 +1,7 @@
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const BOSTON = {
id: 'boston',
feedId: 'mbta',
label: 'Boston (MBTA)',
lat: 42.3554,
lon: -71.0605,
@@ -11,6 +12,7 @@ const LIVE_CITIES = [
BOSTON,
{
id: 'austin',
feedId: 'capmetro-austin',
label: 'Austin (CapMetro)',
lat: 30.2672,
lon: -97.7431,
@@ -19,6 +21,7 @@ const LIVE_CITIES = [
},
{
id: 'minneapolis',
feedId: 'metrotransit-msp',
label: 'Minneapolis (Metro Transit)',
lat: 44.9778,
lon: -93.265,
@@ -27,6 +30,7 @@ const LIVE_CITIES = [
},
{
id: 'helsinki',
feedId: 'hsl-helsinki',
label: 'Helsinki (HSL)',
lat: 60.1699,
lon: 24.9384,
@@ -35,6 +39,7 @@ const LIVE_CITIES = [
},
{
id: 'oslo',
feedId: 'entur-norway',
label: 'Oslo (Entur)',
lat: 59.9139,
lon: 10.7522,
+2 -2
View File
@@ -1,6 +1,6 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -307,7 +307,7 @@ try {
result.pixelPass = pixels.pass;
result.pixelDetails = pixels;
result.errors = errors;
await page.screenshot({ path: `${shots}/recovery.png` });
await captureQaScreenshot(page, { path: `${shots}/recovery.png` });
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
const pass =
+7 -6
View File
@@ -1,3 +1,4 @@
import { captureQaScreenshot } from './qa-chrome.mjs';
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
// Not registered or active: camera discovery and live polls never own this feed.
@@ -219,7 +220,7 @@ export async function runFleetBudgets({
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-fleet-${count}.jpg`,
type: 'jpeg',
quality: 85,
@@ -392,7 +393,7 @@ export async function runBostonMatrix({
JSON.stringify({ pixels, contrast }),
{ unexercised: pixels.length < 6 },
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
type: 'jpeg',
quality: 90,
@@ -407,7 +408,7 @@ export async function runBostonMatrix({
target.key,
);
await wait(500);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
type: 'jpeg',
quality: 90,
@@ -431,7 +432,7 @@ export async function runBostonMatrix({
);
}
await wait(300);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
type: 'jpeg',
quality: 90,
@@ -579,7 +580,7 @@ export async function runBostonLive({
JSON.stringify(selection),
{ unexercised: !target },
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
type: 'jpeg',
quality: 90,
@@ -1010,7 +1011,7 @@ export async function runTrailVisibility({
stable: pixels.stable[i],
eligible: pixels.eligible[i],
}));
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
type: 'jpeg',
quality: 95,
+67 -22
View File
@@ -36,10 +36,11 @@ import {
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js';
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
import {
reduceAnchor,
@@ -47,8 +48,6 @@ import {
reduceScenario,
reduceSensorContrast,
reduceScriptedMotion,
reduceMotion,
reduceFleet,
} from '../src/layers/transit/qaMetrics.js';
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
@@ -266,6 +265,7 @@ const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-backgrounding-occluded-windows',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1920,1080',
@@ -273,6 +273,10 @@ const browser = await puppeteer.launch({
],
});
const page = await browser.newPage();
// This gate measures visible-page motion. OS minimization must not silently
// switch it into the app's intentional hidden-tab polling pause.
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
boundPageEvaluations(page);
await page.setViewport({ width: 1920, height: 1080 });
const pageErrors = [];
@@ -288,11 +292,13 @@ page.on('request', (request) => {
});
const shot = (name, caption) =>
page
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
.then(() => {
console.log(` shot ${name} — ${caption}`);
});
captureQaScreenshot(page, {
path: `${SHOTS}/${name}`,
type: 'jpeg',
quality: 82,
}).then(() => {
console.log(` shot ${name} — ${caption}`);
});
const view = (city) =>
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
@@ -391,22 +397,42 @@ try {
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
});
}, city);
await page.waitForFunction(
() =>
(window.__godsEyeView.dataManager.layers
.get('transit')
.module.getStats().count || 0) > 0,
{ timeout: 90_000 },
);
const arrived = await page
.waitForFunction(
(feedId) =>
[
...window.__godsEyeView.dataManager.layers
.get('transit')
.module._transitStateForTest()
._vehicles.keys(),
].some((key) => key.startsWith(`${feedId}:`)),
{ timeout: 90_000 },
city.feedId,
)
.then(
() => true,
() => false,
);
if (!arrived) {
check(
`${city.label}: live feed arrived`,
false,
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
{ unexercised: true },
);
continue;
}
await wait(9_000);
const fleet = await page.evaluate(() => {
const fleet = await page.evaluate((feedId) => {
const layer =
window.__godsEyeView.dataManager.layers.get('transit').module;
return {
count: layer._transitStateForTest()._vehicles.size,
count: [...layer._transitStateForTest()._vehicles.keys()].filter(
(key) => key.startsWith(`${feedId}:`),
).length,
stats: layer.getStats(),
};
});
}, city.feedId);
check(
`${city.label}: vehicles are rendered`,
fleet.count >= 20,
@@ -428,6 +454,7 @@ try {
.module._transitStateForTest();
let best = null;
for (const entry of state._vehicles.values()) {
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
if (!best || d < best.d)
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
@@ -1609,6 +1636,7 @@ try {
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
];
const frames = [];
const observedPolls = new Set();
const publicationsBefore = state._cardPublications || 0;
let headSamples = 0,
headErrorPx = 0,
@@ -1625,6 +1653,13 @@ try {
const proj = scene.camera.frustum.projectionMatrix;
const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
if (
entry.marker.show !== false &&
rect &&
Number.isFinite(entry.pollSeq)
) {
observedPolls.add(entry.pollSeq);
}
frames.push({
t: performance.now() - t0,
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
@@ -1670,7 +1705,15 @@ try {
headSamples++;
}
}
if (performance.now() - t0 >= durationMs) finish();
const elapsed = performance.now() - t0;
// Keep at least 20 seconds of motion and require an observed poll
// transition. A fixed 20s wall-clock window can miss a 15s poll
// when an earlier refresh is still settling.
if (
(elapsed >= 20_000 && observedPolls.size >= 2) ||
elapsed >= durationMs
)
finish();
};
let remove;
const finish = () => {
@@ -1678,6 +1721,7 @@ try {
remove?.();
resolve({
selected: true,
elapsedMs: performance.now() - t0,
frames,
textPublishes:
(state._cardPublications || 0) - publicationsBefore,
@@ -1692,7 +1736,7 @@ try {
});
},
target.key,
20_000,
3 * TRANSIT_POLL_MS,
);
}
check(
@@ -1713,8 +1757,9 @@ try {
);
check(
'card text publication stays throttled while the anchor runs at frame rate',
anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3,
`${anchor.textPublishes} text publications in 20 s`,
anchor.textPublishes > 0 &&
anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
);
} else {
check(
+1 -1
View File
@@ -295,7 +295,7 @@ async function main() {
);
// Wait for the photoreal tileset and at least one vessel refresh.
const settled = await page
await page
.waitForFunction(() => {
const gev = window.__godsEyeView;
const t = gev.tileset;
+32
View File
@@ -183,6 +183,38 @@ try {
return manager.bloomEnabled !== before;
}),
);
const sliders = await page.evaluate(() => {
const ordinary = [
...document.querySelectorAll('input[type="range"]'),
].filter(
(input) => !input.matches('#radio-tuner-slider, .bhote-event-range'),
);
return {
count: ordinary.length,
shared: ordinary.every((input) =>
input.classList.contains('gev-quantitative-slider'),
),
surface: ordinary.every((input) => {
const style = getComputedStyle(input);
return (
style.height === '18px' &&
style.appearance === 'none' &&
style.padding === '0px' &&
style.borderWidth === '0px' &&
(!input.disabled ||
(style.opacity === '0.42' && style.cursor === 'not-allowed'))
);
}),
tunerSeparate: !document
.getElementById('radio-tuner-slider')
.classList.contains('gev-quantitative-slider'),
};
});
check(
'ordinary sliders share one native surface',
sliders.count > 10 && sliders.shared && sliders.surface,
);
check('radio tuning keeps its specialized control', sliders.tunerSeparate);
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
await page.setViewport({ width: 620, height: 900 });
+5 -3
View File
@@ -1745,8 +1745,9 @@ async function runBehaviorLayer() {
'behavior: analyst follow-up re-filters the remembered set',
`count=${r?.count} followUp=${r?.coverage?.followUp}`,
);
// A marine region resolves from the bundled Natural Earth pack in the page,
// without the slower geocode fallback.
// Require the bundled boundary source and the existing whole-query limit.
// This elapsed time includes record acquisition and filtering; the region
// resolver has its own separate fallback budget.
const gulfStarted = Date.now();
r = await run('analyst_query', {
layers: ['flights'],
@@ -1757,9 +1758,10 @@ async function runBehaviorLayer() {
report(
r?.ok === true &&
String(r?.coverage?.scope || '').includes('Gulf of Mexico') &&
r?.coverage?.scopeSource === 'natural-earth' &&
gulfMs < 3000,
'behavior: analyst resolves the Gulf of Mexico from the bundled pack',
`scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`,
`scope=${r?.coverage?.scope} source=${r?.coverage?.scopeSource} ms=${gulfMs} err=${r?.error || ''}`,
);
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
+1 -7
View File
@@ -8,13 +8,7 @@
* node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google
* [--layer weather-satellite] [--product clouds] [--headless]
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
+28 -4
View File
@@ -61,6 +61,10 @@ const browser = await puppeteer.launch({
const boot = async () => {
const page = await browser.newPage();
// This probe measures active rendering; an OS-minimized window must not
// silently exercise the application's intentional hidden-page pause.
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
const errors = [];
page.on('pageerror', (e) =>
@@ -141,7 +145,7 @@ const snapshot = (page) =>
),
},
weather,
windCells: wind.gpu?.pathCount ?? wind.paths ?? null,
windCells: wind.gpu?.pathCount ?? wind.paths ?? 0,
enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id),
};
});
@@ -220,7 +224,7 @@ try {
const baseline = await snapshot(a.page);
console.log('baseline', JSON.stringify(baseline));
for (const id of WEATHER) await setLayer(a.page, id, true);
await a.page
const weatherReady = await a.page
.waitForFunction(
() =>
['weather-radar', 'weather-satellite', 'weather-lightning'].every(
@@ -233,7 +237,13 @@ try {
),
{ timeout: 120_000 },
)
.catch(() => console.log(' !! weather not ready'));
.then(
() => true,
() => false,
);
results.weatherReady = weatherReady;
if (!weatherReady)
console.log(' !! weather not ready — feature coverage incomplete');
await sleep(2000);
// exercise: history playback, wind reading, storm selection, product switch
await a.page.evaluate(() => {
@@ -331,11 +341,25 @@ try {
'errors (control run):',
results.errorsB.length ? results.errorsB : 'none',
);
// Resource ownership is an acceptance check; provider availability remains
// separately reported diagnostic evidence in both runs.
if (
!weatherReady ||
Object.keys(leak).length ||
[...a.errors, ...b.errors].some((e) => e.startsWith('pageerror:'))
) {
process.exitCode = 1;
}
} catch (error) {
results.failure = { name: error.name, message: error.message };
throw error;
} finally {
// Preserve completed resource evidence even if a later provider or browser
// phase aborts. A partial run still exits nonzero.
writeFileSync(
path.join(outDir, 'results.json'),
JSON.stringify(results, null, 2),
);
console.log(`wrote ${path.join(outDir, 'results.json')}`);
} finally {
await browser.close();
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { readdirSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { pathToFileURL } from 'node:url';
export const ALLOCATION_TEST_FILES = Object.freeze([
'src/data/focusAllocations.test.mjs',
+21 -4
View File
@@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
}
/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */
export function resolveOpenSkyCredentialsFile({
authoritativeEnvironment = false,
environment = process.env,
rootDir = ROOT,
} = {}) {
const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE');
const value = authoritativeEnvironment && environmentDefines
? String(environment.OPENSKY_CREDENTIALS_FILE || '')
: String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir);
return isConfiguredValue(value.trim());
}
function hasKeychainItem(service, account) {
if (process.platform !== 'darwin') return false;
const result = spawnSync('security', [
@@ -153,10 +166,11 @@ export function resolveCredential(spec, {
export function buildCapabilitySummary(
credentials,
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {},
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {},
) {
const configured = (name) => credentials[name]?.configured === true;
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true;
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
? requestedOpenSkyMode
@@ -164,11 +178,13 @@ export function buildCapabilitySummary(
const flights = openSkyMode === 'anon'
? 'OpenSky keyless anonymous access (rate-limited)'
: openSkyMode === 'basic'
? 'OpenSky Basic mode selected (credential presence and validity not verified)'
? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)'
: openSkyMode === 'auto'
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
: hasOAuthCredentials
: hasClientPair
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
: hasOAuthCredentials
? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)'
: 'OpenSky keyless anonymous access (rate-limited)';
const route = selectMapStartupRoute({
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
@@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
credentials,
capabilities: buildCapabilitySummary(credentials, {
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }),
}),
};
}
+34 -51
View File
@@ -94,6 +94,7 @@
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
@@ -2695,16 +2696,17 @@ async function main() {
// reappear wearing its stale/creation-north nose for up to 1 s).
// Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then
// trigger each path with the camera idle. The idle-drift catch-up
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
// ~300 ms SwiftShader frame window, so a regression shows as an
// intermittent (not guaranteed) failure — never a false failure.
// trigger each path with the camera idle. The fleet runs every 80 ms,
// independently of rendered FPS. Observe the first completed settle and
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
// rotation refresh can repair an incorrect first reveal.
// ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager;
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
// 3D models OFF for this phase: a model-handed-off billboard is hidden
// and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from).
@@ -2766,39 +2768,29 @@ async function main() {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
// (a) settle pass: tamper, then raise moveEnd with the camera IDLE —
// the pose signature is unchanged, so only the moveEnd hook can fix
// this before the 1 s catch-up.
bb.rotation = r0 + 1.5;
v.camera.moveEnd.raiseEvent();
await nextFrames(2);
const afterMoveEnd = bb.rotation;
// A real-GPU fleet tick can land near the edge of the frame budget and
// advance the contact before the probe reads it. Compare with the
// production projection at the CURRENT position, not the now-stale r0.
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
// visible AND correct the nose in that same tick (camera still idle,
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
// the flip landed on (flipFrame; -1 = never within the extended
// window) and whether a 3D model owns this id — distinguishes "tick
// slow under load" from "occluder says hidden" from "model handoff".
bb.rotation = r0 + 1.5;
bb.show = false;
let flipFrame = -1;
for (let f = 1; f <= 6; f++) {
await nextFrames(1);
if (bb.show && flipFrame === -1) flipFrame = f;
if (f >= 2 && flipFrame !== -1) break;
}
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
const observed = await observeArrivalRotation({
scene: v.scene,
startMoveEnd() {
// Only moveEnd can repair the tampered angle at the idle pose.
bb.rotation = r0 + 1.5;
v.camera.moveEnd.raiseEvent();
},
startReveal() {
// Hide immediately after the confirmed settle frame. The next
// eligible fleet tick must reveal AND re-aim in that same tick.
bb.rotation = r0 + 1.5;
bb.show = false;
},
read() {
return {
shown: bb.show,
rotationError: angDiff(bb.rotation,
screenProjectedRotation(v.scene, bb.position, course, null)),
};
},
});
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
return {
r0,
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
dReveal: angDiff(bb.rotation, expectedReveal),
shown: bb.show, flipFrame, hasModel,
};
return { r0, ...observed, hasModel };
};
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
@@ -2807,22 +2799,13 @@ async function main() {
military: await probe('bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
// shows the military flip consistently lands on frame 2 — the exact edge
// of the old budget — so any load hiccup flaked it (flights lands frame
// 1). The regression this protects against is a reveal that waits for
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
// catches that with a wide margin, with zero-headroom flakes gone.
const arrOk = (a) => !!a && !a.error && a.shown
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrOk(arrival.flights), fmtArr(arrival.flights));
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrOk(arrival.military), fmtArr(arrival.military));
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.military), fmtArr(arrival.military));
// ============================================================
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
Loaded 100 of 495 files, more files were not shown because too many files have changed in this diff. Show more