Compare commits

..
Author SHA1 Message Date
Said 664c18d97a Merge latest CCTV foundations and retain reviewed follow-up fixes 2026-10-19 23:50:21 +01:00
Said fb3506cc20 Record refreshed Kartverket PR ancestry without changing the tested tree 2026-10-19 23:19:13 +01:00
Said f927ef288a Check tracking rotation on the first visible frame within fleet cadence 2026-10-19 23:16:02 +01:00
Said d90bae49b6 Record refreshed Tarktee PR ancestry with reviewed fixes preserved 2026-10-19 23:06:45 +01:00
Said ea09a59877 Validate CCTV terrain regime and frame calibration drag fixture 2026-10-19 23:05:38 +01:00
Said 5b4f5ed4a0 Fix CCTV provider cancellation and credential-safe errors 2026-10-19 23:05:37 +01:00
Said 1bccc295f0 Merge remote-tracking branch 'origin/pr945' into codex/cctv-shared-batch-20261019 2026-10-19 22:50:20 +01:00
Said 4416a491d2 Merge remote-tracking branch 'origin/pr1228' into codex/cctv-shared-batch-20261019 2026-10-19 22:50:20 +01:00
Said 4171ebb6a8 Merge current upstream CCTV fixes into review candidate 2026-10-19 22:49:17 +01:00
Said 7cdaf40fd0 docs(cctv): retain Ontario licence attribution in batch candidate 2026-10-19 14:00:35 +01:00
Said 0a4d520e79 Merge remote-tracking branch 'origin/pr1200' into codex/cctv-shared-batch-20261019 2026-10-19 13:51:26 +01:00
Said 695cce967d Merge remote-tracking branch 'origin/pr1201' into codex/cctv-shared-batch-20261019 2026-10-19 13:51:25 +01:00
Said 42c95a8a24 Merge remote-tracking branch 'origin/pr945' into codex/cctv-shared-batch-20261019 2026-10-19 13:51:25 +01:00
Said 29d8fb4101 Merge remote-tracking branch 'origin/pr1228' into codex/cctv-shared-batch-20261019 2026-10-19 13:51:17 +01:00
Said 7a31ef5e8a Integrate PR #1233 while preserving the shared 30-second timeout 2026-10-19 13:51:17 +01:00
Said 7dfecd7d98 Merge remote-tracking branch 'origin/pr1234' into codex/cctv-shared-batch-20261019 2026-10-19 13:50:20 +01:00
Said ea06c9fc64 Merge remote-tracking branch 'origin/pr1133' into codex/cctv-shared-batch-20261019 2026-10-19 13:50:20 +01:00
Said Turksever 77dfa718a9 Merge branch 'main' into fix/qa-cctv-v2-headful 2026-10-14 17:44:50 +01:00
Said Turksever 436ffbb21c Merge branch 'main' into fix/qa-cctv-v2-headful 2026-10-14 16:55:46 +01:00
Said TurkseverandClaude Opus 5.5 1a0b9c8b0f fix(qa-cctv-v2): keep long headful waits under puppeteer's protocolTimeout
The headful drain budget is uncapped (camCount * 800 + 30000 ms, ~50 min
at N≈3,700), but each page.waitForFunction is one CDP call that puppeteer
aborts after its 180 s default protocolTimeout. The .catch then recorded
"timed out waiting for drain on the real-GPU sign-off path" after ~3 min.

- Poll the drain in <=120 s slices (waitForFunctionSliced) so no single
  CDP call outlives protocolTimeout, whatever the catalog size.
- Launch with protocolTimeout 240 s, headroom above the largest slice and
  the 60 s fixed waits.
- Bound the sibling-owner 3-frame postRender wait at 10 s and report the
  frame count, so a renderer that stops producing frames fails with
  evidence instead of hanging the evaluate.
- List --disable-backgrounding-occluded-windows explicitly. Puppeteer
  25.10 already passes it by default, so this documents the headful
  contract rather than changing behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 13:08:35 +01:00
484 changed files with 19312 additions and 30777 deletions

No files matched your search

-6
View File
@@ -228,9 +228,6 @@ AISSTREAM_API_KEY=
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras
# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today)
# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras
# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200
# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL
# CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras
# CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog
# CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras
@@ -250,9 +247,6 @@ AISSTREAM_API_KEY=
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md)
# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first)
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
+6
View File
@@ -67,6 +67,8 @@ jobs:
timeout-minutes: 30
env:
GEV_REQUIRE_ALLOCATION_GATE: '0'
# Fail on a missed header press unless the panel itself moved.
QA_FAIL_ON_RETRY: '1'
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -102,6 +104,10 @@ jobs:
- name: Street Level gate with Mapillary fixtures
run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173
- name: Portable panel gate
if: success() || failure()
run: npm run qa:panel-resize -- --url http://localhost:4173
- name: Preview server log
if: failure()
run: cat preview.log
-4
View File
@@ -16,10 +16,6 @@ output/
.gev-cache/
# Browser QA gate evidence (screenshots, console and network dumps).
qa-artifacts/
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
.playwright-mcp/
# Local Claude Code settings and preview configs.
.claude/
.DS_Store
.gstack/
3d-models/
+20 -14
View File
@@ -317,8 +317,9 @@
[NotProniss](https://github.com/NotProniss) (#1084).
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
(orange; off by default, one provider at a time): below about 60 km the
federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
(orange; off by default, one provider at a time): from orbit dots show
where the federated commons has pictures, and below about 60 km its
sequences draw as lines from `api.panoramax.xyz` vector tiles. 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 and ‹ › /
← → following the picture's own links. The 360° and since filters apply,
@@ -376,10 +377,10 @@
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
open Google's own panorama in
the panel, with its arrows, the address and month in the caption, a link
to Google Maps. Share links carry it in the provider switches
to Google Maps and FOLLOW. Share links carry it in the provider switches
(`r.google-1`). One provider is lit at a time (lighting one switches the
other off), Mapillary by default since it is free. Street View's panoramas
are all 360°, so the IMAGERY and SINCE filters apply to
are all 360°, so the IMAGERY and SINCE filters and the legend apply to
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
is a Google-blue view cone.
@@ -418,16 +419,21 @@
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
- Street Level: street-level imagery from providers, Mapillary first (free
client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider,
360°/flat and captured-since filters and an embedded viewer with EXPAND and
FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and
Google 3D tiles; opening a photo frames it with one camera flight. Share
links carry the provider switches, the filters and the panel's collapsed
state. A server proxy adds the token to Mapillary coverage tiles, trims and
caches them for 24 h, refuses cross-site requests and redirects, and limits
load per IP and upstream. The panel reads KEY REQUIRED without a token and
KEY REJECTED when Mapillary refuses it.
- Street Level: a street-level imagery layer modelled on the iD editor's photo
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit
down to a single street; on Google 3D they sit on the bare earth, refined
against the rendered mesh near the camera, so trees and buildings hide them.
Share links carry the provider switches, the filters and the panel's collapsed
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
rejects the token or rate-limits.
- The CCTV and Street Level panels are portable: drag the header to float the
panel, resize it from any edge or corner, and double-click the header to dock
it again. The position persists across reloads.
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
+9 -7
View File
@@ -54,8 +54,9 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
Runnable gate entrypoints document what they assert and how to invoke them.
Most need a dev server; some also need a specific provider key or port. Follow
the entrypoint's header rather than assuming every matching file accepts the
same arguments. Four have an `npm run` alias: `qa:map-source-tray`,
`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`.
same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
`qa:transit`.
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
for the feature: it names the gate for many of them, and it's the authoritative
@@ -65,10 +66,11 @@ gate for the feature you changed.
> **CI covers only two of these for you.** The workflow runs the setup policy
> checks, the formatting and package-boundary checks, the unit suite and the
> production build, plus a Windows onboarding job. It runs neither
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
> gate (`street-level-browser` job) — they need a live dev server and a
> browser. Include the applicable local run in your PR's validation evidence.
> production build, plus a Windows onboarding job, and against the built preview
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
> server and a browser. Include the applicable local run in your PR's
> validation evidence.
## Share-link layer tokens
@@ -116,7 +118,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/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/ui.js` and `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.
+2 -12
View File
@@ -20,10 +20,10 @@ How to read this:
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
@@ -63,7 +63,6 @@ How to read this:
| **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner |
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" |
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
@@ -113,7 +112,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
@@ -170,7 +168,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK.
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public/<cameraId>/manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover.
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
@@ -205,8 +202,6 @@ 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) |
@@ -279,11 +274,6 @@ 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
+5 -15
View File
@@ -177,16 +177,6 @@ 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>
@@ -391,9 +381,9 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
@@ -435,9 +425,9 @@ _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 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.
**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. 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 and legend 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, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and 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**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). 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`.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. 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`.
## 🎖️ Field Missions
@@ -488,7 +478,7 @@ How the globe handles live data:
```
src/
├── main.js # Bootstrap: Google 3D tiles, layer registration
├── ui/ # Runtime UI — panels, HUD, styles, controls
├── ui.js # Runtime UI — panels, HUD, styles, control facade
├── 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
+2 -9
View File
@@ -29,7 +29,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
@@ -69,13 +69,11 @@ 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. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless 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.
- **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.
@@ -100,11 +98,6 @@ 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
+1 -72
View File
@@ -47,75 +47,6 @@ 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 = [],
@@ -130,9 +61,7 @@ export function createBrowserViteConfig({
} = {}) {
return {
plugins: [
// 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.
// First, so no provider route answers a Host the server does not allow.
hostCheckPlugin(),
cesium(),
applicationHtmlPlugin(),
@@ -0,0 +1,6 @@
- Make CCTV QA ground-sampling checks respect the active terrain regime and
frame the actual calibration gizmo for the drag regression, preserving the
photoreal completion floor and all drag invariants.
- Measure the tracking arrival regression on the first visible frame within
250 ms, accounting for the 80 ms fleet cadence while rejecting a stale
first-visible orientation instead of retrying until it is correct.
-3
View File
@@ -1,3 +0,0 @@
- Report actual outcomes when voice enables or disables CCTV through the shared
layer lifecycle, including failed and cancelled changes (manjunath22466,
#1205).
+2
View File
@@ -2,3 +2,5 @@
free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is
otherwise skipped with one log line saying where to get a key
(quanticsoul4772, #1228).
- Refuse Ontario catalog redirects and keep credential-bearing URLs and provider
exception text out of failure logs; retain the Ontario licence attribution.
@@ -1,4 +1,5 @@
- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects,
cancel both response streams when either response fails or overflows the 4 MB
byte cap, and deduplicate camera IDs before per-pack prioritization
(yaka0007, #1233).
abort pending siblings and cancel received or late response streams when either
request fails before headers or overflows the 4 MB
byte cap, retain the shared 30 s dual-XML fetch deadline, and deduplicate camera
IDs before per-pack prioritization (yaka0007, #1233).
-3
View File
@@ -1,3 +0,0 @@
- 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).
-4
View File
@@ -1,4 +0,0 @@
- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV:
~1,800 cameras read at runtime from the official MOTC 2.0 open-data table,
with on-demand snapshots and attribution. No API key is required; set
`CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237).
-5
View File
@@ -1,5 +0,0 @@
- 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).
@@ -1,9 +0,0 @@
- 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
@@ -1,3 +0,0 @@
- 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).
-1
View File
@@ -1 +0,0 @@
- Coalesce refreshes and cool down opensky failures (asturksever, #1269).
-1
View File
@@ -1 +0,0 @@
- Reject inherited style keys during restore (asturksever, #1271).
-3
View File
@@ -1,3 +0,0 @@
- 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
@@ -1 +0,0 @@
- Bound each height request to one upstream chunk (asturksever, #1276).
-1
View File
@@ -1 +0,0 @@
- Cull and budget surface height sampling (asturksever, #1277).
@@ -1,3 +0,0 @@
- Report cached CelesTrak satellite records as stale in the Data Layers row,
preserving separate core and dense catalog provenance through refresh,
removal and lifecycle cleanup (rudycelekli, #1283).
@@ -1,3 +0,0 @@
- Keep aircraft route labels on the scheduled airport leg instead of accepting
its distant great-circle extensions, preserving generous endpoint tolerance
across the signed-angle branch cut (rudycelekli, #1284).
-4
View File
@@ -1,4 +0,0 @@
- 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
@@ -1,3 +0,0 @@
- 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).
-4
View File
@@ -1,4 +0,0 @@
- 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).
@@ -1,5 +0,0 @@
- 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).
@@ -1,3 +0,0 @@
- 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).
@@ -1,3 +0,0 @@
- 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
@@ -1,4 +0,0 @@
- 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).
@@ -1,3 +0,0 @@
- 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).
@@ -1 +0,0 @@
- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317).
-6
View File
@@ -1,6 +0,0 @@
- 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).
-4
View File
@@ -1,4 +0,0 @@
- 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).
-6
View File
@@ -1,6 +0,0 @@
- Street Level is rebuilt around one small provider registry: Mapillary,
KartaView, Panoramax and Google Street View share one coverage drawer and
one photo viewer, one source lit at a time. Floating and docked panels,
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
deleted (gekh, #974).
-7
View File
@@ -1,7 +0,0 @@
- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV
source pack: the public DATA.GOV.HK English locations XML, frames pinned to
`tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By
default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads,
prioritized across Hong Kong Island, Kowloon and the New Territories. Headings
use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets
the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990).
-5
View File
@@ -5,11 +5,6 @@ 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
+9 -45
View File
@@ -79,6 +79,7 @@ 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 |
@@ -86,8 +87,9 @@ 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 one exact standalone-import exception is `server/providers/local.js` to
`server/standalone/key-setup.js`. It does not authorize new compatibility
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
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.
@@ -235,7 +237,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/applicationShell.js` retains panel layout, persistence, share restoration and application
`src/ui.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.
@@ -354,8 +356,8 @@ drag or final change. Feature adapters own data mapping and service subscription
generic modules own DOM/listeners/timers and release them in `destroy()`.
Keep persistence with panel chrome: collapse uses
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
Do not rename existing
ids or reset persistence versions when adding a body. The WEATHER adapter is
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
@@ -372,15 +374,6 @@ 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
@@ -468,8 +461,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/controls.js` provides the running
application's instances. The shell
rendering, HUD and share operations. `src/standalone/ui.js` provides the running
application's instances; `src/ui.js` remains the compatibility entry. 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
@@ -736,32 +729,3 @@ 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.
+91 -311
View File
@@ -1,29 +1,5 @@
# God's Eye View Current State
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.
Screen annotations skip hidden records and, before surface sampling, reject an
unsettled anchor only when its whole accepted height range (-430 to 9000 m) is
behind the camera or below the globe horizon; a settled height is tested as the
actual point. Anchors outside the conservative frustum still project at their
cached or zero height, so a partly visible arrow keeps its connector, but do
not sample. Unresolved visible heights share a 32-sample projection-pass budget,
served oldest-refused first, retry at most once per second per anchor on
rendered frames, and retain no extra timer or listener. Validated heights remain
settled in the bounded cache.
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.
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,
@@ -45,16 +21,6 @@ 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
@@ -285,7 +251,6 @@ 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
@@ -659,14 +624,6 @@ 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.
@@ -694,7 +651,7 @@ The UI facade composes separate owners for navigation authority, share restorati
visual settings and panel chrome. Navigation owns request generations and follow
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
visual settings own style/detection overrides and sensor cleanup; panel chrome
owns disclosure and Cockpit panel snapshots. Each takes explicit
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
operations and reads instead of the complete shell. Existing controls, share
formats and one-application-per-page behavior are preserved.
@@ -838,15 +795,6 @@ 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.
@@ -869,11 +817,7 @@ Viewport limits, saturation retry, placement, query caps and controls are retain
Satellite and mission layers expose separate factories through `./layers/satellites`
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
and teardown. Satellite core and dense catalogs retain upstream stale-cache
provenance only for admitted records; duplicate or invalid entries cannot taint
a fresh winning record. Dense removal clears only its own provenance, and a
fresh core rebuild or lifecycle reset replaces the corresponding ownership.
Source adapters retain the existing CelesTrak and Launch Library
and teardown. Source adapters retain the existing CelesTrak and Launch Library
endpoints; standalone entries provide scene services and the satellite dependency.
The mission factory separates paths, replay, camera, panel, overlays and placement.
Launch Library pad coordinates retain unknown values: missing fields use the
@@ -1033,9 +977,10 @@ start with current state, isolate listener failures and stop on disposal.
The standalone entry composes the UI with the application's existing layer,
terrain, navigation and rendering operations. The shell receives those instances
and assembles the controls. Panel layout scheduling, loading notices, recording presentation and DOM lookup have focused
and assembles the controls. Panel layout scheduling, position preferences and
active drags, loading notices, recording presentation and DOM lookup have focused
owners. Disposal revokes queued presentation and listeners before asynchronous
Context restoration, restores the
Context restoration, cancels an unfinished drag without saving it, restores the
recording HUD and releases status decoration without replacing accessible text.
The ordinary control snapshot includes the current 3D model toggle and mode.
@@ -1148,26 +1093,6 @@ The images are periodically updated road-condition photographs, not continuous
video; camera poses use the existing low-confidence heading prior.
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
## Taiwan CCTV source pack
The CCTV catalog includes Taiwan national freeway cameras from the Freeway
Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table
(~1,800 cameras); the request refuses redirects, has a 15-second deadline and a
6 MiB response cap, and uses the shared 15-minute cache and fair catalog
allocation. A failed download serves the last good table (up to a day old),
or an empty pack, and is not retried for an hour: the catalog host does not
answer from some networks outside Taiwan, and without the backoff every refresh
there would wait out the connect timeout. The table
publishes MJPEG stream URLs; each is mapped to the same camera server's
single-JPEG path for the same camera number, and only those exact HTTPS pairs
on the four `freeway.gov.tw` camera servers are registered with the frame
proxy. Camera keys come from the server and camera number, because published
CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use
the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack;
`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000), and
`CCTV_TAIWAN_URL` overrides the table URL.
Gate: `src/data/cctvTaiwan.test.mjs`.
## DriveBC CCTV source pack
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
@@ -1214,24 +1139,6 @@ 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
@@ -1277,84 +1184,6 @@ 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
@@ -1389,32 +1218,6 @@ 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. 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.
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
@@ -2930,16 +2733,11 @@ 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` 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.
> - **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.
> - **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
@@ -3188,13 +2986,6 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> position getter reads only `_trackedDisplayCached`, the same authoritative
> Cartesian already consumed by the tracked visual and camera, and never
> advances dead reckoning from the host frame.
> - **Scheduled route plausibility:** Aircraft route labels and selected-route
> context reject the distant backwards, forwards and antipodal extensions of
> an airport pair's great circle. The existing 130 km endpoint admission and
> 200 km cross-track tolerance remain; a projected fix outside the finite
> origin-to-destination minor arc also has a generous 200 km extension at
> either end. Missing coordinates and coincident or antipodal endpoint pairs
> retain their loose, unjudgeable behavior; vertical-trend checks are unchanged.
> - **Aircraft label convention (both flight layers, 2026-08-18):** every civil
> and military label surface resolves **callsign → registration → icao24**
> (`_contactLabel()` in `flights.js`; the same chain inline in
@@ -3580,7 +3371,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in
Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
@@ -3591,8 +3382,7 @@ Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The
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. Chinese tags map to `zh-CN`; tags containing
`Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop
`navigator.languages` → English. `?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
@@ -3631,7 +3421,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/` and `src/hud.js`
- Scene/HUD/style systems in `src/ui.js` 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)
@@ -3658,16 +3448,16 @@ its criteria cannot be silently ignored.
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.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) |
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (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, cached 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
@@ -3699,8 +3489,8 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
collapsed. It opens itself only when the user, voice or a tool switches the
layer on or a photo opens, never on a restore, and those opens are not stored.
Like the iD editor's photo overlay, it has one chip per registered provider,
@@ -3712,30 +3502,31 @@ sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers'
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
Street View, KartaView and Panoramax are registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile.
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
the panel says to zoom in. Tiles come from the visible ground within range of
the camera, nearest first, at most 9 (the halves of a view across
the date line count a shared tile once). Lines are draped
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
and Google 3D tiles alike; cones and the photo marker are ground-clamped
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
pauses them for its Retry-After.
Mapillary coverage ranks unique tile addresses before applying the 16-tile
overview budget. Overlapping dateline halves in a polar view therefore do not
waste overview slots on the same tile; loading already skips duplicate keys.
The server proxy (`server/providers/mapillary.js`) adds the token to
coverage-tile requests (the browser holds it too, for MapillaryJS and the Graph
API), serves only z11–14 coverage, refuses upstream redirects, strips the
unused `image` layer, shares one fetch per tile, runs at most six at once
(cancelling those every client has left) and keeps tiles in memory for 24 h
(a body too big to be a trimmed tile is served but not kept). While Mapillary
rate-limits, the proxy answers 429 itself until the Retry-After (seconds or an
HTTP date, at most 10 minutes) is over, so the rest of a view does not go on
asking.
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
the mesh hides what is behind buildings and trees. A tile is drawn draped until
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
steep streets and roads under trees. It uses the mesh floor's rules (the
visible tileset has finished streaming, a real bare-earth prior, the shared
mesh window) and probes a cell again once the camera is half as far from it,
down to 40 m, so coarse early samples are replaced as finer tiles load. A
failed probe keeps the last good sample and distance and is retried after the
miss cooldown. Roads on elevated decks are still drawn at
ground level. Framing a photo ignores mesh samples far below the bare earth
(unloaded tiles).
The header pill is the layer switch. Lighting a chip swaps the source;
clicking the lit chip turns the layer off. A chip without a usable key
@@ -3746,18 +3537,19 @@ their keys but a switched-off one would draw with a key already set (no
Mapillary token, Street View on Google 3D with a Google key), the layer
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
line and the layer row's tooltip say to light it. The
viewer sits under the header with EXPAND, FIT/FILL and close above the
image. SINCE is a stepped slider whose readout names the cut-off date.
MapillaryJS loads on the first photo. Opening one frames it with a single
camera flight through the application's deferred navigation (`begin` /
`reassert`): it releases tracking, is refused in the cockpit, and is skipped
if newer navigation took the camera while the photo loaded; closing the photo
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
iPhone, which has no element fullscreen, the viewer opens as a top-layer
popover instead (hidden where neither API exists). Clean View, recording and
the cockpit take an expanded photo out of full screen. At phone width the
viewer height comes from the rail band so the whole photo fits.
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
stops when the stack changes. Camera moves go through the application's
navigation (`attachNavigation`), which releases aircraft and satellite tracking
and is refused in the cockpit: turning FOLLOW on claims the camera at once, and
FOLLOW stops when another feature takes it; a photo takes a deferred ticket
when it starts opening and frames only if nothing newer took the camera while
it loaded. SINCE is a stepped slider whose readout names the
cut-off date. The panel is portable (`street-level-panel` spec, minimum
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
docks it. At phone width (≤720 px) the viewer height is derived from the rail
band so the whole photo fits.
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
core routes clicks by pick prefix, swaps viewer adapters in the one host,
@@ -3766,10 +3558,9 @@ add a provider: implement the definition, register it in
`src/app/layers/streetLevel.js` and add its modules to
`scripts/package-boundaries.json`; its share switch travels in `r` by id.
A provider with line coverage builds it on `lineCoverage.js` (camera-driven
XYZ tiles near the ground, draped, the selected sequence drawn again over
them) and its cones
XYZ tiles, draped or cast to bare earth, selection recolouring) and its cones
on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick
prefixes; Mapillary, KartaView and Panoramax all do. With providers on different keys the
prefixes; Mapillary's `coverage.js` and `sequences.js` are such wrappers. With providers on different keys the
layer's `requiresKeyId` is null and each chip reports its own key.
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
@@ -3778,7 +3569,7 @@ on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
Its chip starts off (`defaultOn: false`; a link without its switch keeps it off) because every
panorama load is billed to the key. Google's terms forbid Street View beside
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
off Google 3D it does not appear at all (no chip, globe
off Google 3D it does not appear at all (no chip, legend swatch, globe
credit, layer source or button, and Google's script is not loaded), lookups
and opens skip it, and leaving Google 3D closes its panorama; the share
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
@@ -3800,8 +3591,9 @@ marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place
of GEV's amber pin. Its
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
(a provider's `filters` default to both `pano` and `since`): lookups ignore
the IMAGERY and SINCE filters, and the panel hides each filter row no lit provider
honours (`filterControls`). The viewer is Google's `StreetViewPanorama` in its own
the IMAGERY and SINCE filters, the panel hides each filter row no lit provider
honours (`filterControls`), and the legend lists only providers that draw
lines, with "Selected" only beside one of them. The viewer is Google's `StreetViewPanorama` in its own
element in the viewer host, without Google's fullscreen, close and address
controls (the address goes in the caption); a pose goes out at once and
again when the panorama's month, photographer and address arrive (one
@@ -3828,7 +3620,8 @@ are no KartaView photos in it. A tile's lines are OSM way segments with
photos and carry no sequence, date or projection, so `decode.js` chains
segments that meet end to start (about four times fewer lines; linear time,
at most 20,000 segments a tile and 5,000 points a line), they are
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted, and the
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted
(`highlightsSequence: false`, so the legend has no "Selected"), and the
provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N
streets in view · click a line for its nearest photo"). KartaView filters
coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`;
@@ -3837,7 +3630,8 @@ asked for), so a filtered tile is drawn from the cached JSON segments that
lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the
points sampled every 2 px have a drawn pixel within one; on Jakarta's 360°
filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between).
A filter change reloads the lines (`lineCoverage.reload`), keeps the JSON segments, and caches masks per
A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show
until the new ones are in), keeps the JSON segments, and caches masks per
filter and tile. A dense filtered PNG can take KartaView over a minute the
first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is
asked again 4 s later, four tries in all; then the panel says it did not
@@ -3848,8 +3642,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 "(by
upload date)" beside the SINCE value, as ANY DATE (by upload date) (`sinceNote` in the UI state). Tiles come uncompressed and
`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
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
@@ -3864,7 +3658,7 @@ remembered; a storage timeout or 5xx is asked again. The open photo's
sequence draws as cones from the 250-photo page holding it
(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos
(`countActivePhotos`, asked once per sequence). The camera direction (the
marker, cones and a panorama's centre) is the direction of travel
marker, cones, FOLLOW, and a panorama's centre) is the direction of travel
to the next photo a few metres on (`assignCourses`): KartaView is dashcam
imagery, while its `heading` is the device compass, which reads 0 for whole
drives without one and can sit a steady 110° off inside a car. Until the
@@ -3921,18 +3715,23 @@ to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
view draw their longest lines within 90,000 points (`maxPoints`,
`keepOrder`), split evenly between the tiles in view (10,000 points
each). Every point becomes ~8 ground-line vertices:
`keepOrder`), shared out once loading settles so a sparse tile keeps all
it has and a dense one (a city centre) takes the rest; the selected
sequence is always drawn. Every point becomes ~8 ground-line vertices:
unsimplified, the lines hung the tab. Decoded tiles are kept while the
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView).
Above 60 km nothing is drawn and the panel says to zoom in.
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView),
and above
60 km the `grid` layer's 0.1°
density cells, binned to at most 96×96 points a tile (a z0 tile holds
~70,000 cells) and not counted as sequences: the panel says to zoom in.
A tile decodes in slices of about 8 ms, the page getting a turn between
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
tiles back for its Retry-After, which the browser cannot read here (the API
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
answers; an answer may take as long as it keeps arriving (silent for 15 s
is given up), so a slow link still gets its megabyte tiles. Both
filters apply: lines carry their day and camera type. A click on a
filters apply: lines carry their day and camera type, and a cell stays while
it holds pictures of the kind asked for (cells are undated). A click on a
line asks the core (`actions.openNearest`) for that sequence's picture
nearest the click, within the camera height / 100 (50–400 m: from high up
a click lands well off a line), without framing, then any sequence's within
@@ -3981,9 +3780,8 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
from fixtures (`providers/mapillary/coverageFixture.mjs`,
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
needs no network. It opens and closes a photo from Mapillary, KartaView and
Panoramax; stepping, EXPAND, FIT/FILL, the filters and Street View stay with
the unit tests. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize`
against a production build served by `vite preview` with a dummy token.
Directions is a keyless front end to the routing the voice agent already
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
@@ -4648,7 +4446,7 @@ silently demoting every later lookup for the session.
requires a developer key, so the pack needs `ONTARIO_511_API_KEY` and is skipped without one
(#1228). All sources are RAW PRIOR
poses, and the layer is stills-first.
sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
@@ -4661,6 +4459,11 @@ silently demoting every later lookup for the session.
empty keeps the old catalog and is retried after another 15 minutes, not on the next
request. Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s
(`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s.
Ontario catalog requests refuse redirects and log only a fixed diagnostic for
transport/JSON failures, so reflected request URLs or keys cannot reach logs.
Tarktee owns both XML requests with one 30-second deadline and shared abort;
a failed header or body read cancels its sibling, including a response arriving
after cleanup.
Both Tarktee XML catalogs refuse redirects and have a 4 MiB per-document
limit, enforced against declared length and streamed bytes. A rejected fetch,
unsuccessful response, or body-read failure releases both available response
@@ -4756,12 +4559,6 @@ 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
@@ -5200,7 +4997,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/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.
- **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.
- **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
@@ -5210,7 +5007,7 @@ pins and share links already supply complete boxes.
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. `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)
@@ -5295,7 +5092,7 @@ pins and share links already supply complete boxes.
Pixel-mode pinch deltas receive bounded amplification before reaching the
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
unchanged. The application scene owns and removes the relay listener.
- Z ladder: panels around 100, voice pill 150, toast 200, clean-view exit 300.
- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300.
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
@@ -5409,7 +5206,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, `src/ui/visualPresets.js`
The value literals (engine constant, markup value, markup readout, `ui.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
@@ -5457,7 +5254,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 `src/ui/visualPresets.js`, and
`GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in `ui.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
@@ -5506,8 +5303,8 @@ easier to meet (detection is now on more often), but does not create it.
samples and constant elevation during E/N drag; one shared-floor resolution on
release; late one-shot shared-cell work is permitted during viewshed idle. The
A+B harness intentionally excludes citywide LOD assertions.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. Docked panels store nothing; a portable panel (CCTV, Street Level) stores `{ left, top, width, height, floating: true }` once a header drag lifts it out of the rail, and a header double-click removes it. Double presses are detected in the header's `pointerdown` handler, because its `preventDefault()` suppresses native `dblclick`. Collapsed state persists for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
@@ -6081,20 +5878,3 @@ 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.
+3 -2
View File
@@ -57,8 +57,9 @@ Related keys (current versions):
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
`'1'` closed, absent means the panel's own default. A view opened from a share
link ignores the stored value entirely.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the current layout
writes none.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
the panel header, docks the panel again.
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
---
+2 -2
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`).
Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`).
## 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), [Simplified Chinese](./translations/zh-CN.md). Read it before translating, and add a term there when you settle on one.
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md). Read it before translating, and add a term there when you settle on one.
-77
View File
@@ -1,77 +0,0 @@
# 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,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" />
<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" />
</head>
<body>
<!-- gev:template scene-chrome -->
+1
View File
@@ -49,6 +49,7 @@
"test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"qa:panel-resize": "node scripts/qa-panel-resize.mjs",
"qa:street-level": "node scripts/qa-street-level.mjs",
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
+1
View File
@@ -8,6 +8,7 @@ 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
@@ -1,158 +0,0 @@
#!/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
@@ -1,415 +0,0 @@
#!/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();
+4 -3
View File
@@ -24,10 +24,11 @@ const renderer = (file) =>
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
file,
);
// One compatibility composition entry intentionally selects standalone defaults.
// Two compatibility composition entries intentionally select standalone defaults.
const compatibilityEdge = (from, to) =>
from === 'server/providers/local.js' &&
to === 'server/standalone/key-setup.js';
(from === 'server/providers/local.js' &&
to === 'server/standalone/key-setup.js') ||
(from === 'src/ui.js' && to === 'src/standalone/catalog.js');
const portableExport = (key) =>
key.startsWith('./sources/') ||
/\/source$/.test(key) ||
+4 -8
View File
@@ -48,8 +48,7 @@ export function defaultGatewayFromRouteTable(text) {
/**
* Decide this launch's trusted-peer list. An operator value always wins, so a
* compose override stays authoritative; `none` trusts no additional peers.
* An unset or blank value means the gateway alone is
* compose override stays authoritative; otherwise 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
@@ -57,7 +56,6 @@ 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) ?? '';
}
@@ -111,17 +109,15 @@ 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.');
}
@@ -49,6 +49,11 @@ export function resetKartaViewFixture() {
nearestLookups = 0;
}
/** Nearest lookups answered since the last reset (the first got a 429). */
export function kartaViewLookups() {
return nearestLookups;
}
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
export function kartaViewPhotos() {
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
/**
* Mapillary Graph API and image CDN answered from fixtures, covering every
* request the provider and MapillaryJS make while opening and closing a
* photo, so the gate needs no network and no real token.
* request the provider and MapillaryJS make while opening, stepping and
* closing a photo, so the gate needs no network and no real token.
*
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
@@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({
north: 38.591,
});
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
const PHOTO_SPACING_M = 30;
export const PHOTO_SPACING_M = 30;
/** Fixture thumbnail host (answered, never reached). */
export const THUMB_HOST = 'qa-fixture.mapillary.com';
@@ -181,6 +181,18 @@ export function answerMapillaryRequest({ method, url, images }) {
: [];
return { ...json(200, { data }), known: true };
}
if (q.has('lat') && q.has('lng')) {
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
const radius = Math.min(50, Number(q.get('radius')) || 50);
const limit = Number(q.get('limit')) || 10;
// In no particular order, as the real API answers.
const data = photos
.filter((image) => metresApart(at, image) <= radius)
.reverse()
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
return {
...json(400, { error: 'unsupported images query' }),
+1 -1
View File
@@ -28,7 +28,7 @@ export const PNX_PARK = Object.freeze({
height: 700,
pitch: -1.4,
});
const PNX_CREATOR = 'Fixture Mapper';
export const PNX_CREATOR = 'Fixture Mapper';
const PNX_SPACING_M = 10;
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
const API = 'https://api.panoramax.xyz/api';
+35 -51
View File
@@ -2,8 +2,6 @@
".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/next-layer-state-token.mjs",
@@ -48,6 +46,9 @@
"scripts/qa-voice-auth-focus.mjs",
"src/codexOauthRealtime.test.mjs",
"src/voice/cloudVoiceAuth.test.mjs",
"scripts/qa-panel-resize.mjs",
"scripts/qa-panelDrag.mjs",
"src/tooling/panelResizeQa.test.mjs",
"scripts/qa-terrain-429.mjs",
"src/app/application.js",
"src/app/application.test.mjs",
@@ -114,14 +115,11 @@
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"src/data/cctvLithuania.test.mjs",
"server/providers/cctv/taiwan.js",
"src/data/cctvTaiwan.test.mjs",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"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",
@@ -184,6 +182,7 @@
"src/ui/panelRails.js",
"src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js",
"src/ui/panelResize.test.mjs",
"src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.test.mjs",
@@ -193,10 +192,7 @@
"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",
@@ -284,11 +280,11 @@
"src/ui/scenePresentation.js",
"src/ui/sceneControls.test.mjs",
"scripts/qa-scene-controls.mjs",
"scripts/qa-action-buttons.mjs",
"src/ui.js",
"src/standalone/ui.js",
"src/ui/applicationShell.js",
"src/ui/panelLayoutController.js",
"src/ui/panelPositionControls.js",
"src/ui/panelPositionControls.test.mjs",
"src/ui/recordingControls.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
@@ -306,8 +302,6 @@
"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",
@@ -452,7 +446,6 @@
"src/data/satellites.js",
"src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs",
"src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"src/layers/installations/lifecycle.js",
@@ -559,7 +552,6 @@
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs",
"src/data/cctvCataloniaSource.test.mjs",
"src/data/cctvHk.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvNswSource.test.mjs",
@@ -920,32 +912,27 @@
"src/ui/streetLevelPresentation.test.mjs",
"src/app/layers/streetLevel.js",
"src/tooling/mapillaryProvider.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/data/idSwitches.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/providerSwitches.test.mjs",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
"src/streetLevelMarkup.test.mjs",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/tileMath.js",
@@ -958,6 +945,7 @@
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/layers/streetLevel/providers/google/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
@@ -967,6 +955,8 @@
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
@@ -995,6 +985,8 @@
"src/testSupport/kartaViewFakes.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
"src/layers/streetLevel/tileMath.test.mjs",
@@ -1007,15 +999,23 @@
"scripts/fixtures/street-level/kartaView.mjs",
"scripts/fixtures/street-level/panoramax.mjs",
"src/layers/streetLevel/filter.test.mjs",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/groundCast.test.mjs",
"src/layers/streetLevel/meshSampler.test.mjs",
"src/layers/streetLevel/view.test.mjs",
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
"src/layers/streetLevel/cameraFraming.test.mjs",
"src/layers/streetLevel/cameraFollow.test.mjs",
"src/ui/streetLevelControls.test.mjs",
"src/ui/panelDock.test.mjs",
"src/layers/streetLevel/marker.test.mjs",
"src/layers/streetLevel/viewerHost.test.mjs",
"src/layers/streetLevel/sequenceCones.test.mjs",
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
"src/layers/streetLevel/selection.test.mjs",
"src/layers/streetLevel/index.test.mjs",
"scripts/qa-overpass-offload.mjs",
@@ -1070,21 +1070,5 @@
"scripts/assemble-changelog.mjs",
"src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs",
"src/data/routeSegment.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/terrainHeightsBounds.test.mjs"
]
+73 -137
View File
@@ -22,14 +22,7 @@
},
"viewer": {
"exports": ["./application/viewer"],
"modules": [
"src/app/viewer.js",
"src/app/atmosphereCompat.js",
"src/sky/starrySky.js",
"src/sky/stars.js",
"src/data/bundledJson.js",
"src/renderGovernor.js"
],
"modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"],
"external": ["cesium"]
},
"vite-build": {
@@ -194,13 +187,10 @@
"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",
"server/providers/cctv/lithuania.js",
"server/providers/cctv/taiwan.js",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
@@ -224,6 +214,8 @@
"exports": ["./server/providers/mapillary"],
"modules": [
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
"server/providers/common/same-site.js",
@@ -261,7 +253,6 @@
"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",
@@ -271,7 +262,6 @@
"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",
@@ -433,18 +423,16 @@
"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/zh-CN.json"
"src/i18n/locales/ru.json"
],
"external": []
},
"display-controls": {
"exports": ["./ui/display"],
"modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"],
"modules": ["src/ui/displayControls.js"],
"external": []
},
"visual-effects": {
@@ -463,8 +451,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -480,8 +467,7 @@
"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/i18n/locales/ru.json"
],
"external": []
},
@@ -495,7 +481,6 @@
"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",
@@ -510,11 +495,9 @@
"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/statusBadge.js"
"src/ui/statusText.js"
],
"external": []
},
@@ -530,8 +513,7 @@
"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/i18n/locales/ru.json"
],
"external": []
},
@@ -546,8 +528,7 @@
"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/i18n/locales/ru.json"
],
"external": []
},
@@ -557,7 +538,6 @@
"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",
@@ -566,9 +546,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/ui/statusBadge.js"
"src/ui/unitLabels.js"
],
"external": []
},
@@ -592,7 +570,6 @@
"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": []
@@ -626,7 +603,6 @@
"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"]
@@ -637,12 +613,10 @@
"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": []
@@ -651,8 +625,6 @@
"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",
@@ -702,7 +674,6 @@
"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",
@@ -731,7 +702,6 @@
"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",
@@ -754,6 +724,7 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/radio.js",
@@ -765,13 +736,11 @@
"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",
@@ -788,7 +757,6 @@
"src/ui/shellFacade.js",
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/policy.js",
"src/ui/localSdrControls.js",
"src/layers/localAdsb/feedNames.js",
"src/ui/localSdrPresentation.js",
@@ -797,12 +765,10 @@
"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/statusBadge.js"
"src/ui/statusText.js"
],
"external": ["cesium"]
},
@@ -880,7 +846,6 @@
"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",
@@ -917,7 +882,6 @@
"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"]
@@ -983,7 +947,6 @@
"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"]
@@ -1029,8 +992,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -1090,8 +1052,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -1120,7 +1081,6 @@
"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"
],
@@ -1149,8 +1109,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -1181,7 +1140,6 @@
"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"]
@@ -1213,8 +1171,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -1256,7 +1213,6 @@
"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"]
@@ -1291,7 +1247,6 @@
"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"]
@@ -1340,8 +1295,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
},
@@ -1369,8 +1323,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -1417,8 +1370,7 @@
"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/i18n/locales/ru.json"
],
"external": ["hls.js", "cesium"]
},
@@ -1431,13 +1383,15 @@
],
"modules": [
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/data/idSwitches.js",
"src/layers/streetLevel/params.js",
@@ -1445,8 +1399,9 @@
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -1473,14 +1428,14 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"scripts/qa-street-level.mjs",
"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/layers/streetLevel/providers/mapillary/policy.js"
"src/i18n/locales/ru.json"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
},
@@ -1507,8 +1462,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
},
@@ -1547,8 +1501,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -1569,8 +1522,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -1625,8 +1577,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -1682,7 +1633,6 @@
"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",
@@ -1771,8 +1721,6 @@
"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",
@@ -1854,6 +1802,7 @@
"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",
@@ -1907,7 +1856,6 @@
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/firstRunExperience.js",
"src/exclusiveSurfaces.js",
"src/hud.js",
"src/hudLocality.js",
"src/hudLocalityFormat.js",
@@ -2195,8 +2143,6 @@
"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",
@@ -2206,7 +2152,6 @@
"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",
@@ -2236,7 +2181,6 @@
"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",
@@ -2259,6 +2203,7 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/radio.js",
@@ -2270,13 +2215,11 @@
"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",
@@ -2480,22 +2423,24 @@
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"scripts/qa-street-level.mjs",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -2522,6 +2467,8 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -2534,6 +2481,7 @@
"src/data/cameraArrival.js",
"src/layers/alpr/floors.js",
"src/data/militaryNames.js",
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
@@ -2546,7 +2494,6 @@
"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",
@@ -2559,13 +2506,11 @@
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/voice/providerCommands.js",
"src/voice/providerCapabilities.js",
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js"
"src/voice/geminiCaptureWorklet.js"
],
"external": [
"hls.js",
@@ -3060,22 +3005,24 @@
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"scripts/qa-street-level.mjs",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -3102,6 +3049,8 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -3114,6 +3063,7 @@
"src/data/cameraArrival.js",
"src/layers/alpr/floors.js",
"src/data/militaryNames.js",
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
@@ -3122,7 +3072,6 @@
"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",
@@ -3132,8 +3081,7 @@
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
"src/ui/layerNames.js",
"src/layers/streetLevel/providers/mapillary/policy.js"
"src/ui/layerNames.js"
],
"external": [
"hls.js",
@@ -3229,7 +3177,6 @@
"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"]
@@ -3250,8 +3197,7 @@
"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/i18n/locales/ru.json"
],
"external": []
},
@@ -3275,18 +3221,15 @@
"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/zh-CN.json"
"src/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -3302,6 +3245,7 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/rightPanelRail.js",
@@ -3309,8 +3253,7 @@
"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/i18n/locales/ru.json"
],
"external": []
},
@@ -3401,7 +3344,6 @@
"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"]
@@ -3456,7 +3398,6 @@
"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"
],
@@ -3709,8 +3650,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
@@ -3752,8 +3692,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium", "pbf"]
},
@@ -3805,8 +3744,7 @@
"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/i18n/locales/ru.json"
],
"external": ["cesium"]
},
@@ -3822,7 +3760,6 @@
"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"
],
@@ -4015,7 +3952,6 @@
"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
@@ -1,335 +0,0 @@
#!/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
@@ -1,160 +0,0 @@
#!/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();
}
+2 -2
View File
@@ -7,10 +7,10 @@ import fs from 'node:fs/promises';
import path from 'node:path';
/** Console messages and failed requests kept per page. */
const EVIDENCE_LIMIT = 200;
export const EVIDENCE_LIMIT = 200;
/** Cesium's console line when the render loop throws (CesiumWidget). */
const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
/** Mask tokens in a URL or message before it is printed or saved. */
export function redact(text) {
-3
View File
@@ -370,9 +370,6 @@ 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',
+28
View File
@@ -1797,6 +1797,34 @@ async function main() {
gizmoStates.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' '),
);
// Frame the actual gizmo for the drag fixture. Default autofocus can leave
// its shaft below the 10px interaction threshold; that distant view is not
// the drag-usability scenario this regression exercises.
const dragFraming = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const e = viewer.entities.getById('cctv-gizmo-move-east');
if (!e) return null;
const pts = e.polyline.positions.getValue(viewer.clock.currentTime);
const target = pts?.[0];
if (!target) return null;
const measure = () => {
const a = viewer.scene.cartesianToCanvasCoordinates(pts[0]);
const b = viewer.scene.cartesianToCanvasCoordinates(pts[pts.length - 1]);
return {
shaftPx: a && b ? Math.hypot(b.x - a.x, b.y - a.y) : null,
rangeM: target.constructor.distance(viewer.camera.positionWC, target),
};
};
const before = measure();
viewer.camera.cancelFlight();
viewer.camera.lookAt(target, new target.constructor(0, -200, 180));
viewer.camera.lookAtTransform(viewer.camera.transform.constructor.IDENTITY);
viewer.scene.requestRender();
const frames = await window.__qaRenderFrames(3, 10000);
return { before, after: measure(), frames };
});
console.log('Drag fixture framing:', JSON.stringify(dragFraming));
// Synthetic CDP drag on the EAST arrow — the owner's field-test
// regression. Sample effective elevation after every mouse move: it must
// stay frozen until release, with no transient sampleHeight calls.
+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/shellFacade.js), the
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.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,6 +34,10 @@
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,6 +52,7 @@ 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
@@ -1,465 +0,0 @@
#!/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/visualPresets.js STYLE_STATUS_LABELS). Passing the
// surveillance / thermal (src/ui.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,6 +86,14 @@ 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;
+3 -84
View File
@@ -156,50 +156,14 @@ 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()
));
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
}
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();
@@ -251,16 +215,7 @@ const layerFocusState = async (id) => ({
...await page.evaluate((layerId) => {
const manager = window.__godsEyeView.dataManager;
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
// 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;
}
}
const list = document.getElementById('data-toggles');
return {
lifecycle: manager.getLayerLifecycleState(layerId),
feedState: button?.dataset.feedState, label: button?.textContent.trim(),
@@ -1191,8 +1146,6 @@ 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',
@@ -1226,13 +1179,7 @@ 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 });
@@ -1427,27 +1374,6 @@ 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 });
@@ -1563,12 +1489,6 @@ 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
@@ -1629,7 +1549,6 @@ 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`);
+1 -10
View File
@@ -306,16 +306,7 @@ try {
await observeNetwork(page);
page.on('pageerror', (error) => result.errors.push(error.message));
page.on('console', (message) => {
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)
if (message.type() === 'error' && result.consoleErrors.length < 30)
result.consoleErrors.push(message.text());
});
page.on('requestfailed', (request) => {
+482
View File
@@ -0,0 +1,482 @@
#!/usr/bin/env node
/**
* Browser QA for the floating CCTV panel: lift, resize from every handle,
* minimum size, reload, and dock again. `--fail-on-retry` (or
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
import {
describePress,
dockPanelByDoubleClick,
dragPointer,
liftPanelByHeader,
nextFrames,
} from './qa-panelDrag.mjs';
import {
hookRenderErrors,
readRenderErrors,
saveFailureArtifacts,
watchPage,
} from './qa-browserEvidence.mjs';
export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
export const MIN_SIZE = { width: 300, height: 160 };
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
/** Box edges a resize in `dir` must leave exactly where they were. */
export function pinnedEdges(dir) {
return Object.entries(EDGE_BY_SIDE)
.filter(([side]) => !dir.includes(side))
.map(([, edge]) => edge);
}
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
export function growthDelta(dir, amount) {
return {
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
};
}
/**
* Viewport point inside the handle for `dir`, given the panel's client rect.
* Edge strips straddle the border by 3px; corners and the grip sit inside.
*/
export function handlePoint(rect, dir) {
const inset = dir.length === 2 ? 4 : 1;
const x = dir.includes('w')
? rect.left + inset
: dir.includes('e')
? rect.right - inset
: rect.left + rect.width / 2;
const y = dir.includes('n')
? rect.top + inset
: dir.includes('s')
? rect.bottom - inset
: rect.top + rect.height / 2;
return { x, y };
}
/** Pinned edges that moved during a resize in `dir`. */
export function driftedEdges(before, after, dir, tolerance = 1.5) {
const edges = (box) => ({
left: box.left,
top: box.top,
right: box.left + box.width,
bottom: box.top + box.height,
});
const a = edges(before);
const b = edges(after);
return pinnedEdges(dir).filter(
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
);
}
function near(actual, expected, tolerance, label) {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
);
}
async function main() {
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const monitors = [];
try {
const page = await browser.newPage();
await page.setViewport(VIEWPORT);
const errors = [];
monitors.push(watchPage(page, { name: 'panel', errors }));
await hookRenderErrors(page);
const boot = async () => {
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
timeout: 90_000,
});
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click();
});
await page.keyboard.press('Escape');
await page.evaluate(() => {
document.getElementById('first-run-launcher')?.remove();
for (const node of document.querySelectorAll('[class*=first-run]'))
node.remove();
});
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
await nextFrames(page);
};
const readPanel = () =>
page.evaluate((id) => {
const panel = document.getElementById(id);
const rect = panel.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// handlePoint() reads the far edges for s/e handles.
right: rect.right,
bottom: rect.bottom,
floating: panel.classList.contains('panel-floating'),
collapsed: panel.classList.contains('collapsed'),
inRail: panel.parentElement?.id === 'right-context-rail',
allocated: panel.style.getPropertyValue(
'--right-panel-allocated-height',
),
inlineWidth: panel.style.width,
inlineHeight: panel.style.height,
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
};
}, PANEL_ID);
/**
* Best effort: wait for the box to hold still for half a second. The lift
* is checked against the box at pointerdown, so a timeout only costs time.
*/
const settle = async () => {
try {
await page.waitForFunction(
(id) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height]
.map(Math.round)
.join(',');
const now = performance.now();
if (window.__qaLastBox !== box) {
window.__qaLastBox = box;
window.__qaSince = now;
}
return now - window.__qaSince >= 500;
},
{ polling: 100, timeout: 20_000 },
PANEL_ID,
);
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
console.log('note: the CCTV panel was still moving; lifting anyway');
}
};
const headerPoint = async () => {
const rect = await page.evaluate((id) => {
const title = document.querySelector(
`#${id} .panel-header .panel-title`,
);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, PANEL_ID);
return rect;
};
const drag = (from, dx, dy) => dragPointer(page, from, dx, dy);
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.evaluate((key) => {
localStorage.removeItem(key);
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
}, STORAGE_KEY);
await boot();
await page.evaluate((id) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed'))
document
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
.click();
}, PANEL_ID);
await page.waitForFunction(
(id) => !document.getElementById(id).classList.contains('collapsed'),
{},
PANEL_ID,
);
// The rail animates panel heights, and on a slow software renderer the
// panels above CCTV can still be moving after a fixed sleep.
await settle();
const docked = await readPanel();
assert.equal(docked.floating, false);
assert.equal(docked.inRail, true);
// Names whatever above CCTV in the rail shifted it, for a retry note.
const railAbove = () =>
page.evaluate((id) => {
const out = [];
for (const node of document.getElementById('right-context-rail')
?.children || []) {
if (node.id === id) break;
out.push(
`${node.id || node.className}:${Math.round(node.offsetHeight)}`,
);
}
return out.join(' ');
}, PANEL_ID);
const aboveAtDock = await railAbove();
// On CI a panel above CCTV grows after load and can shift the header out
// from under the press; the helper retries only such a provable miss.
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -200,
dy: -100,
note: async () =>
`rail above CCTV ${aboveAtDock} -> ${await railAbove()}`,
});
const { pressed } = attempt;
const lifted = await readPanel();
assert.equal(
lifted.floating,
true,
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
);
assert.equal(
lifted.collapsed,
false,
'the drag-ending click must not collapse',
);
const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`;
near(
lifted.left,
Math.max(6, pressed.left - 200),
2,
`lifted left (${moved})`,
);
near(
lifted.top,
Math.max(6, pressed.top - 100),
2,
`lifted top (${moved})`,
);
near(lifted.width, pressed.width, 2, 'lifted width');
// The rail lets go of the lifted panel on its next layout pass.
await page
.waitForFunction(
(id) =>
document
.getElementById(id)
.style.getPropertyValue('--right-panel-allocated-height') === '',
{ timeout: 5_000 },
PANEL_ID,
)
.catch(() => {});
const afterLayout = await readPanel();
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail');
// Same rule as the rail: hidden panels take no room.
const counted = [...rail.children].filter((panel) =>
panel.matches(
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
),
);
return {
expandedCount: rail.dataset.expandedCount,
counted: counted.length,
includesPanel: counted.some((panel) => panel.id === id),
};
}, PANEL_ID);
assert.equal(railExcludes.includesPanel, false);
if (railExcludes.expandedCount !== undefined)
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
const storedAfterLift = JSON.parse(afterLayout.stored);
assert.equal(storedAfterLift.floating, true);
assert.equal(
'height' in storedAfterLift,
false,
'a drag alone must not freeze the measured height',
);
console.log('PASS: header drag lifts CCTV out of the rail');
// Park above and right of the voice dock and its raised provider chooser:
// growing every edge must leave the south handles clear of both controls.
{
const panel = await readPanel();
const header = await headerPoint();
await drag(header, 900 - panel.left, 120 - header.y);
}
const limits = {
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
viewportWidth: VIEWPORT.width,
viewportHeight: VIEWPORT.height,
};
for (const dir of RESIZE_DIRECTIONS) {
const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
const point = handlePoint(before, dir);
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
if (!node) return 'nothing';
const dirAttr = node.dataset?.dir
? `[data-dir=${node.dataset.dir}]`
: '';
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`;
}, point);
await drag(point, dx, dy);
const after = await readPanel();
const expected = resizeBox(before, dir, dx, dy, limits);
for (const key of ['left', 'top', 'width', 'height'])
near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`);
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
}
console.log('PASS: eight resize handles keep the opposite edge pinned');
let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
before = after;
await drag(handlePoint(before, 'nw'), 2000, 2000);
after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
await boot();
const restored = await readPanel();
assert.equal(restored.floating, true, 'floating survives a reload');
for (const key of ['left', 'top', 'width', 'height'])
near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size');
// A collapsed window keeps the height chosen for it, through a drag of
// its header strip and a reload.
const setCollapsed = async (collapsed) => {
await page.evaluate(
(id, want) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed') !== want)
document
.querySelector(
`.panel-collapse-btn[data-collapse-target="${id}"]`,
)
.click();
},
PANEL_ID,
collapsed,
);
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('collapsed') === want,
{},
PANEL_ID,
collapsed,
);
await nextFrames(page);
};
// Grow it from the corner once the restored window holds still, with the
// pointer really on the handle (a press beside it selects page text).
let chosen = await readPanel();
const tried = [];
for (
let attempt = 0;
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
attempt++
) {
await settle();
const box = await readPanel();
const corner = handlePoint(box, 'se');
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
return {
dir: node?.dataset?.dir || null,
target: node
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
: 'nothing',
};
}, corner);
tried.push(
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
);
if (hit.dir === 'se') await drag(corner, 0, 200);
chosen = await readPanel();
}
assert.ok(
chosen.height > MIN_SIZE.height + 150,
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
);
await setCollapsed(true);
const strip = await readPanel();
assert.ok(strip.height < chosen.height - 100, 'collapsed to its header');
await drag(await headerPoint(), 40, 30);
const movedStrip = await readPanel();
assert.equal(movedStrip.floating, true, 'the strip stays a window');
near(
JSON.parse(movedStrip.stored).height,
chosen.height,
2,
'stored height while collapsed',
);
await boot();
await setCollapsed(false);
near(
(await readPanel()).height,
chosen.height,
2,
'height after expanding',
);
console.log(
'PASS: a collapsed window keeps its height through a drag and a reload',
);
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
const snapped = await readPanel();
assert.equal(
snapped.floating,
false,
`double-click snaps back (${describePress(dock)})`,
);
assert.equal(snapped.inRail, true);
assert.equal(snapped.inlineWidth, '');
assert.equal(snapped.inlineHeight, '');
assert.equal(snapped.stored, null, 'the storage key is gone');
console.log('PASS: header double-click returns the panel to the rail');
const renderErrors = await readRenderErrors(page);
assert.deepEqual(
[...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)],
[],
'no page errors and no Cesium render-loop errors',
);
console.log('PASS: no page errors and no render-loop errors');
} catch (error) {
const dir = await saveFailureArtifacts({
dir: ARTIFACT_DIR,
browser,
monitors,
error,
}).catch(() => null);
if (dir) console.error(`failure evidence saved in ${dir}/`);
throw error;
} finally {
await browser.close();
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
}
+319
View File
@@ -0,0 +1,319 @@
/**
* Header press-and-verify for the QA gates that lift a rail panel into a
* floating window and dock it again. The rail animates panel heights, so a
* point read once can be under another panel by the time the pointer goes
* down. Only a provable miss (a recorded press outside the header) is
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
* panel's own movement cannot explain fails the run instead.
*/
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Retries after the first attempt, and the pause before each. */
export const PRESS_RETRIES = 2;
const RETRY_PAUSE_MS = 1000;
/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */
export function failOnRetryDefault(
env = process.env,
argv = process.argv.slice(2),
) {
return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry');
}
/**
* A failed attempt whose press landed outside the header. No recorded press
* is a failure, not a miss: a retry would only hide it.
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
*/
export function pressMissed(attempt) {
return !attempt.done && attempt.pressed?.inHeader === false;
}
/** Whether the panel's box changed by more than 2 px between two reads. */
export function panelMoved(before, after) {
if (!before || !after) return false;
return ['left', 'top', 'width', 'height'].some(
(key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2,
);
}
/** The panel's box, rounded, as `left,top widthxheight`. */
export function formatBox(box) {
if (!box) return 'unknown';
const r = (value) => Math.round(value ?? NaN);
return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`;
}
function panelBox(page, panelId) {
return page.evaluate((id) => {
const r = document.getElementById(id).getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
}, panelId);
}
export function headerPoint(page, panelId) {
return page.evaluate((id) => {
const title = document.querySelector(`#${id} .panel-header .panel-title`);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, panelId);
}
/** Name the element at a point, and whether it is the panel's header. */
export function hitAt(page, point, panelId) {
return page.evaluate(
({ x, y, id }) => {
const node = document.elementFromPoint(x, y);
if (!node) return { target: 'nothing', inHeader: false };
return {
target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`,
inHeader: Boolean(node.closest(`#${id} .panel-header`)),
};
},
{ ...point, id: panelId },
);
}
/**
* Re-resolve the header point until it hits the header or the wait runs out
* (the press then records the miss).
*/
export async function headerTarget(
page,
panelId,
{ tries = 20, intervalMs = 500 } = {},
) {
let point = await headerPoint(page, panelId);
let hit = await hitAt(page, point, panelId);
for (let i = 0; i < tries && !hit.inHeader; i++) {
await delay(intervalMs);
point = await headerPoint(page, panelId);
hit = await hitAt(page, point, panelId);
}
const box = await panelBox(page, panelId);
return { point, hit, timedOut: !hit.inHeader, box };
}
/**
* Best effort: wait until the panel's box has held still for `stillMs`, so
* the header does not move between the hit check and the press.
*/
export async function waitForStill(
page,
panelId,
{ stillMs = 300, timeout = 5000 } = {},
) {
try {
await page.waitForFunction(
(id, ms) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
const now = performance.now();
if (window.__qaStillBox !== box) {
window.__qaStillBox = box;
window.__qaStillSince = now;
}
return now - window.__qaStillSince >= ms;
},
{ polling: 100, timeout },
panelId,
stillMs,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Record the next pointerdown's target and the panel's box at that moment. */
export function recordNextPress(page, panelId) {
return page.evaluate((id) => {
window.__qaPressBox = null;
window.addEventListener(
'pointerdown',
(event) => {
const r = document.getElementById(id).getBoundingClientRect();
const target = event.target;
window.__qaPressBox = {
left: r.left,
top: r.top,
width: r.width,
height: r.height,
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
};
},
{ capture: true, once: true },
);
}, panelId);
}
function readPress(page) {
return page.evaluate(() => window.__qaPressBox ?? null);
}
export async function waitForFloating(
page,
panelId,
floating,
{ timeout = 3000 } = {},
) {
try {
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('panel-floating') ===
want,
{ polling: 50, timeout },
panelId,
floating,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Two paints: by then every layout change an input caused is on screen. */
export function nextFrames(page) {
return page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps });
await page.mouse.move(from.x + dx, from.y + dy, { steps });
await page.mouse.up();
await nextFrames(page);
}
export function describeRetry(
panelId,
attempt,
{ round, retries, extra = '' },
) {
const { hit, pressed, timedOut, still, box } = attempt;
return [
`retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`,
`pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`,
`stillness ${still === false ? 'timed out' : 'held'}`,
`panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`,
]
.concat(extra ? [extra] : [])
.join('; ');
}
/** Retry `press` only while it provably misses the header. */
async function pressUntilDecided(
page,
panelId,
press,
{
retries,
log,
note,
pauseMs = RETRY_PAUSE_MS,
failOnRetry = failOnRetryDefault(),
},
) {
for (let round = 0; ; round++) {
const still = await waitForStill(page, panelId);
await recordNextPress(page, panelId);
const { point, hit, timedOut, box } = await headerTarget(page, panelId);
const done = await press(point);
const attempt = {
point,
hit,
timedOut,
still,
box,
done,
pressed: await readPress(page),
retries: round,
};
if (round >= retries || !pressMissed(attempt)) return attempt;
const why = describeRetry(panelId, attempt, {
round,
retries,
extra: note ? await note() : '',
});
if (failOnRetry && !panelMoved(box, attempt.pressed))
throw new Error(
`QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`,
);
log(`note: ${why}; retrying`);
log(`::warning title=Panel press retried::${why}`);
await delay(pauseMs);
}
}
/**
* Lift a docked panel into a floating window with a header drag.
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
*/
export function liftPanelByHeader(
page,
panelId,
{
dx,
dy,
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await dragPointer(page, point, dx, dy);
return waitForFloating(page, panelId, true);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Dock a floating panel with a header double-click; same result as lifting. */
export function dockPanelByDoubleClick(
page,
panelId,
{
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await page.mouse.click(point.x, point.y, { clickCount: 1 });
await page.mouse.click(point.x, point.y, { clickCount: 2 });
return waitForFloating(page, panelId, false);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Where an attempt pressed, for an assertion message. */
export function describePress(attempt) {
const { point, hit, pressed } = attempt;
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
if (!pressed)
return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`;
const n = attempt.retries || 0;
const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : '';
return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`;
}
+2
View File
@@ -1291,6 +1291,8 @@ 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');
+1 -5
View File
@@ -66,8 +66,7 @@ try {
);
}),
);
await page.focus('#scene-capture-btn');
await page.keyboard.press('Space');
await page.click('#scene-capture-btn');
await page.waitForFunction(
() => document.querySelectorAll('.scene-shot-row').length === 1,
);
@@ -329,9 +328,6 @@ 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
@@ -1,322 +0,0 @@
#!/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
@@ -1,147 +0,0 @@
#!/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
@@ -1,276 +0,0 @@
#!/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;
+2782 -554
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -80,7 +80,9 @@ try {
page.evaluate(() => {
const { viewer, dataManager } = window.__godsEyeView;
let upper = 0,
lower = 0;
lower = 0,
near = 0;
const camera = viewer.camera.positionWC;
dataManager.layers
.get('traffic')
.module.visitMotionDots((id, key, p) => {
+2
View File
@@ -47,6 +47,8 @@ import {
reduceScenario,
reduceSensorContrast,
reduceScriptedMotion,
reduceMotion,
reduceFleet,
} from '../src/layers/transit/qaMetrics.js';
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
+12 -2
View File
@@ -43,7 +43,11 @@ try {
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
const observer = ui._panelLayout[name];
const owner =
name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout;
const observer = owner[name];
if (!observer) return false;
const disconnect = observer.disconnect.bind(observer);
counts[name] = 0;
@@ -55,6 +59,7 @@ try {
};
const observed = [
'_commandDockTrayObserver',
'_draggableResizeObserver',
'_leftStackResizeObserver',
'_rightStackResizeObserver',
'_leftStackMutationObserver',
@@ -134,6 +139,7 @@ try {
const focusBefore = document.activeElement;
const stoppedBeforeRestoration =
ui._panelLayout.destroyed &&
ui._panelPosition.destroyed &&
ui._feedback.destroyed &&
ui._recording.destroyed &&
cockpit.destroyed &&
@@ -188,7 +194,11 @@ try {
counts[field] === 1 && ui._contextControls[field] === null,
),
observersReleased: observed.every(
(name) => counts[name] === 1 && ui._panelLayout[name] === null,
(name) =>
counts[name] === 1 &&
(name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout)[name] === null,
),
subscriptionReleased:
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
+1 -1
View File
@@ -295,7 +295,7 @@ async function main() {
);
// Wait for the photoreal tileset and at least one vessel refresh.
await page
const settled = await page
.waitForFunction(() => {
const gev = window.__godsEyeView;
const t = gev.tileset;
-32
View File
@@ -183,38 +183,6 @@ 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 });
+3 -5
View File
@@ -1745,9 +1745,8 @@ async function runBehaviorLayer() {
'behavior: analyst follow-up re-filters the remembered set',
`count=${r?.count} followUp=${r?.coverage?.followUp}`,
);
// 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.
// A marine region resolves from the bundled Natural Earth pack in the page,
// without the slower geocode fallback.
const gulfStarted = Date.now();
r = await run('analyst_query', {
layers: ['flights'],
@@ -1758,10 +1757,9 @@ 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} source=${r?.coverage?.scopeSource} ms=${gulfMs} err=${r?.error || ''}`,
`scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`,
);
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
+7 -1
View File
@@ -8,7 +8,13 @@
* node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google
* [--layer weather-satellite] [--product clouds] [--headless]
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
+4 -28
View File
@@ -61,10 +61,6 @@ 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) =>
@@ -145,7 +141,7 @@ const snapshot = (page) =>
),
},
weather,
windCells: wind.gpu?.pathCount ?? wind.paths ?? 0,
windCells: wind.gpu?.pathCount ?? wind.paths ?? null,
enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id),
};
});
@@ -224,7 +220,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);
const weatherReady = await a.page
await a.page
.waitForFunction(
() =>
['weather-radar', 'weather-satellite', 'weather-lightning'].every(
@@ -237,13 +233,7 @@ try {
),
{ timeout: 120_000 },
)
.then(
() => true,
() => false,
);
results.weatherReady = weatherReady;
if (!weatherReady)
console.log(' !! weather not ready — feature coverage incomplete');
.catch(() => console.log(' !! weather not ready'));
await sleep(2000);
// exercise: history playback, wind reading, storm selection, product switch
await a.page.evaluate(() => {
@@ -341,25 +331,11 @@ 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 { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
export const ALLOCATION_TEST_FILES = Object.freeze([
'src/data/focusAllocations.test.mjs',
+78 -34
View File
@@ -94,7 +94,6 @@
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';
@@ -2696,17 +2695,16 @@ 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 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.
// trigger each path with the camera idle. The idle-drift catch-up
// (ROTATION_REFRESH_MS = 1 s) must not repair a stale first-visible nose
// before we inspect it: capture that first postRender, with a 250 ms
// deadline that admits the 80 ms fleet cadence without waiting a second.
// ============================================================
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).
@@ -2764,33 +2762,72 @@ async function main() {
});
await nextFrames(3);
const angDiff = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)));
// Render frames are not fleet ticks: at 60/120 Hz four frames can
// precede the next 80 ms fleet pass. Observe elapsed time instead, and
// freeze the angle on the FIRST visible frame, even when it is wrong.
const observeFirstReveal = (bb, course, budgetMs = 250) => new Promise((resolve) => {
const startedAt = performance.now();
let frames = 0;
let settled = false;
const finish = (shown, elapsedMs) => {
if (settled) return;
settled = true;
clearTimeout(timer);
remove();
const expected = shown
? screenProjectedRotation(v.scene, bb.position, course, null)
: null;
resolve({
shown,
flipFrame: shown ? frames : -1,
revealElapsedMs: elapsedMs,
revealWithinBudget: shown && elapsedMs <= budgetMs,
dReveal: shown && Number.isFinite(expected) ? angDiff(bb.rotation, expected) : null,
});
};
const remove = v.scene.postRender.addEventListener(() => {
frames += 1;
const elapsedMs = performance.now() - startedAt;
if (bb.show) {
finish(true, elapsedMs);
} else if (elapsedMs >= budgetMs) {
finish(false, elapsedMs);
} else {
v.scene.requestRender();
}
});
const timer = setTimeout(() => finish(false, performance.now() - startedAt), budgetMs);
v.scene.requestRender();
});
const probe = async (id, course) => {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
const observed = await observeArrivalRotation({
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)),
};
},
});
// (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). Record frame count as diagnostics only: the
// first visible postRender must already have the corrected rotation.
bb.rotation = r0 + 1.5;
bb.show = false;
const reveal = await observeFirstReveal(bb, course);
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
return { r0, ...observed, hasModel };
return {
r0,
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
...reveal,
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;
@@ -2799,13 +2836,20 @@ async function main() {
military: await probe('bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${a.dReveal === null ? 'unobserved' : (a.dReveal * 180 / Math.PI).toFixed(1) + '°'} shown=${a.shown} flipFrame=${a.flipFrame} revealMs=${a.revealElapsedMs.toFixed(1)} hasModel=${a.hasModel}`);
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.military), fmtArr(arrival.military));
// 250 ms spans multiple 80 ms fleet opportunities while remaining well
// below the 1 s idle catch-up. Never retry until the angle becomes correct:
// dReveal is the immutable first-visible-frame observation above.
const arrOk = (a) => !!a && !a.error && a.shown
&& a.revealWithinBudget && a.revealElapsedMs <= 250
&& a.dReveal !== null
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
record(`arrival: moveEnd re-aims and first reveal is correct within 250 ms, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrOk(arrival.flights), fmtArr(arrival.flights));
record(`arrival: moveEnd re-aims and first reveal is correct within 250 ms, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrOk(arrival.military), fmtArr(arrival.military));
// ============================================================
// DISPLAY FLOOR (2026-08-19): a grounded contact must never render below
+47 -64
View File
@@ -1,7 +1,4 @@
import {
coalesceProxyRequest,
readResponseTextCapped,
} from '../common/http.js';
import { readResponseTextCapped } from '../common/http.js';
/**
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
@@ -22,7 +19,6 @@ import {
export function adsbLolProxy() {
/** @type {string|null} Cached upstream JSON body. */
let _cache = null;
const inFlight = new Map();
/** @type {number} Epoch-ms when the cache was populated. */
let _cacheAt = 0;
/** @type {number} Epoch-ms until which upstream is not contacted. */
@@ -108,73 +104,60 @@ export function adsbLolProxy() {
);
return;
}
const refresh = coalesceProxyRequest(inFlight, 'global', async () => {
const result = (status, body, cacheStatus, extra = {}) => ({
status,
body,
cacheStatus,
extra,
});
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
signal,
});
if (upstream.ok) {
const body = await readResponseTextCapped(
upstream,
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
signal,
);
_cache = body;
_cacheAt = Date.now();
_cooldownUntil = 0;
_cooldownStatus = 0;
return result(200, body, 'MISS');
}
if (upstream.status === 429 || upstream.status >= 500) {
const failedAt = Date.now();
_cooldownUntil = failedAt + cooldownFor(upstream, failedAt);
_cooldownStatus = upstream.status;
console.warn(
`[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`,
);
if (_cache) {
// Do not wait for a failing response body before serving usable data.
upstream.body?.cancel().catch(() => {});
return result(200, _cache, 'STALE', {
'X-ADS-B-Upstream-Status': String(upstream.status),
'X-ADS-B-Retry-After-Seconds': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
),
});
}
}
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
signal,
});
if (upstream.ok) {
const body = await readResponseTextCapped(
upstream,
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
signal,
);
return result(
upstream.status,
body,
'MISS',
_cooldownUntil > Date.now()
? {
'Retry-After': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
),
}
: {},
_cache = body;
_cacheAt = Date.now();
_cooldownUntil = 0;
_cooldownStatus = 0;
serve(res, 200, body, 'MISS');
return;
}
if (upstream.status === 429 || upstream.status >= 500) {
const failedAt = Date.now();
_cooldownUntil = failedAt + cooldownFor(upstream, failedAt);
_cooldownStatus = upstream.status;
console.warn(
`[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`,
);
});
const response = await refresh.promise;
if (_cache) {
// Do not wait for a failing response body before serving usable data.
upstream.body?.cancel().catch(() => {});
serve(res, 200, _cache, 'STALE', {
'X-ADS-B-Upstream-Status': String(upstream.status),
'X-ADS-B-Retry-After-Seconds': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
),
});
return;
}
}
const body = await readResponseTextCapped(
upstream,
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
signal,
);
serve(
res,
response.status,
response.body,
response.cacheStatus,
response.extra,
upstream.status,
body,
'MISS',
_cooldownUntil > Date.now()
? {
'Retry-After': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000),
),
}
: {},
);
} catch (e) {
console.error('[adsb.lol Proxy]', e.message);
+111 -141
View File
@@ -67,24 +67,15 @@ async function fetchOpenSkyStates(headers) {
try {
const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS);
const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal });
if (upstream.status === 429 || upstream.status >= 500) {
if (upstream.status === 429) {
// Start the cooldown from the headers and drop the rejected body
// unread, so a stalled or oversized 429 body cannot skip it.
_openskyCooldownUntil =
Date.now() +
(upstream.status === 429
? openSkyRateLimitCooldownMs(upstream)
: 15000);
_openskyCooldownStatus = upstream.status;
Date.now() + openSkyRateLimitCooldownMs(upstream);
void upstream.body?.cancel().catch(() => {});
return {
upstream,
body: JSON.stringify({
error:
upstream.status === 429
? 'OpenSky rate limited.'
: 'OpenSky upstream unavailable.',
}),
body: JSON.stringify({ error: 'OpenSky rate limited.' }),
};
}
const body = await readResponseTextCapped(
@@ -118,8 +109,6 @@ async function fetchOpenSkyStates(headers) {
let _openskyTtlMs = OPENSKY_CACHE_MS;
/** @type {number} Epoch-ms before which no upstream fetch is attempted. */
let _openskyCooldownUntil = 0;
let _openskyCooldownStatus = 429;
const _openskySnapshotInFlight = new Map();
/**
* Picks the cache TTL from the remaining daily credit budget.
* Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the
@@ -471,9 +460,7 @@ export function openSkyProxy() {
requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown',
reason: isStale
? _openskyCooldownStatus === 429
? 'rate_limited_serving_stale'
: `upstream_http_${_openskyCooldownStatus}_serving_stale`
? 'rate_limited_serving_stale'
: cachedMeta.reason || 'cached',
staleSeconds: isStale
? (now - _openskyCacheTime) / 1000
@@ -500,140 +487,80 @@ export function openSkyProxy() {
)
return;
res.writeHead(
_openskyCooldownStatus,
429,
buildOpenSkyHeaders({
cacheStatus: 'COOLDOWN',
requestedMode,
usedMode: 'none',
reason:
_openskyCooldownStatus === 429
? 'rate_limited'
: `upstream_http_${_openskyCooldownStatus}`,
reason: 'rate_limited',
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000,
}),
);
res.end(
JSON.stringify({
error: 'OpenSky upstream unavailable; proxy cooling down.',
error: 'OpenSky rate limited; proxy cooling down.',
}),
);
return;
}
const refresh = coalesceProxyRequest(
_openskySnapshotInFlight,
'global',
async () => {
const basicUser = process.env.OPENSKY_USERNAME || '';
const basicPass = process.env.OPENSKY_PASSWORD || '';
const hasBasicCreds = Boolean(basicUser && basicPass);
const headers = { Accept: 'application/json' };
let usedMode = 'anon';
let reason = 'forced_anonymous';
const basicUser = process.env.OPENSKY_USERNAME || '';
const basicPass = process.env.OPENSKY_PASSWORD || '';
const hasBasicCreds = Boolean(basicUser && basicPass);
const headers = { Accept: 'application/json' };
let usedMode = 'anon';
let reason = 'forced_anonymous';
if (requestedMode === 'basic') {
if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'basic_credentials';
} else {
reason = 'missing_basic_creds';
}
} else if (requestedMode === 'oauth') {
const token = await getOpenSkyToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else {
reason = 'oauth_invalid_or_missing';
}
} else if (requestedMode === 'auto') {
const token = await getOpenSkyToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'oauth_unavailable_fallback_basic';
} else {
reason = 'missing_oauth_and_basic_creds';
}
}
if (requestedMode === 'basic') {
if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'basic_credentials';
} else {
reason = 'missing_basic_creds';
}
} else if (requestedMode === 'oauth') {
const token = await getOpenSkyToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else {
reason = 'oauth_invalid_or_missing';
}
} else if (requestedMode === 'auto') {
const token = await getOpenSkyToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
usedMode = 'oauth';
reason = 'oauth_token';
} else if (hasBasicCreds) {
headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`;
usedMode = 'basic';
reason = 'oauth_unavailable_fallback_basic';
} else {
reason = 'missing_oauth_and_basic_creds';
}
}
let { upstream, body } = await fetchOpenSkyStates(headers);
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if (
(upstream.status === 401 || upstream.status === 403) &&
requestedMode === 'auto' &&
usedMode === 'oauth' &&
hasBasicCreds
) {
const retryHeaders = {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
};
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
let { upstream, body } = await fetchOpenSkyStates(headers);
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
if (
(upstream.status === 401 || upstream.status === 403) &&
requestedMode === 'auto' &&
usedMode === 'oauth' &&
hasBasicCreds
) {
const retryHeaders = {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
};
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
usedMode = 'basic';
reason = 'oauth_rejected_fallback_basic';
}
const sourceEpochMs = upstream.ok
? openSkySourceEpochMs(body)
: null;
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (
upstream.ok &&
usedMode === 'oauth' &&
reason === 'oauth_token'
) {
reason = 'oauth_ok';
}
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = {
requestedMode,
usedMode,
reason,
};
// Credit governor: adapt the cache TTL to the remaining daily
// budget so a continuously-open app stretches its polls instead of
// exhausting the quota mid-day. Success also clears any cooldown.
const remaining = Number(
upstream.headers.get('x-rate-limit-remaining'),
);
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0;
}
return {
upstream,
body,
usedMode,
reason,
hasBasicCreds,
sourceEpochMs,
};
},
);
let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } =
await refresh.promise;
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
if (
upstream.ok &&
openSkySourceIsStale(sourceEpochMs, now) &&
@@ -644,16 +571,20 @@ export function openSkyProxy() {
'opensky_snapshot_stale_regional_fallback',
))
) {
// Keep the last global snapshot available as a fail-soft cache,
// but do not label or render it as a fresh live result.
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = { requestedMode, usedMode, reason };
return;
}
if (upstream.status === 429 || upstream.status >= 500) {
reason =
upstream.status === 429
? 'rate_limited'
: `upstream_http_${upstream.status}`;
if (upstream.status === 429) {
reason = 'rate_limited';
// Credit governor: fetchOpenSkyStates already started the cooldown
// from OpenSky's retry-after — no upstream attempts until then.
const cooldownMs = Math.max(0, _openskyCooldownUntil - Date.now());
const cooldownMs = openSkyRateLimitCooldownMs(upstream);
// Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) {
@@ -663,7 +594,7 @@ export function openSkyProxy() {
cacheStatus: 'STALE',
requestedMode,
usedMode,
reason: `${reason}_serving_stale`,
reason: 'rate_limited_serving_stale',
staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000,
}),
@@ -721,6 +652,45 @@ export function openSkyProxy() {
}
}
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (
upstream.ok &&
usedMode === 'oauth' &&
reason === 'oauth_token'
) {
reason = 'oauth_ok';
}
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = {
requestedMode,
usedMode,
reason,
};
// Credit governor: adapt the cache TTL to the remaining daily
// budget so a continuously-open app stretches its polls instead of
// exhausting the quota mid-day. Success also clears any cooldown.
const remaining = Number(
upstream.headers.get('x-rate-limit-remaining'),
);
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0;
}
res.writeHead(
upstream.status,
buildOpenSkyHeaders({
+34 -64
View File
@@ -1,9 +1,11 @@
import { createCctvCatalog } from './cctv/catalog.js';
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
import {
normalizeFeedType,
isVideoFeedType,
toFiniteNumber,
} from './cctv/normalize.js';
import {
buildSyntheticCctvSvg,
cctvLiveMediaType,
cctvRasterImageType,
proxyMediaResponse,
fetchCctvImageFromUpstream,
fetchTxdotSnapshot,
@@ -15,8 +17,6 @@ import {
CCTV_MAX_SOURCES_CEILING,
} from './cctv/constants.js';
import { sanitizeCctvRangeHeader } from './cctv/range.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
import { createHlsPuller } from './cctv/stream.js';
import { googleServerApiKey } from './places/google-key.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
@@ -116,15 +116,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
signal: AbortSignal.timeout(CCTV_FRAME_FETCH_TIMEOUT_MS),
});
const svType = cctvRasterImageType(svResp.headers.get('content-type'));
if (!svResp.ok || !svType) {
try {
await svResp.body?.cancel();
} catch {
/* no-op */
}
return null;
}
const svType = svResp.headers.get('content-type') || '';
if (!svResp.ok || !svType.startsWith('image/')) return null;
return {
ok: true,
@@ -142,15 +135,11 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
});
server.middlewares.use('/api/cctv', async (req, res) => {
try {
const url = new URL(req.url || '/', 'http://localhost');
// Refuse cross-site requests before any catalog or upstream work. The
// keyless gate keeps the routes reachable behind a reverse proxy; the
// key-bearing Street View fallback below has its own check.
if (admitSameSiteKeyless(req, res)) return;
const sources = await getCctvSources();
const sourceById = new Map(
sources.map((source) => [source.id, source]),
);
const url = new URL(req.url || '/', 'http://localhost');
if (url.pathname === '/sources') {
const body = {
@@ -228,8 +217,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const mediaUrl = source?.url || '';
const feedType = normalizeFeedType(source?.feedType || 'image');
// Live feeds only. A still camera is served by /frame, whose fetch
// keeps redirects on the registered origin, accepts only raster
// images and caps the body; relaying it here would bypass all three.
// keeps redirects on the registered origin, accepts only image/* and
// caps the body; relaying it here would bypass all three.
if (!isVideoFeedType(feedType) && feedType !== 'mjpeg') {
res.writeHead(404, {
'Content-Type': 'application/json',
@@ -393,6 +382,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
}
return;
}
const contentType = upstream.headers.get('content-type') || '';
if (!upstream.ok) {
try {
await upstream.body?.cancel();
@@ -417,42 +407,31 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
return;
}
// The relayed type is the feed kind's own, checked before the
// camera counts as healthy and before any byte is relayed.
const contentType = cctvLiveMediaType(
feedType,
upstream.headers.get('content-type'),
);
if (!contentType) {
try {
await upstream.body?.cancel();
} catch {
/* already closed */
}
if (
isVideoFeedType(feedType) &&
!(
contentType.startsWith('video/') ||
contentType.includes('mpegurl')
)
) {
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'upstream',
label: source?.provider || 'Configured source',
message: 'Unexpected media type',
message: `Unexpected media type ${contentType || 'unknown'}`,
});
res.writeHead(502, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
} else {
setHealth(cameraId, {
status: 'ok',
sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot',
label: source?.provider || 'Configured source',
message: isVideoFeedType(feedType)
? 'Live stream connected'
: 'Snapshot feed connected',
});
res.end(JSON.stringify({ error: 'Media proxy failed' }));
return;
}
setHealth(cameraId, {
status: 'ok',
sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot',
label: source?.provider || 'Configured source',
message: isVideoFeedType(feedType)
? 'Live stream connected'
: 'Snapshot feed connected',
});
await proxyMediaResponse(res, upstream, {
contentType,
sourceHeader: isVideoFeedType(feedType)
? 'live-media'
: 'upstream-image',
@@ -545,22 +524,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
return;
}
// The Street View fallback spends the Google key, so a forwarded
// request (whose client this server cannot see) gets the placeholder,
// as if no key were configured. Any forwarding header counts, even an
// empty one.
const forwarded = PROXY_SIGNALS.some(
(name) => req.headers?.[name] !== undefined,
);
const sv = forwarded
? null
: await streetViewFallback({
lat,
lon,
heading,
fov,
pitch,
});
const sv = await streetViewFallback({
lat,
lon,
heading,
fov,
pitch,
});
if (sv?.ok) {
setHealth(cameraId, {
status: 'degraded',
-12
View File
@@ -5,7 +5,6 @@ import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { cameraRegion, normalizeSourceItem } from './normalize.js';
import { loadLithuaniaSources } from './lithuania.js';
import { loadTaiwanSources } from './taiwan.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
@@ -20,7 +19,6 @@ import {
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadCataloniaSourcesFromOpenData,
loadHkSourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
loadKingCountySourcesFromOpenData,
loadWsdotSourcesFromOpenData,
@@ -108,11 +106,6 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
load: loadCataloniaSourcesFromOpenData,
},
{
name: 'hk',
enabled: () => envEnabled('CCTV_HK_ENABLED'),
load: loadHkSourcesFromOpenData,
},
{
name: 'deldot',
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
@@ -160,11 +153,6 @@ const LIVE_PACKS = [
enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'),
load: loadFloridaSourcesFromOpenData,
},
{
name: 'taiwan',
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
load: loadTaiwanSources,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
+1 -31
View File
@@ -18,7 +18,7 @@ export const CCTV_MAX_SOURCES_CEILING = 5000;
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
/** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */
const TXDOT_ORIGIN = 'https://its.txdot.gov';
export const TXDOT_ORIGIN = 'https://its.txdot.gov';
export const TXDOT_CCTV_STATUS_URL = (district) =>
`${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`;
/** Per-camera frame. Returns JSON `{snippet:<base64 jpeg>}`, not an image body;
@@ -309,36 +309,6 @@ export const CATALONIA_ANCHORS = [
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
];
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
export const DEFAULT_HK_CAMERAS_URL =
'https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml';
/** The only origin HK camera frames may come from. Verified 2026-10-11: still
* URLs answer 200 on this host with no redirect hop. */
export const HK_IMAGE_ORIGIN = 'https://tdcctv.data.one.gov.hk/';
/** Above the full TD English catalog (~1,013 cameras) so a default install
* keeps nearly all of them. Catalog-wide round-robin still applies when the
* sum of packs exceeds DEFAULT_CCTV_MAX_SOURCES (Norway-style). */
export const DEFAULT_HK_MAX_SOURCES = 1100;
/** Prioritization anchors across Hong Kong Island, Kowloon and the New
* Territories so a lowered cap keeps territory-wide coverage rather than
* only the harbour corridor. */
export const HK_ANCHORS = [
{ lat: 22.2819, lon: 114.158 }, // Central
{ lat: 22.2976, lon: 114.1722 }, // Tsim Sha Tsui
{ lat: 22.3827, lon: 114.19 }, // Sha Tin
{ lat: 22.3701, lon: 114.114 }, // Tsuen Wan
{ lat: 22.4503, lon: 114.1688 }, // Tai Po
{ lat: 22.493, lon: 114.139 }, // Fanling
{ lat: 22.391, lon: 113.977 }, // Tuen Mun
{ lat: 22.4445, lon: 114.022 }, // Yuen Long
{ lat: 22.307, lon: 114.26 }, // Tseung Kwan O
];
/** Hard ceiling on the locations XML body. The live English list is ~400 KB
* for ~1,000 cameras; this only exists so an unbounded upstream cannot be
* buffered without limit. */
export const HK_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view
* of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole
* country (~900 cameras). The raw DATEX node needs an account; this view does
+1 -1
View File
@@ -3,7 +3,7 @@ import path from 'node:path';
import { poseHash } from '../../../src/data/cctvFootprint.js';
/** Sidecar written by scripts/precompute-cctv-heights.mjs. */
const DEFAULT_GROUND_HEIGHTS_FILE =
export const DEFAULT_GROUND_HEIGHTS_FILE =
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json';
/** @type {{path:string, mtimeMs:number, cameras:object}|null} */
+10 -131
View File
@@ -1,21 +1,13 @@
import { Readable } from 'node:stream';
import { hashSeed, escapeXml, pinnedCameraUrl } from './normalize.js';
import { hashSeed, escapeXml } from './normalize.js';
import {
CALGARY_IMAGE_ORIGIN,
CCTV_FRAME_FETCH_TIMEOUT_MS,
CCTV_FRAME_MAX_BODY_BYTES,
CCTV_MEDIA_FETCH_TIMEOUT_MS,
CCTV_MEDIA_IDLE_TIMEOUT_MS,
CCTV_MEDIA_MAX_BODY_BYTES,
HK_IMAGE_ORIGIN,
KING_COUNTY_IMAGE_ORIGIN,
NSW_IMAGE_ORIGIN,
NSW_IMAGE_USER_AGENT,
TALLINN_IMAGE_ORIGIN,
TARKTEE_IMAGE_ORIGIN,
TFL_IMAGE_ORIGIN,
WARENDORF_IMAGE_ORIGINS,
WSDOT_IMAGE_ORIGIN,
} from './constants.js';
/**
* Generate a synthetic SVG billboard image for a CCTV camera placeholder.
@@ -92,7 +84,7 @@ export function buildSyntheticCctvSvg({ cameraId, label, city, status }) {
* @param {ReadableStream|NodeJS.ReadableStream|null} body
* @returns {import('stream').Readable|null}
*/
function toReadable(body) {
export function toReadable(body) {
if (!body) return null;
if (typeof body.pipe === 'function') return body;
if (typeof body.getReader === 'function') {
@@ -101,73 +93,6 @@ function toReadable(body) {
return null;
}
/**
* Split a Content-Type value into its lower-case essence and parameters.
*
* @param {string|null|undefined} value
* @returns {{essence:string,params:Map<string,string>}|null} Null when the
* value is missing or is not a `type/subtype` media type.
*/
function parseMediaType(value) {
const [head, ...rest] = String(value ?? '').split(';');
const essence = head.trim().toLowerCase();
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(essence)) return null;
const params = new Map();
for (const part of rest) {
const eq = part.indexOf('=');
if (eq <= 0) continue;
const name = part.slice(0, eq).trim().toLowerCase();
let paramValue = part.slice(eq + 1).trim();
if (/^".*"$/.test(paramValue)) paramValue = paramValue.slice(1, -1);
if (name && !params.has(name)) params.set(name, paramValue);
}
return { essence, params };
}
/** Still-image types a camera frame may be served as. */
const CCTV_RASTER_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
]);
/**
* The canonical type of a raster camera frame, or null for any other type.
*
* @param {string|null|undefined} contentType
* @returns {string|null}
*/
export function cctvRasterImageType(contentType) {
const essence = parseMediaType(contentType)?.essence;
return CCTV_RASTER_IMAGE_TYPES.has(essence) ? essence : null;
}
/**
* The type to relay a live feed as, or null when the upstream type does not
* match the feed kind. MJPEG keeps its part boundary, without which the body
* cannot be split into frames.
*
* @param {string} feedType - Normalized feed type (mp4, webm or mjpeg).
* @param {string|null|undefined} contentType
* @returns {string|null}
*/
export function cctvLiveMediaType(feedType, contentType) {
const parsed = parseMediaType(contentType);
if (!parsed) return null;
if (feedType === 'mp4' || feedType === 'webm')
return parsed.essence === `video/${feedType}` ? parsed.essence : null;
if (feedType !== 'mjpeg' || parsed.essence !== 'multipart/x-mixed-replace')
return null;
const boundary = parsed.params.get('boundary') || '';
if (!/^[0-9A-Za-z'()+_,./:=? -]{0,69}[0-9A-Za-z'()+_,./:=?-]$/.test(boundary))
return null;
return /^[0-9A-Za-z'+_.-]+$/.test(boundary)
? `multipart/x-mixed-replace; boundary=${boundary}`
: `multipart/x-mixed-replace; boundary="${boundary}"`;
}
/**
* Pipe an upstream fetch Response (image or video) to the client HTTP response.
*
@@ -179,8 +104,6 @@ export function cctvLiveMediaType(feedType, contentType) {
* @param {Response} upstream - fetch() Response object.
* @param {object} [opts]
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
* @param {string} [opts.contentType] - Validated type to send instead of the
* upstream's own.
* @param {number} [opts.idleTimeoutMs=CCTV_MEDIA_IDLE_TIMEOUT_MS] - Silence
* allowed between upstream chunks before the stream is released. Injectable
* only to keep the deadline unit-testable.
@@ -191,10 +114,10 @@ export async function proxyMediaResponse(
{
sourceHeader = 'upstream',
idleTimeoutMs = CCTV_MEDIA_IDLE_TIMEOUT_MS,
contentType = upstream.headers.get('content-type') ||
'application/octet-stream',
} = {},
) {
const contentType =
upstream.headers.get('content-type') || 'application/octet-stream';
const cacheControl = upstream.headers.get('cache-control') || 'no-store';
const contentLength = upstream.headers.get('content-length');
const contentRange = upstream.headers.get('content-range');
@@ -417,14 +340,7 @@ export async function fetchCctvMediaUpstream(
if (downstream?.aborted) controller.abort();
else downstream?.addEventListener?.('abort', onDownstreamAbort);
try {
// Redirects stay on the registered origin, as for frames.
const upstream = await fetchWithinHost(
url,
{ headers, signal: controller.signal },
fetchImpl,
);
if (!upstream) throw new Error('Media redirect refused');
return upstream;
return await fetchImpl(url, { headers, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
downstream?.removeEventListener?.('abort', onDownstreamAbort);
@@ -512,40 +428,9 @@ export async function fetchTxdotSnapshot(
/** Redirect hops the frame path will follow, and only within the same host. */
const MAX_SAME_HOST_REDIRECTS = 2;
/**
* Directories camera catalogs are pinned to, by host. A redirect on one of
* these hosts must stay inside the pinned directory too, so a same-origin
* redirect cannot leave it (another bucket on a shared storage host).
*/
const CCTV_PINNED_PREFIXES_BY_HOST = new Map();
for (const prefix of [
TFL_IMAGE_ORIGIN,
TALLINN_IMAGE_ORIGIN,
TARKTEE_IMAGE_ORIGIN,
...WARENDORF_IMAGE_ORIGINS,
NSW_IMAGE_ORIGIN,
CALGARY_IMAGE_ORIGIN,
HK_IMAGE_ORIGIN,
KING_COUNTY_IMAGE_ORIGIN,
WSDOT_IMAGE_ORIGIN,
]) {
const host = new URL(prefix).hostname;
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
...(CCTV_PINNED_PREFIXES_BY_HOST.get(host) || []),
prefix,
]);
}
/** Whether a redirect target stays inside its host's pinned directory. */
function withinPinnedPrefix(url) {
const prefixes = CCTV_PINNED_PREFIXES_BY_HOST.get(url.hostname);
return !prefixes || prefixes.some((p) => pinnedCameraUrl(url.href, p));
}
/**
* Fetch a registered frame URL following redirects ONLY within the original
* origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops) and,
* on a pinned camera host, within its pinned directory.
* origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops).
* Default redirect-following would let an upstream steer a host-pinned
* request, and its host-specific headers, to any origin, another port, or a
* plaintext downgrade.
@@ -586,7 +471,7 @@ export async function fetchWithinHost(url, init, fetchImpl = fetch) {
} catch {
return null;
}
if (next.origin !== origin || !withinPinnedPrefix(next)) return null;
if (next.origin !== origin) return null;
current = next;
}
return null;
@@ -658,15 +543,9 @@ export async function fetchCctvImageFromUpstream(
fetchImpl,
);
if (!upstream) return null;
const contentType = cctvRasterImageType(
upstream.headers.get('content-type'),
);
if (!upstream.ok || !contentType) {
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
const contentType = upstream.headers.get('content-type') || '';
if (!upstream.ok || !contentType.startsWith('image/')) {
controller.abort();
return null;
}
const body = await readCappedResponseBytes(upstream, maxBytes);
+3 -49
View File
@@ -64,7 +64,7 @@ export function toFiniteNumber(value, fallback = NaN) {
* @param {string} text
* @returns {string}
*/
function normalizeKey(text) {
export function normalizeKey(text) {
return String(text || '')
.trim()
.toLowerCase()
@@ -80,7 +80,7 @@ function normalizeKey(text) {
* @param {string} value
* @returns {{lat:number, lon:number}}
*/
function parsePointString(value) {
export function parsePointString(value) {
const match = String(value || '').match(
/POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i,
);
@@ -100,7 +100,7 @@ function parsePointString(value) {
* @param {string|object|null} value
* @returns {{lat:number, lon:number}}
*/
function coerceLatLon(value) {
export function coerceLatLon(value) {
if (!value) return { lat: NaN, lon: NaN };
if (typeof value === 'string') {
@@ -399,17 +399,6 @@ export function isLikelyNycCoordinate(lat, lon) {
);
}
/** Hong Kong SAR extent, with slack for the outlying islands. */
export function isLikelyHongKongCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 22.15 &&
lat <= 22.6 &&
lon >= 113.8 &&
lon <= 114.45
);
}
/** Mainland Norway, with slack for the coast and the Finnmark border. */
export function isLikelyNorwayCoordinate(lat, lon) {
return (
@@ -659,38 +648,3 @@ export function isLikelyTorontoCoordinate(lat, lon) {
lon <= -79.1
);
}
/**
* A camera URL under a pinned directory, by parsed URL rather than string
* prefix: the same scheme and exact host as `prefix`, no credentials, the
* default port, and a normalized path inside the prefix's directory. Dot
* segments (including encoded ones) and backslashes are resolved before the
* path is compared, and an encoded slash or backslash is refused.
*
* @param {string} raw - Camera URL from a catalog or redirect.
* @param {string} prefix - Pinned origin and directory, ending in `/`.
* @returns {string|null} The normalized URL, or null when it is not pinned.
*/
export function pinnedCameraUrl(raw, prefix) {
let url;
let pin;
try {
url = new URL(String(raw ?? '').trim());
pin = new URL(prefix);
} catch {
return null;
}
if (
url.protocol !== pin.protocol ||
url.hostname !== pin.hostname ||
url.username ||
url.password ||
url.port ||
!url.pathname.startsWith(
pin.pathname.endsWith('/') ? pin.pathname : `${pin.pathname}/`,
) ||
/%2f|%5c/i.test(url.pathname)
)
return null;
return url.href;
}
+40
View File
@@ -127,3 +127,43 @@ for (const status of [429, 500, 503]) {
assert.doesNotMatch(lines[0], /secret-key-value/);
});
}
for (const failure of ['transport', 'invalid-json']) {
test(`Ontario ${failure} failures cannot log reflected keys or request URLs`, async (t) => {
const lines = captureConsole(t);
const key = 'synthetic secret&/=%value';
t.mock.method(globalThis, 'fetch', async (url) => {
if (failure === 'transport')
throw new Error(`request failed: ${url} ${key}`);
return new Response(`not JSON: ${url} ${key}`);
});
assert.deepEqual(
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: key }),
[],
);
assert.equal(lines.length, 1);
assert.match(lines[0], /Ontario 511 camera download error/);
assert.equal(lines[0].includes(key), false);
assert.equal(lines[0].includes('synthetic'), false);
assert.equal(lines[0].includes('https://'), false);
});
}
test('Ontario encodes reserved key characters and refuses credential-bearing redirects', async (t) => {
const lines = captureConsole(t);
const key = 'synthetic &/=% value';
let request;
t.mock.method(globalThis, 'fetch', async (url, init) => {
request = { url: new URL(url), init };
return Response.json([ROW]);
});
const cameras = await loadOntarioSourcesFromOpenData(
{},
{ ONTARIO_511_API_KEY: key },
);
assert.equal(request.url.searchParams.get('key'), key);
assert.equal(request.init.redirect, 'error');
assert.equal(cameras.length, 1);
assert.equal(JSON.stringify(cameras).includes('synthetic'), false);
assert.equal(lines.join(' ').includes('synthetic'), false);
});
+75 -333
View File
@@ -43,6 +43,7 @@ import {
TARKTEE_IMAGE_ORIGIN,
DEFAULT_TARKTEE_MAX_SOURCES,
TARKTEE_MAX_CATALOG_BYTES,
TARKTEE_FETCH_TIMEOUT_MS,
TARKTEE_ANCHORS,
DEFAULT_WARENDORF_SOURCE_FILE,
WARENDORF_IMAGE_ORIGINS,
@@ -76,13 +77,7 @@ import {
TORONTO_DOWNTOWN,
TORONTO_GROUND_ELEVATION_M,
TORONTO_MAX_CATALOG_BYTES,
DEFAULT_HK_CAMERAS_URL,
HK_IMAGE_ORIGIN,
DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS,
HK_MAX_CATALOG_BYTES,
CCTV_SOURCE_FETCH_TIMEOUT_MS,
TARKTEE_FETCH_TIMEOUT_MS,
DELDOT_CCTV_URL,
DEFAULT_DELDOT_MAX_SOURCES,
DELDOT_ANCHORS,
@@ -133,6 +128,7 @@ import {
fallbackHeadingFromId,
isLikelyFinlandCoordinate,
fintrafficCameraName,
hashSeed,
isPlausibleLatLon,
isLikelyBcCoordinate,
isLikelyTexasCoordinate,
@@ -142,13 +138,11 @@ import {
isLikelyCataloniaCoordinate,
decodeNumericEntities,
isLikelyNycCoordinate,
isLikelyHongKongCoordinate,
isLikelyNorwayCoordinate,
isLikelyTorontoCoordinate,
cameraDisplayCode,
rowArrayToObject,
prioritizeSources,
pinnedCameraUrl,
} from './normalize.js';
import { directionToHeading } from '../../../src/data/directionText.js';
import {
@@ -310,12 +304,9 @@ export async function loadCaltransSourcesFromOpenData() {
const lon = toFiniteNumber(loc.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const imageUrl = String(cctv.imageData?.static?.currentImageURL || '');
// Official-host pin (see JSDoc). Also drops records with no still image.
const imageUrl = pinnedCameraUrl(
cctv.imageData?.static?.currentImageURL,
'https://cwwp2.dot.ca.gov/',
);
if (!imageUrl) continue;
if (!imageUrl.startsWith('https://cwwp2.dot.ca.gov/')) continue;
const locationName = String(loc.locationName || '').trim();
// Leading token of locationName is the stable camera code ("TV102 -- I-580 : …").
@@ -420,8 +411,8 @@ export async function loadTflSourcesFromOpenData() {
const lat = toFiniteNumber(place?.lat);
const lon = toFiniteNumber(place?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const imageUrl = pinnedCameraUrl(props.imageUrl, TFL_IMAGE_ORIGIN);
if (!imageUrl) continue; // official-bucket pin
const imageUrl = String(props.imageUrl || '');
if (!imageUrl.startsWith(TFL_IMAGE_ORIGIN)) continue; // official-bucket pin
// "JamCams_00002.00865" → "tfl-00002.00865" (provider-stable id).
const rawId = String(place?.id || '').replace(/^JamCams_/, '');
@@ -572,6 +563,7 @@ export async function loadOntarioSourcesFromOpenData(
url.searchParams.set('key', key);
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
redirect: 'error',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
@@ -655,10 +647,11 @@ export async function loadOntarioSourcesFromOpenData(
`[CCTV] Loaded Ontario 511 camera sources: ${unique.length} enabled (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
} catch {
// Transport and JSON errors may reflect the credential-bearing request.
// Do not log upstream exception text, response bodies or URLs.
console.warn(
'[CCTV] Ontario 511 camera download error:',
error?.message || error,
'[CCTV] Ontario 511 camera download error (network or invalid response)',
);
return [];
}
@@ -1130,15 +1123,13 @@ export function loadTallinnSourcesFromCatalog({
// Rough Estonia/Tallinn metro sanity (allows nearby suburbs already in the pack).
if (lat < 59.2 || lat > 59.7 || lon < 24.3 || lon > 25.4) continue;
const imageUrl = pinnedCameraUrl(
const imageUrl =
typeof item.url === 'string'
? item.url
? item.url.trim()
: typeof item.snapshotUrl === 'string'
? item.snapshotUrl
: '',
TALLINN_IMAGE_ORIGIN,
);
if (!imageUrl) continue;
? item.snapshotUrl.trim()
: '';
if (!imageUrl.startsWith(TALLINN_IMAGE_ORIGIN)) continue;
const extractedHeading = toFiniteNumber(item.headingDeg, NaN);
const hasHeading = Number.isFinite(extractedHeading);
@@ -1240,8 +1231,8 @@ export function parseTarkteeDatexImages(xml) {
body,
);
if (!refMatch || !urlMatch) continue;
const url = pinnedCameraUrl(urlMatch[1], TARKTEE_IMAGE_ORIGIN);
if (!url) continue;
const url = urlMatch[1].trim();
if (!url.startsWith(TARKTEE_IMAGE_ORIGIN)) continue;
out.set(refMatch[1], url);
}
return out;
@@ -1257,74 +1248,50 @@ export function parseTarkteeDatexImages(xml) {
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadTarkteeSourcesFromDatex() {
try {
const responses = await Promise.allSettled([
fetch(TARKTEE_LOCATIONS_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
}),
fetch(TARKTEE_IMAGES_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
}),
]);
const [locResp, imgResp] = responses.map((result) =>
result.status === 'fulfilled' ? result.value : undefined,
// Own both transports before either can reject. A response that arrives
// after cleanup must also be discarded instead of escaping Promise.all.
const abort = new AbortController();
const signal = AbortSignal.any([
abort.signal,
AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
]);
const responses = [];
const discardBoth = async () => {
abort.abort();
await Promise.allSettled(
responses.map((response) => response.body?.cancel()),
);
// Both responses are fetched in parallel; if either is rejected or overflows
// its byte cap, both bodies must be released so neither transport leaks.
const discardBoth = async () => {
await Promise.allSettled([
locResp?.body?.cancel(),
imgResp?.body?.cancel(),
]);
return [];
};
const rejected = responses.find((result) => result.status === 'rejected');
if (rejected) {
await discardBoth();
throw rejected.reason;
return [];
};
const fetchXml = async (url) => {
const response = await fetch(url, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal,
});
if (signal.aborted) {
await Promise.allSettled([response.body?.cancel()]);
signal.throwIfAborted();
}
if (
(locResp.status >= 300 && locResp.status < 400) ||
(imgResp.status >= 300 && imgResp.status < 400)
) {
console.warn(
'[CCTV] Tarktee DATEX catalog redirected; redirects are not followed',
responses.push(response);
if (!response.ok) {
throw new Error(
response.status >= 300 && response.status < 400
? 'catalog redirected; redirects are not followed'
: `catalog HTTP ${response.status}`,
);
return discardBoth();
}
if (!locResp.ok) {
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
return discardBoth();
}
if (!imgResp.ok) {
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
return discardBoth();
}
let locXml;
let imgXml;
const readAbort = new AbortController();
try {
[locXml, imgXml] = await Promise.all([
readResponseTextCapped(
locResp,
TARKTEE_MAX_CATALOG_BYTES,
readAbort.signal,
),
readResponseTextCapped(
imgResp,
TARKTEE_MAX_CATALOG_BYTES,
readAbort.signal,
),
]);
} catch (error) {
readAbort.abort();
await discardBoth();
throw error;
}
return response;
};
try {
const [locResp, imgResp] = await Promise.all([
fetchXml(TARKTEE_LOCATIONS_URL),
fetchXml(TARKTEE_IMAGES_URL),
]);
const [locXml, imgXml] = await Promise.all([
readResponseTextCapped(locResp, TARKTEE_MAX_CATALOG_BYTES, signal),
readResponseTextCapped(imgResp, TARKTEE_MAX_CATALOG_BYTES, signal),
]);
const locations = parseTarkteeDatexLocations(locXml);
const images = parseTarkteeDatexImages(imgXml);
if (!locations.size || !images.size) {
@@ -1385,6 +1352,7 @@ export async function loadTarkteeSourcesFromDatex() {
);
return prioritized;
} catch (error) {
await discardBoth();
console.warn(
'[CCTV] Tarktee DATEX download error:',
error?.message || error,
@@ -1427,16 +1395,14 @@ export function loadWarendorfSourcesFromCatalog({
for (const item of rows) {
if (!item || typeof item !== 'object') continue;
const id = typeof item.id === 'string' ? item.id.trim() : '';
const raw =
const url =
typeof item.url === 'string'
? item.url
? item.url.trim()
: typeof item.snapshotUrl === 'string'
? item.snapshotUrl
? item.snapshotUrl.trim()
: '';
const url = WARENDORF_IMAGE_ORIGINS.map((o) =>
pinnedCameraUrl(raw, o),
).find(Boolean);
if (!id || !url) continue;
if (!id || !WARENDORF_IMAGE_ORIGINS.some((o) => url.startsWith(o)))
continue;
const lat = typeof item.lat === 'number' ? item.lat : NaN;
const lon = typeof item.lon === 'number' ? item.lon : NaN;
if (!isPlausibleLatLon(lat, lon)) continue;
@@ -1490,8 +1456,8 @@ export function nswCameraToSource(feature) {
const lat = typeof coords?.[1] === 'number' ? coords[1] : NaN;
if (!isLikelyNswCoordinate(lat, lon)) return null;
const props = feature?.properties || {};
const url = pinnedCameraUrl(props.href, NSW_IMAGE_ORIGIN);
if (!url) return null;
const url = String(props.href || '').trim();
if (!url.startsWith(NSW_IMAGE_ORIGIN)) return null;
// "N-E" -> "NE" for the compass lookup.
const direction = String(props.direction || '')
.trim()
@@ -1585,7 +1551,8 @@ export function normalizeCalgaryImageUrl(raw) {
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
parsed.protocol = 'https:';
return pinnedCameraUrl(parsed.toString(), CALGARY_IMAGE_ORIGIN);
const upgraded = parsed.toString();
return upgraded.startsWith(CALGARY_IMAGE_ORIGIN) ? upgraded : null;
}
/**
@@ -1868,7 +1835,7 @@ export function normalizeCataloniaImageUrl(rawUrl) {
* @param {string} imageUrl - A URL normalizeCataloniaImageUrl accepted.
* @returns {string}
*/
function cataloniaCameraId(imageUrl) {
export function cataloniaCameraId(imageUrl) {
const parsed = new URL(imageUrl);
const slug = (text) =>
text
@@ -2015,225 +1982,6 @@ export async function loadCataloniaSourcesFromOpenData() {
}
}
/**
* Read one XML child tag's text from an `<image>` block.
*
* @param {string} body
* @param {string} tag
* @returns {string}
*/
function hkXmlTag(body, tag) {
const match = new RegExp(`<${tag}>\\s*([^<]*?)\\s*</${tag}>`, 'i').exec(
String(body || ''),
);
return match ? match[1].trim() : '';
}
/**
* Parse the Transport Department English camera-locations XML into rows.
*
* Schema-specific: each `<image>` carries key, description, lat/lon and a
* still URL. No XML dependency — same regex style as the Tarktee DATEX parser.
*
* @param {string} xml
* @returns {Array<object>}
*/
export function parseHkCameraLocationsXml(xml) {
const rows = [];
const blockRe = /<image>([\s\S]*?)<\/image>/gi;
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const body = match[1];
rows.push({
key: hkXmlTag(body, 'key'),
region: hkXmlTag(body, 'region'),
district: hkXmlTag(body, 'district'),
description: hkXmlTag(body, 'description'),
latitude: hkXmlTag(body, 'latitude'),
longitude: hkXmlTag(body, 'longitude'),
url: hkXmlTag(body, 'url'),
});
}
return rows;
}
/**
* Build a host-pinned still URL from a Transport Department camera key.
*
* The catalog also publishes a URL; the registered frame is always rebuilt
* from the key so a catalog edit cannot steer the proxy off-origin.
*
* @param {string|null|undefined} key
* @returns {?string}
*/
export function hkImageUrlFromKey(key) {
const id = String(key ?? '')
.trim()
.toUpperCase();
if (!/^[A-Z0-9]{1,32}$/.test(id)) return null;
return `${HK_IMAGE_ORIGIN}${id}.JPG`;
}
/**
* Pin a catalog frame URL to the Transport Department still host.
*
* @param {string|null|undefined} raw
* @returns {?string}
*/
export function normalizeHkImageUrl(raw) {
const text = String(raw ?? '').trim();
if (!text) return null;
let parsed;
try {
parsed = new URL(text);
} catch {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
parsed.protocol = 'https:';
return pinnedCameraUrl(parsed.toString(), HK_IMAGE_ORIGIN);
}
/**
* Label for one HK camera: the published English description, else the key.
*
* @param {object} record
* @param {string} cameraId
* @returns {string}
*/
export function hkCameraName(record, cameraId) {
const description = String(record?.description ?? '').trim();
if (description) return description;
const key = String(record?.key ?? '').trim();
if (key) return key;
return `HK Camera ${String(cameraId).replace(/^hk-/, '')}`;
}
/**
* One Transport Department locations row -> one catalog source, or null.
*
* The dataset publishes no camera facing, so headings use the shared id-hash
* fallback at low confidence and are corrected with the calibration gizmo.
*
* @param {object} record
* @returns {?object}
*/
export function hkCameraToSource(record) {
if (!record || typeof record !== 'object') return null;
const key = String(record.key ?? '')
.trim()
.toUpperCase();
if (!key) return null;
const lat = toFiniteNumber(record.latitude);
const lon = toFiniteNumber(record.longitude);
if (!isLikelyHongKongCoordinate(lat, lon)) return null;
const imageUrl = hkImageUrlFromKey(key);
if (!imageUrl) return null;
// If the catalog carries a URL, it must already be on the pinned host.
const catalogUrl = String(record.url ?? '').trim();
if (catalogUrl) {
const pinnedCatalog = normalizeHkImageUrl(catalogUrl);
if (!pinnedCatalog) return null;
}
const cameraId = `hk-${key.toLowerCase()}`;
const name = hkCameraName(record, cameraId);
const region = String(record.region ?? '').trim();
return {
id: cameraId,
name,
city: region || 'Hong Kong',
cityId: 'hk',
provider: 'Transport Department',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
// Coastal / harbour prior; the client's one-shot ground snap corrects.
groundElevationM: 15,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'hk-td-open-data',
license:
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
code: cameraDisplayCode(name.toUpperCase()),
};
}
/**
* Fetch Hong Kong Transport Department traffic snapshot cameras from the
* keyless DATA.GOV.HK English locations XML. Frames are stills on
* tdcctv.data.one.gov.hk.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadHkSourcesFromOpenData() {
try {
const endpoint = process.env.CCTV_HK_CAMERAS_URL || DEFAULT_HK_CAMERAS_URL;
const resp = await fetch(endpoint, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn('[CCTV] HK catalog redirected; redirects are not followed');
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] HK camera download failed:', resp.status);
return discard();
}
let xml;
try {
xml = await readResponseTextCapped(resp, HK_MAX_CATALOG_BYTES);
} catch (error) {
if (error?.code === 'RESPONSE_TOO_LARGE') {
console.warn('[CCTV] HK catalog body exceeded byte ceiling');
return [];
}
throw error;
}
const rows = parseHkCameraLocationsXml(xml);
const cameras = [];
const seen = new Set();
for (const record of rows) {
const camera = hkCameraToSource(record);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_HK_MAX_SOURCES || DEFAULT_HK_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
: DEFAULT_HK_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, HK_ANCHORS);
console.log(
`[CCTV] Loaded HK camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn('[CCTV] HK camera download error:', error?.message || error);
return [];
}
}
export async function loadDelDOTSourcesFromOpenData() {
try {
const resp = await fetch(DELDOT_CCTV_URL, {
@@ -2428,16 +2176,10 @@ export async function loadKingCountySourcesFromOpenData() {
// county image origin, so they are skipped.
if (owner === 'WSDOT') continue;
// The handler is served over HTTPS; the feed still writes http://.
const imageUrl = pinnedCameraUrl(
String(attrs.ImageURL || '')
.trim()
.replace(
/^http:\/\/info\.kingcounty\.gov\//,
KING_COUNTY_IMAGE_ORIGIN,
),
KING_COUNTY_IMAGE_ORIGIN,
);
if (!imageUrl) continue;
const imageUrl = String(attrs.ImageURL || '')
.trim()
.replace(/^http:\/\/info\.kingcounty\.gov\//, KING_COUNTY_IMAGE_ORIGIN);
if (!imageUrl.startsWith(KING_COUNTY_IMAGE_ORIGIN)) continue;
const [lon, lat] = Array.isArray(row?.geometry?.coordinates)
? row.geometry.coordinates
: [];
@@ -2597,8 +2339,8 @@ export async function loadWsdotSourcesFromOpenData() {
const attrs = row?.attributes;
const rawId = attrs?.CameraID;
if (!Number.isSafeInteger(rawId) || rawId <= 0) continue;
const imageUrl = pinnedCameraUrl(attrs.ImageURL, WSDOT_IMAGE_ORIGIN);
if (!imageUrl) continue;
const imageUrl = String(attrs.ImageURL || '').trim();
if (!imageUrl.startsWith(WSDOT_IMAGE_ORIGIN)) continue;
const x = row?.geometry?.x;
const y = row?.geometry?.y;
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
+1 -1
View File
@@ -70,7 +70,7 @@ export async function fetchHlsBytes(
}
}
function sameOriginHlsUrl(value, base) {
export function sameOriginHlsUrl(value, base) {
const url = new URL(value, base);
if (
!['https:', 'http:'].includes(url.protocol) ||
-303
View File
@@ -1,303 +0,0 @@
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
import {
cameraDisplayCode,
fallbackHeadingFromId,
prioritizeSources,
} from './normalize.js';
import { readResponseTextCapped } from '../common/http.js';
/** Freeway Bureau (MOTC) CCTV static table in the national MOTC 2.0 XML
* standard, published keyless and refreshed daily (~1,800 cameras). */
export const TAIWAN_FREEWAY_CCTV_URL =
'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml';
/** The whole network is ~1.2 MB. */
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
const DEFAULT_TAIWAN_MAX_SOURCES = 600;
/** After a failed download, wait this long before asking again. The catalog
* host does not answer from some networks outside Taiwan, and every attempt
* there holds the shared catalog refresh until the connect timeout. */
export const TAIWAN_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000;
/** The table is republished daily; a good copy stands in for a failed
* download for up to this long. */
const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000;
// The table publishes each camera's MJPEG stream. The same camera servers
// answer a single JPEG for the same camera ID on a sibling path, which is what
// the frame proxy can relay; only these exact pairs are ever registered.
const STREAM_TO_FRAME = new Map([
['cctvn.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
['cctvn5.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
['cctvc.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']],
[
'cctvs.freeway.gov.tw',
['/live-view/mjpg/video.cgi', '/live-view/jpg/image.cgi'],
],
]);
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
/** Prioritization anchors, north to south, so a cap keeps the whole island
* rather than only greater Taipei. */
const ANCHORS = [
{ lat: 25.033, lon: 121.5654 }, // Taipei
{ lat: 24.9936, lon: 121.301 }, // Taoyuan
{ lat: 24.8138, lon: 120.9675 }, // Hsinchu
{ lat: 24.7021, lon: 121.7378 }, // Yilan
{ lat: 24.1477, lon: 120.6736 }, // Taichung
{ lat: 22.9999, lon: 120.227 }, // Tainan
{ lat: 22.6273, lon: 120.3014 }, // Kaohsiung
];
/** Taiwan main island with slack for the coast; the freeway network has no
* offshore-island cameras. */
function isLikelyTaiwanCoordinate(lat, lon) {
return (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
lat >= 21.8 &&
lat <= 25.4 &&
lon >= 119.9 &&
lon <= 122.1
);
}
const XML_ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
};
function codePoint(value, radix) {
const code = parseInt(value, radix);
return Number.isInteger(code) && code > 0 && code <= 0x10ffff
? String.fromCodePoint(code)
: '';
}
function decodeXml(text) {
return String(text ?? '')
.replace(
/&(?:(amp|lt|gt|quot|apos)|#(\d{1,7})|#x([0-9a-fA-F]{1,6}));/g,
(_, name, dec, hex) =>
name ? XML_ENTITIES[name] : codePoint(dec ?? hex, dec ? 10 : 16),
)
.trim();
}
function tag(block, name) {
const match = new RegExp(`<${name}>([^<]*)</${name}>`).exec(block);
return match ? decodeXml(match[1]) : '';
}
/**
* Parse the MOTC 2.0 `CCTVList` document into flat rows. Regex-based like the
* Catalonia and Tarktee parsers: the schema is flat, and only the fields used
* below are read.
*
* @param {string} xml
* @returns {Array<object>}
*/
export function parseTaiwanCctvXml(xml) {
const rows = [];
const blockRe = /<CCTV>([\s\S]*?)<\/CCTV>/g;
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const block = match[1];
rows.push({
cctvId: tag(block, 'CCTVID'),
streamUrl: tag(block, 'VideoStreamURL'),
lat: Number(tag(block, 'PositionLat')),
lon: Number(tag(block, 'PositionLon')),
roadName: tag(block, 'RoadName'),
direction: tag(block, 'RoadDirection'),
sectionStart: tag(block, 'Start'),
sectionEnd: tag(block, 'End'),
mile: tag(block, 'LocationMile'),
});
}
return rows;
}
/**
* Published MJPEG stream URL -> the same camera's still, plus the host and
* camera number that identify it, or null.
*
* @param {string} streamUrl
* @returns {?{frameUrl: string, host: string, camera: string}}
*/
export function taiwanFrame(streamUrl) {
let url;
try {
url = new URL(String(streamUrl ?? ''));
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password || url.port)
return null;
const paths = STREAM_TO_FRAME.get(url.hostname);
if (!paths || url.pathname !== paths[0]) return null;
const camera = url.searchParams.get('camera') || '';
if (!/^[A-Za-z0-9-]{1,40}$/.test(camera)) return null;
return {
// Reconstruct so extra query parameters cannot alter the request.
frameUrl: `https://${url.hostname}${paths[1]}?camera=${camera}`,
host: url.hostname.split('.')[0],
camera,
};
}
/**
* One parsed Freeway Bureau row -> one catalog source, or null.
*
* @param {object} row - Output of parseTaiwanCctvXml.
* @returns {?object}
*/
export function taiwanCameraToSource(row) {
if (!row || typeof row !== 'object') return null;
const { lat, lon } = row;
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
const frame = taiwanFrame(row.streamUrl);
if (!frame) return null;
const imageUrl = frame.frameUrl;
// CCTVID carries Chinese place names and a few IDs repeat across distinct
// cameras, so the stable key is the camera server plus its camera number.
const rawId = String(row.cctvId ?? '')
.trim()
.slice(0, 80);
const cameraId = `tw-freeway-${frame.host}-${frame.camera.toLowerCase()}`;
const road = String(row.roadName ?? '').trim();
const direction = DIRECTIONS[String(row.direction ?? '').trim()] || '';
const mile = String(row.mile ?? '').trim();
const section = [row.sectionStart, row.sectionEnd]
.map((part) => String(part ?? '').trim())
.filter(Boolean)
.join('→');
// A bare carriageway letter is not a name; fall back to the published ID.
const name =
(road || mile || section
? [road, direction, mile, section].filter(Boolean).join(' ')
: '') ||
rawId ||
`Freeway camera ${frame.camera}`;
return {
id: cameraId,
name: name.slice(0, 160),
// One island-wide category in the camera picker, like Finland and Norway.
city: 'Taiwan / 臺灣',
cityId: 'taiwan',
provider: 'Freeway Bureau, MOTC',
lat,
lon,
// The table gives the carriageway direction, not the camera's bearing,
// so the pose uses the shared low-confidence prior.
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'tw-freeway-motc',
license:
'Freeway Bureau, MOTC open data, Open Government Data License, Taiwan (Version 1.0)',
code: cameraDisplayCode(
`${road} ${mile}`.trim().toUpperCase() || frame.camera,
),
};
}
let lastGood = null;
let lastGoodAt = 0;
let retryAfter = 0;
/** Forget the last good table and any failure backoff (tests only). */
export function resetTaiwanSourcesCache() {
lastGood = null;
lastGoodAt = 0;
retryAfter = 0;
}
function fallbackSources() {
return lastGood && Date.now() - lastGoodAt <= TAIWAN_LAST_GOOD_MAX_AGE_MS
? lastGood
: [];
}
function taiwanSourceCap() {
const rawCap = Number(
process.env.CCTV_TAIWAN_MAX_SOURCES || DEFAULT_TAIWAN_MAX_SOURCES,
);
return Number.isFinite(rawCap)
? Math.max(1, Math.min(2000, Math.floor(rawCap)))
: DEFAULT_TAIWAN_MAX_SOURCES;
}
/**
* Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table.
* One bounded request per shared catalog refresh. A failed download serves the
* last good table (up to a day old) and is not retried for an hour, so a host
* that cannot reach the Freeway Bureau does not wait on it every refresh.
*
* @returns {Promise<Array<object>>}
*/
export async function loadTaiwanSources() {
if (Date.now() < retryAfter) return fallbackSources();
try {
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
const response = await fetch(
process.env.CCTV_TAIWAN_URL || TAIWAN_FREEWAY_CCTV_URL,
{
headers: { Accept: 'application/xml,text/xml' },
redirect: 'manual',
signal,
},
);
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error(
response.status >= 300 && response.status < 400
? 'catalog redirected; redirects are not followed'
: `HTTP ${response.status}`,
);
}
const xml = await readResponseTextCapped(
response,
TAIWAN_MAX_CATALOG_BYTES,
signal,
);
const cameras = new Map();
for (const row of parseTaiwanCctvXml(xml)) {
const camera = taiwanCameraToSource(row);
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
}
if (!cameras.size) throw new Error('no usable cameras in the table');
const sources = prioritizeSources(
[...cameras.values()],
taiwanSourceCap(),
ANCHORS,
);
console.log(
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
);
lastGood = sources;
lastGoodAt = Date.now();
retryAfter = 0;
return sources;
} catch (error) {
retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS;
const fallback = fallbackSources();
console.warn(
`[CCTV] Taiwan camera download failed (${error?.message || error}); ` +
(fallback.length
? `serving the last good ${fallback.length} cameras`
: 'no Taiwan cameras') +
', next attempt in 1 h',
);
return fallback;
}
}
Loaded 100 of 484 files, more files were not shown because too many files have changed in this diff. Show more