mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 05:12:23 +00:00
Compare commits
258
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a904562f0f | ||
|
|
1a8defbfc8 | ||
|
|
78c5b6c245 | ||
|
|
28907caf7b | ||
|
|
a7f96313e9 | ||
|
|
da134328ef | ||
|
|
09520bc34c | ||
|
|
9bce41d3c6 | ||
|
|
04b8c856ad | ||
|
|
e5da073d33 | ||
|
|
ddd07f13f0 | ||
|
|
10784fa9c9 | ||
|
|
fce5ee6381 | ||
|
|
d90da158ea | ||
|
|
b04fcf191c | ||
|
|
581ef7eef4 | ||
|
|
015e3ff7fc | ||
|
|
72a240ba6f | ||
|
|
77df600329 | ||
|
|
0dbc657bf9 | ||
|
|
e22348a8e1 | ||
|
|
75e8e7ddbd | ||
|
|
9787d9c4cc | ||
|
|
2725f307a6 | ||
|
|
cf05a29b7f | ||
|
|
45108a0b0b | ||
|
|
0600d86584 | ||
|
|
4da477a654 | ||
|
|
c1ca0f489c | ||
|
|
cdf52ab290 | ||
|
|
c3738c0fdb | ||
|
|
ace88172d3 | ||
|
|
6a7a09372a | ||
|
|
3e630cf34f | ||
|
|
9d7fe8fcaa | ||
|
|
a45d6af5f7 | ||
|
|
caad795fa2 | ||
|
|
09bf01630b | ||
|
|
2ea4a78e75 | ||
|
|
61e9cf87f9 | ||
|
|
2c1b2c278d | ||
|
|
7a053c640d | ||
|
|
a5b40dfc3d | ||
|
|
be9c37bee2 | ||
|
|
394f3216f8 | ||
|
|
c62f7ef00e | ||
|
|
dbb082355a | ||
|
|
da6b63b6fd | ||
|
|
3150a460aa | ||
|
|
c2bff536a5 | ||
|
|
d52c719ef6 | ||
|
|
9f2dfdf820 | ||
|
|
027c9616a5 | ||
|
|
c1c71d1987 | ||
|
|
26718edc0b | ||
|
|
2f31828d28 | ||
|
|
c5e1951f51 | ||
|
|
f6dc89b4fa | ||
|
|
e954734379 | ||
|
|
5a6cf22be1 | ||
|
|
63aa45c520 | ||
|
|
1453794573 | ||
|
|
eb1969e874 | ||
|
|
034acb06a8 | ||
|
|
27847bd063 | ||
|
|
4ee617cf42 | ||
|
|
577170ee8e | ||
|
|
6be8af17f1 | ||
|
|
7d6314b981 | ||
|
|
7b844792be | ||
|
|
dd02068ce9 | ||
|
|
fad62a4ba8 | ||
|
|
569c7cfb1b | ||
|
|
b18336d3d4 | ||
|
|
dd063b0e83 | ||
|
|
567c833582 | ||
|
|
745c11969b | ||
|
|
4396ba4506 | ||
|
|
b953c3f1dc | ||
|
|
a274d10038 | ||
|
|
9a16fb8cbc | ||
|
|
e944f75275 | ||
|
|
83a24b8d27 | ||
|
|
4ab23733e9 | ||
|
|
f1ed762e84 | ||
|
|
30fbfe238f | ||
|
|
573479d7ab | ||
|
|
3bf7e9edb7 | ||
|
|
e968233a52 | ||
|
|
85d0f84066 | ||
|
|
bf29408d00 | ||
|
|
b72f453fea | ||
|
|
6ca53febad | ||
|
|
5c25bc54e6 | ||
|
|
a5df391d6c | ||
|
|
af8dc37df5 | ||
|
|
19d7148b06 | ||
|
|
59c0997636 | ||
|
|
fe5470a852 | ||
|
|
d0ef5250ac | ||
|
|
048293fc2a | ||
|
|
a648c9b665 | ||
|
|
eaf2b8f5b4 | ||
|
|
83dd1af22e | ||
|
|
608ab4adea | ||
|
|
cc24da165b | ||
|
|
3bb742ae7e | ||
|
|
7dcf671b2e | ||
|
|
b59eb8b18c | ||
|
|
8bd16b0be9 | ||
|
|
c84cfcadeb | ||
|
|
8ae56587c3 | ||
|
|
d404445da3 | ||
|
|
63e6338c19 | ||
|
|
c56f098d07 | ||
|
|
922712121c | ||
|
|
f62d947a82 | ||
|
|
e9346cef19 | ||
|
|
1b9bb844e6 | ||
|
|
1bc4f980e0 | ||
|
|
80a33ba52c | ||
|
|
5c937e67e1 | ||
|
|
f96a89a9ef | ||
|
|
0ba7ba6512 | ||
|
|
9abc105266 | ||
|
|
0faa29dfbd | ||
|
|
af21219911 | ||
|
|
620a18608e | ||
|
|
bbe33b48c7 | ||
|
|
3ce3fce46f | ||
|
|
052731202d | ||
|
|
18c15d0352 | ||
|
|
36a2216528 | ||
|
|
b98bc4b0dc | ||
|
|
7ee7dc26b8 | ||
|
|
ff0b4d8449 | ||
|
|
424bcb0843 | ||
|
|
137639dd38 | ||
|
|
84f3476dd3 | ||
|
|
e631839a4e | ||
|
|
543c607b23 | ||
|
|
923287eb92 | ||
|
|
71b694ab62 | ||
|
|
e0ba12e5a9 | ||
|
|
c5bdcdf37f | ||
|
|
83bd48102a | ||
|
|
d55acd2ac5 | ||
|
|
72112ecb45 | ||
|
|
52187c91d1 | ||
|
|
084a63ab5f | ||
|
|
6af14f7f13 | ||
|
|
8a14029501 | ||
|
|
cc3d390911 | ||
|
|
b99c2f1ade | ||
|
|
6150e21ebb | ||
|
|
a1d3003b57 | ||
|
|
ca6eace79e | ||
|
|
39d70fa1c0 | ||
|
|
8455c4bce1 | ||
|
|
872bbefb63 | ||
|
|
2213d3d8cf | ||
|
|
6cadfc9583 | ||
|
|
2cac463c6f | ||
|
|
479286a6a6 | ||
|
|
9b65d41bab | ||
|
|
891eb56091 | ||
|
|
c0a5d4141d | ||
|
|
f5d5da7023 | ||
|
|
021e887f94 | ||
|
|
8ec92eb48c | ||
|
|
5e42a83d88 | ||
|
|
36c29d6ab4 | ||
|
|
b86c650428 | ||
|
|
6e4da23cf7 | ||
|
|
574430fb1d | ||
|
|
9c8bb206ef | ||
|
|
c06eb89190 | ||
|
|
ea76fc4bc0 | ||
|
|
5c4918affb | ||
|
|
3a78f40024 | ||
|
|
697b6b59e6 | ||
|
|
433b336c2d | ||
|
|
f717f8a0d7 | ||
|
|
d0bf21b8c3 | ||
|
|
90db4eb2c9 | ||
|
|
df87419e64 | ||
|
|
e5ab5f945a | ||
|
|
25c33893f6 | ||
|
|
b6ad32009f | ||
|
|
b37025414d | ||
|
|
79c867c8cb | ||
|
|
e2ccac56ae | ||
|
|
a1cee50d86 | ||
|
|
c2a04c11b7 | ||
|
|
b342e405d7 | ||
|
|
84c1fef8d3 | ||
|
|
29a95211c5 | ||
|
|
1c2cdc6775 | ||
|
|
4f43ded5da | ||
|
|
13c3b011be | ||
|
|
a64b41b3f0 | ||
|
|
0e0557e7f5 | ||
|
|
6dca8d943a | ||
|
|
4e40594d4f | ||
|
|
50cd03eb03 | ||
|
|
b47c91965c | ||
|
|
90f9050b4c | ||
|
|
5f686b7289 | ||
|
|
b7de7c1113 | ||
|
|
44581dccaf | ||
|
|
db8afbae81 | ||
|
|
ccd83c5fa2 | ||
|
|
d1ed2e8e6f | ||
|
|
ae55963a0e | ||
|
|
fa4ce4b2a5 | ||
|
|
bc61a39738 | ||
|
|
9ab54675ba | ||
|
|
3cecc8efbe | ||
|
|
8eab6a76a8 | ||
|
|
4fc9722356 | ||
|
|
287c881c17 | ||
|
|
497016b084 | ||
|
|
724109ee17 | ||
|
|
09d833ed96 | ||
|
|
0d96fe6acd | ||
|
|
32e08f5043 | ||
|
|
72ceeb2fce | ||
|
|
056b3b58c6 | ||
|
|
d04e304502 | ||
|
|
e7973e81cf | ||
|
|
0aa54dfa97 | ||
|
|
16965c4fbc | ||
|
|
ed2aaa2c50 | ||
|
|
48093108b1 | ||
|
|
9faec3028e | ||
|
|
3534398771 | ||
|
|
690a6637e2 | ||
|
|
434fc9602d | ||
|
|
32ec6ed436 | ||
|
|
0c4da3cdb2 | ||
|
|
bbfb23a765 | ||
|
|
5f23c785ca | ||
|
|
73f03771d8 | ||
|
|
a160a9e953 | ||
|
|
6a086cb16f | ||
|
|
6977770067 | ||
|
|
2b7c4d1c65 | ||
|
|
d3370d6bc4 | ||
|
|
1133e6327a | ||
|
|
46717548c4 | ||
|
|
85ce864bc4 | ||
|
|
e7dd71e9bc | ||
|
|
47b13ab18e | ||
|
|
daed0be99a | ||
|
|
2e5dfce1af | ||
|
|
fb6b4e34f0 | ||
|
|
b09db55149 | ||
|
|
da689e03ac |
No files matched your search
+9
-1
@@ -218,6 +218,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
||||
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
|
||||
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
|
||||
# OTTAWA_CAMERA_CERTIFICATE= # City of Ottawa Traffic Camera Images API certificate (free; request it via the form on traffic.ottawa.ca 'About the data'); without it the Ottawa pack is skipped
|
||||
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
|
||||
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
||||
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
||||
@@ -238,6 +239,8 @@ 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_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras
|
||||
# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views 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
|
||||
@@ -268,7 +271,12 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
|
||||
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
||||
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
|
||||
# CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first)
|
||||
# CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links)
|
||||
# CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md)
|
||||
# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Oregon and MLIT (Japan) still cameras
|
||||
# CCTV_MAX_SOURCES=7350 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
|
||||
@@ -59,10 +59,6 @@ jobs:
|
||||
- name: Build production bundle
|
||||
run: npm run build
|
||||
|
||||
- name: Main chunk size budget
|
||||
if: matrix.node == '24.22.2'
|
||||
run: npm run check:main-chunk
|
||||
|
||||
street-level-browser:
|
||||
# Hermetic: every Mapillary request is answered from fixtures, so the
|
||||
# dummy token below never leaves the page.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- An application's own tool can take the pointer as Draw does, and its click
|
||||
handlers can yield while a tool holds it, as the layers' do (`pointer` from
|
||||
`createApplicationTools`) (samehkhamis, #1641).
|
||||
|
||||
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
|
||||
|
||||
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ prints anything; never fix that by renaming or deleting PR A's published token.
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md).
|
||||
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
|
||||
- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
|
||||
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
||||
|
||||
+75
-10
@@ -5,7 +5,7 @@ God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the
|
||||
How to read this:
|
||||
|
||||
- **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms.
|
||||
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. **EOX Sentinel-2 cloudless 2020** is also **NonCommercial** (CC BY-NC-SA 4.0): remove that basemap or obtain a commercial licence from EOX.
|
||||
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder.
|
||||
- **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes.
|
||||
- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status.
|
||||
|
||||
@@ -30,10 +30,10 @@ How to read this:
|
||||
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
|
||||
| **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo, linked to its Planespotters page; "Planespotters.net" in Data attribution |
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/); [CC BY-NC-SA 4.0](https://cloudless.eox.at/license-non-commercial). Commercial use requires a commercial licence from EOX. | [EOxCloudless](https://cloudless.eox.at/) by [EOX IT Services GmbH](https://eox.at/) (Contains modified Copernicus Sentinel data 2020) |
|
||||
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
|
||||
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
|
||||
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
|
||||
@@ -66,9 +66,11 @@ How to read this:
|
||||
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
|
||||
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
|
||||
| **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 |
|
||||
| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 |
|
||||
| **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" |
|
||||
| **Webcam Romania — Giroc** (`webcamromania.ro`, `w4.webcamromania.ro`) | Optional Giroc camera metadata, JPEG snapshot and live HLS video | [Provider terms](https://webcamromania.ro/termeni-si-conditii/): video belongs to the original camera operator; no open video redistribution licence asserted. The [camera page](https://webcamromania.ro/webcam-orase/webcam-giroc/) offers an embed. Local opt-in via `CCTV_SOURCES_FILE`; media is fetched at runtime, never bundled. | "Webcam Romania · webcamromania.ro"; source link and rights notice in the Data attribution popover |
|
||||
| **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) |
|
||||
@@ -102,6 +104,40 @@ How to read this:
|
||||
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
|
||||
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
||||
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
|
||||
| **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy |
|
||||
| **Carris Metropolitana** (api.carrismetropolitana.pt) | Transit layer live vehicles, Lisbon Metropolitan Area, Portugal | Keyless GTFS-Realtime documented in the operator’s public API repository (github.com/carrismetropolitana/api). No licence is stated for the realtime endpoint; the operator’s static GTFS is CC BY 4.0. Included on the same basis as the Metro Transit and OVapi feeds and credited as a courtesy; it can be switched off if Carris objects. Speeds are published in km/h and converted to m/s by the proxy. Route ids are read as the line number per the operator’s `/v2/routes`. | Courtesy credit to Carris Metropolitana |
|
||||
| **Prague PID** (api.golemio.cz) | Transit layer live vehicles, Prague and Central Bohemia, Czechia | CC BY (https://pid.cz/o-systemu/opendata/). Stated: “Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY” The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1), read from PID’s GTFS routes.txt. | Pražská integrovaná doprava (PID) / Golemio |
|
||||
| **Warsaw buses and trams** (mkuran.pl) | Transit layer live vehicles, Warsaw, Poland | Warsaw Open Data Project Usage Terms (https://mkuran.pl/gtfs/). Stated: “The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.” Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API and carries the date of the feed timestamp, the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs. | Miasto Stołeczne Warszawa (api.um.warszawa.pl) |
|
||||
| **Bordeaux TBM** (bdx.mecatran.com) | Transit layer live vehicles, Bordeaux, France | Licence Ouverte / Etalab 2.0 (https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite). The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), mapped from TBM’s static GTFS; other numbered lines are buses. | Bordeaux Métropole (TBM) |
|
||||
| **Sendai City Bus** (api-public.odpt.org) | Transit layer live vehicles, Sendai, Japan | CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s. | 仙台市交通局 (Sendai City Transportation Bureau) / ODPT |
|
||||
| **Poznań buses and trams** (ztm.poznan.pl) | Transit layer live vehicles, Poznań, Poland | ZTM Poznań open-data terms (https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/). Stated: “Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.” Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp as the update date. The feed carries no bearing, so heading comes from movement between fixes. | Zarząd Transportu Miejskiego w Poznaniu (processed) |
|
||||
| **Gdańsk buses and trams** (ckan2.multimediagdansk.pl) | Transit layer live vehicles, Gdańsk, Poland | CC BY 4.0 (https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt). The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s. | Zarząd Transportu Miejskiego w Gdańsku |
|
||||
| **Edmonton Transit Service** (gtfs.edmonton.ca) | Transit layer live vehicles, Edmonton, AB | City of Edmonton Open Data Agreement (https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf). The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s. | Contains information licensed under the Open Data Agreement – City of Edmonton |
|
||||
| **Halifax Transit** (gtfs.halifax.ca) | Transit layer live vehicles, Halifax, NS | Open Government Licence – Halifax v1.0 (https://www.halifax.ca/home/open-data/open-data-license), modelled on OGL – Canada 2.0. Stated: “The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.” No logos or implied endorsement. Speeds are m/s. | Contains information licenced under the Open Government Licence – Halifax |
|
||||
| **Victoria Regional Transit** (bct.tmix.se) | Transit layer live vehicles, Victoria, BC | BC Transit Open Data Terms of Use (https://www.bctransit.com/open-data/terms-of-use). Stated: “BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.” The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s. | BC Transit |
|
||||
| **Montpellier TaM** (gtfsproxy.e-tam.fr) | Transit layer live vehicles, Montpellier, France | ODbL (https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain). The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them. | Montpellier Méditerranée Métropole (TaM) |
|
||||
| **Kraków buses** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects. | Zarząd Transportu Publicznego w Krakowie |
|
||||
| **Kraków trams** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). Same server and basis as the bus feed. | Zarząd Transportu Publicznego w Krakowie |
|
||||
| **Vilnius public transport** (stops.lt) | Transit layer live vehicles, Vilnius, Lithuania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383). Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since. | SĮ Susisiekimo paslaugos (stops.lt) |
|
||||
| **Bucharest public transport** (gtfs.tpbi.ro) | Transit layer live vehicles, Bucharest, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104). Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi. | TPBI (Transport Public București–Ilfov) |
|
||||
| **Rome public transport** (romamobilita.it) | Transit layer live vehicles, Rome, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776). Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement. | Roma Servizi per la Mobilità |
|
||||
| **Turin GTT** (percorsieorari.gtt.to.it) | Transit layer live vehicles, Turin, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines. | Gruppo Torinese Trasporti (GTT) |
|
||||
| **Venice ACTV buses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`). | ACTV S.p.A. |
|
||||
| **Venice ACTV waterbuses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). Same catalogue and basis as the ACTV bus feed. | ACTV S.p.A. |
|
||||
| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Transit layer live vehicles, Adelaide, SA | No licence stated for the realtime feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772). Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override. | Adelaide Metro |
|
||||
| **MARTA buses** (gtfs-rt.itsmarta.com) | Transit layer live vehicles, Atlanta, GA | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s. | MARTA |
|
||||
| **Denver RTD** (open-data.rtd-denver.com) | Transit layer live vehicles, Denver, CO | RTD GTFS-realtime licence agreement (https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement). Stated: “RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data”. Read 2026-11-09: a click-through agreement that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from use with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s. | Regional Transportation District (RTD) |
|
||||
| **Cluj-Napoca CTP** (cluj-rt-feed.gtfs.ro) | Transit layer live vehicles, Cluj-Napoca, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Cluj-Napoca |
|
||||
| **Iași CTP** (iasi-rt-feed.gtfs.ro) | Transit layer live vehicles, Iași, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Iași |
|
||||
| **Rzeszów city transport** (mpkrzeszow.pl) | Transit layer live vehicles, Rzeszów, Poland | MPK Rzeszów open data (https://www.mpkrzeszow.pl/otwartedane/). Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement. | MPK Rzeszów |
|
||||
| **Málaga EMT** (gtfs.emtmalaga.es) | Transit layer live vehicles, Málaga, Spain | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213). Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement. | EMT Málaga |
|
||||
| **Carris (Lisbon city)** (gateway.carris.pt) | Transit layer live vehicles, Lisbon, Portugal | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930). Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant. | Carris |
|
||||
| **BAS.MY Johor Bahru** (api.data.gov.my) | Transit layer live vehicles, Johor Bahru, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Stated: “All GTFS Realtime feeds are licensed under CC BY 4.0.” Served keyless by Malaysia’s national open-data API, which caps GTFS Realtime at 4 requests a minute and refreshes positions every 30 seconds, so the proxy caches this feed for 30 seconds. Without the trailing slash the API redirects. Route ids append an operator code. | BAS.MY / data.gov.my |
|
||||
| **BAS.MY Kota Bharu** (api.data.gov.my) | Transit layer live vehicles, Kota Bharu, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Same API, licence and rate limit as Johor Bahru. | BAS.MY / data.gov.my |
|
||||
| **Rouen Astuce** (api.mrn.cityway.fr) | Transit layer live vehicles, Rouen, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie). The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted); and this is its own TCAR vehicle feed from that record; the suburban TAE and TNI networks are separate feeds and are not read. Route ids carry the network prefix (`TCAR:`); line 90 is the Métro (a street-running light rail, drawn as a tram) and 99 the Calypso boat. | Métropole Rouen Normandie (Astuce) |
|
||||
| **Toulon Mistral** (feed-rdtpm-toulon.ratpdev.com) | Transit layer live vehicles, Toulon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee). Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded. | Métropole Toulon Provence Méditerranée (Mistral) |
|
||||
| **Le Havre LiA** (opendata.transports-lia.fr) | Transit layer live vehicles, Le Havre, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri). The network publishes under ODbL (dataset record quoted); and this is its own GTFS-RT vehicle feed from that record; route ids carry the network prefix `8-`. The layer draws positions and redistributes no database. Lines A and B are the tramway. | Le Havre Seine Métropole (LiA) |
|
||||
| **Tours Fil Bleu** (data.filbleu.fr) | Transit layer live vehicles, Tours, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt). Dataset record’s licence field quoted. | Fil Bleu – Syndicat des Mobilités de Touraine |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
@@ -123,7 +159,8 @@ 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).
|
||||
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
|
||||
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The MTQ video is **opt-in** (`CCTV_QUEBEC_VIDEO_ENABLED=1`): the CC BY 4.0 licence covers the camera locations only, and Québec 511's terms for embedding its clips on another site are unconfirmed, so by default MTQ cameras link to the Québec 511 viewer page instead. With the flag set, the 680 MTQ cameras play the operator's five-second MP4 clip directly in the viewer's browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`, only after the viewer presses Play, so Québec 511 sees the viewer's IP address; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. The selected-camera credit names both sources: locations MTQ under CC BY 4.0, video Québec 511. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack. Confirm Québec 511's terms of use before setting `CCTV_QUEBEC_VIDEO_ENABLED=1` on a public deployment.
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||
@@ -164,13 +201,16 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
contributors, ODbL 1.0; GPSJAM for aggregation only.
|
||||
|
||||
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, whose page is an `https://www.planespotters.net` URL, and which names its photographer is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name is visible text next to it ("Photo © {photographer}"), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
|
||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
|
||||
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
|
||||
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
|
||||
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
|
||||
- **City of Ottawa traffic cameras.** Camera images come from the city's [Traffic Camera Images API](https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data), which requires an access certificate requested through the city's registration form and allows one certificate and user id to fetch a camera at most once every 60 seconds. Set `OTTAWA_CAMERA_CERTIFICATE`; without it the pack is skipped and makes no request. The camera list is the documented keyless `traffic.ottawa.ca/map/service/camera` JSON (camera number, English and French names, coordinates, owner). Frames are `traffic.ottawa.ca/opendata/camera?c=<number>`; the server adds the certificate and the user id `gev` at fetch time, so no catalog response carries the key, and serves each camera from one shared in-memory frame per minute. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are never stored on disk. The city states no redistribution licence for the images; the certificate request asks how they will be used, so describe the deployment there. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
|
||||
- **World cams image subset.** The normal CCTV catalog adds 1,176 Oregon TripCheck stills and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The export's Vegagerðin rows are left out because the Iceland pack already loads those cameras. The server validates and pins each image URL to its provider path, fetches a frame only when a camera is viewed, and never stores or redistributes it. [ODOT allows businesses, private citizens and organizations to use TripCheck images and asks for credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions) ("Camera courtesy of ODOT"); TripCheck also carries partner cameras run by Oregon counties and cities, Caltrans and WSDOT, which keep their owner's name in the camera label. MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) follow the Government of Japan Standard Terms of Use 2.0 (CC BY 4.0 compatible, commercial use allowed, source credit required) and ask tools not to collect data periodically, pointing regular collection to the river information data service; frames are therefore fetched only on view and refresh at most every ten minutes per camera. Both credits are registered in the Data attribution popover.
|
||||
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
|
||||
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
|
||||
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
|
||||
@@ -179,6 +219,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
||||
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
|
||||
- **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`.
|
||||
- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_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.
|
||||
@@ -189,7 +230,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
|
||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All fifty-two registered feeds, covering forty-nine regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
|
||||
|
||||
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
@@ -221,10 +262,15 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
|
||||
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
|
||||
| **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel |
|
||||
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
|
||||
| **Imaging sensor pack** (46 Earth-observation platforms: swath, resolution) | `imaging_sensors/` | Individual specification facts from operator and agency pages, cited per entry | ✅ (facts only) | "Imaging sensor swaths: operator and agency specifications" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **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) |
|
||||
| **Natural Earth populated places** (7,342 points: name, position, rank) | `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) |
|
||||
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
|
||||
@@ -280,11 +326,20 @@ Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons
|
||||
> We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS).
|
||||
|
||||
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
|
||||
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
|
||||
Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
`/api/firms` server-side proxy merges the two VIIRS NRT sources (NOAA-20, NOAA-21)
|
||||
and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16. Suomi NPP (`VIIRS_SNPP_NRT`)
|
||||
is not requested: NASA stopped delivering it on 2026-11-02.
|
||||
|
||||
### UCS Satellite Database (`ucs_satellites/`)
|
||||
|
||||
Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
|
||||
|
||||
### Imaging sensor pack (`imaging_sensors/`)
|
||||
|
||||
Swath widths and ground resolutions for 46 Earth-observation platforms (Landsat, Sentinel, MODIS, VIIRS, GOES, Himawari, Gaofen, CBERS, RADARSAT, TerraSAR-X, COSMO-SkyMed, ALOS, KOMPSAT, SPOT, Pléiades, WorldView, SkySat, PlanetScope, ICEYE, Capella, Umbra and others), compiled 2026-11-04 from the operator and agency pages cited per entry. The orbital view draws each satellite's sensor swath to scale and caps its zoom at that swath. Facts only; no text or images copied. A swath is confirmed on an operator, agency (ESA's eoPortal included) or academic page; resellers are not cited. The CEOS MIM Database (CC BY 4.0) is not yet used. Sources and unverified values: the folder README.
|
||||
|
||||
### Natural Earth physical regions (`natural_earth/`)
|
||||
|
||||
@@ -305,6 +360,16 @@ 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).
|
||||
|
||||
### Natural Earth populated places (`natural_earth/populated_places.json`)
|
||||
|
||||
Natural Earth 10m populated places (`ne_10m_populated_places_simple`, the same pinned
|
||||
`nvkelso/natural-earth-vector` commit and SHA-256 as the admin-1 build reads), reduced to one
|
||||
row per place: name, latitude and longitude rounded to 3 decimals, `scalerank` and `rank_max`
|
||||
(7,342 places, 229 KB). From 8x the orbital view names the most prominent place within half
|
||||
its width ("Sichuan, China · near Chengdu"), offline, with the states and provinces pack giving
|
||||
the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild:
|
||||
`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README.
|
||||
|
||||
### Sky background (`sky/`)
|
||||
|
||||
Sources, licences, the changes CC BY asks to state, hashes and build commands
|
||||
@@ -475,4 +540,4 @@ NOAA density imagery with the raw GLM product.
|
||||
|
||||
## In-app attribution
|
||||
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
@@ -340,9 +340,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
||||
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
|
||||
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
|
||||
|
||||
Open **Visual Presets → Globe Engine** to switch between Cesium and experimental Navara. The globe switches in place at the current view and remembers your choice. A link with `?engine=cesium` or `?engine=navara` overrides the saved choice. A fresh visit starts with Cesium.
|
||||
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
|
||||
---
|
||||
|
||||
@@ -438,15 +436,15 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
|
||||
| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 |
|
||||
| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 |
|
||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🛰️ **Satellites** | About 1,300 objects in six classes (Stations, Imaging, Telecom, Starlink, Probes, Debris) with a toggle and a live count each; Starlink loads its whole shell | CelesTrak | 🟢 |
|
||||
| 🌍 **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) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | up to 7,350 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,230 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · 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) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; 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 |
|
||||
| 📻 **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, Rio de Janeiro (buses and BRT), 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 | 🟢 |
|
||||
| 🚌 **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, Rio de Janeiro (buses and BRT), Szczecin, Lisbon (Carris Metropolitana and the city’s Carris), Prague, Warsaw, Kraków, Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, 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 | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
|
||||
@@ -641,7 +639,7 @@ and panel. DelDOT uses its official HTTPS HLS catalog links. Disable that
|
||||
pack with `CCTV_DELDOT_ENABLED=0`.
|
||||
|
||||
The server allows two concurrent sessions. Each retains at most 12 segments
|
||||
and 24 MiB in memory; individual downloads are capped at 4 MiB with a ten
|
||||
and 24 MiB in memory; individual downloads are capped at 8 MiB with a ten
|
||||
second deadline. There are no segment files or ffmpeg processes. Redirects,
|
||||
off-origin references, encrypted playlists and non-MPEG-TS segments are refused.
|
||||
Each decoder has its own client lease (at most eight per session), including
|
||||
|
||||
+3
-2
@@ -69,13 +69,14 @@ browser-key fallback for existing single-key setups.
|
||||
The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay:
|
||||
|
||||
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists to 256 KiB and segment bodies to 8 MiB while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
|
||||
- **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.
|
||||
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||
- **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.
|
||||
- **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. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. 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.
|
||||
|
||||
+2
-22
@@ -2,8 +2,8 @@
|
||||
|
||||
The project depends on third-party npm packages whose own licenses apply. This
|
||||
file records the packages added for the browser-local SDR feature and the
|
||||
Street Level viewer, and the CesiumJS code ported into `src/geo/`; the complete
|
||||
resolved dependency inventory remains in `package-lock.json`.
|
||||
Street Level viewer; the complete resolved dependency inventory remains in
|
||||
`package-lock.json`.
|
||||
|
||||
## Web RTL-SDR
|
||||
|
||||
@@ -45,23 +45,3 @@ resolved dependency inventory remains in `package-lock.json`.
|
||||
- Source: <https://github.com/mrdoob/three.js>
|
||||
- License: MIT
|
||||
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
|
||||
|
||||
## CesiumJS (portions ported to src/geo)
|
||||
|
||||
- Package: `cesium` 1.138.0 (`@cesium/engine` 22.3.0); the files in `src/geo/`
|
||||
are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`,
|
||||
`scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`,
|
||||
`Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.js`,
|
||||
`Transforms.js`, `BoundingSphere.js`, `EllipsoidGeodesic.js`, `JulianDate.js`,
|
||||
`Simon1994PlanetaryPositions.js`) and say so in their first
|
||||
line. The changes: the classes' static functions became module functions,
|
||||
vectors, geodetic positions and quaternions became plain-data instances of
|
||||
module-private constructors (own enumerable fields only, no methods),
|
||||
matrices plain arrays, and the ellipsoid a frozen object; the debug
|
||||
argument checks were left out, as in Cesium's release build; only the subset
|
||||
gev uses was kept.
|
||||
- Author: Copyright 2011-2024 CesiumJS Contributors
|
||||
- Source: <https://github.com/CesiumGS/cesium>
|
||||
- License: Apache License 2.0
|
||||
- License text: `node_modules/@cesium/engine/LICENSE.md` (shipped with the
|
||||
dependency) and <https://www.apache.org/licenses/LICENSE-2.0>
|
||||
@@ -1,58 +0,0 @@
|
||||
// Build guard: the Cesium-mode page must not load three or Navara. The
|
||||
// initial chunks are every entry chunk and what it imports statically;
|
||||
// navaraMode.js reaches Navara only through import(), so three and
|
||||
// @navaramap/* belong to lazy chunks. This checks the bundle's module ids
|
||||
// (what Rollup put in each chunk), and fails the build otherwise.
|
||||
|
||||
/**
|
||||
* Module ids that only the Navara engine may load, when no package list is
|
||||
* given (the browser build passes the full dependency closure,
|
||||
* navaraDependencies.js).
|
||||
*/
|
||||
export const ENGINE_ONLY_MODULES =
|
||||
/[\\/]node_modules[\\/](?:three|@navaramap[\\/][^\\/]+|postprocessing)[\\/]/;
|
||||
|
||||
/**
|
||||
* Engine-only modules in initial chunks of a Rollup bundle, sorted by chunk.
|
||||
* @param {Record<string, { type: string, isEntry?: boolean, imports?: string[], moduleIds?: string[] }>} bundle
|
||||
* @returns {{ chunk: string, module: string }[]}
|
||||
*/
|
||||
export function initialChunkViolations(bundle, pattern = ENGINE_ONLY_MODULES) {
|
||||
const initial = new Set();
|
||||
const visit = (fileName) => {
|
||||
const chunk = bundle[fileName];
|
||||
if (!chunk || chunk.type !== 'chunk' || initial.has(fileName)) return;
|
||||
initial.add(fileName);
|
||||
for (const imported of chunk.imports ?? []) visit(imported);
|
||||
};
|
||||
for (const [fileName, chunk] of Object.entries(bundle))
|
||||
if (chunk.type === 'chunk' && chunk.isEntry) visit(fileName);
|
||||
const violations = [];
|
||||
for (const fileName of [...initial].sort())
|
||||
for (const module of bundle[fileName].moduleIds ?? [])
|
||||
if (pattern.test(module)) violations.push({ chunk: fileName, module });
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** The Rollup plugin: fails generateBundle when an initial chunk holds three or Navara. */
|
||||
export function initialChunkGuard({
|
||||
log = console.log,
|
||||
pattern = ENGINE_ONLY_MODULES,
|
||||
} = {}) {
|
||||
return {
|
||||
name: 'gev-initial-chunk-guard',
|
||||
generateBundle(_options, bundle) {
|
||||
const violations = initialChunkViolations(bundle, pattern);
|
||||
if (violations.length) {
|
||||
this.error(
|
||||
'Navara-only modules in the Cesium-mode initial chunks:\n' +
|
||||
violations
|
||||
.map(({ chunk, module }) => ` ${chunk}: ${module}`)
|
||||
.join('\n'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
log('[initial-chunks] no Navara-only modules in the initial chunks');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Where @navaramap/three keeps its prebuilt worker assets, or null. */
|
||||
function defaultAssetsDir() {
|
||||
try {
|
||||
const entry = fileURLToPath(import.meta.resolve('@navaramap/three'));
|
||||
return path.join(path.dirname(entry), 'assets');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navara's prebuilt workers (@navaramap/three/dist/assets) load their wasm by
|
||||
* file name next to themselves (`new URL('x-hash.wasm', self.location)`), a
|
||||
* reference the bundler cannot see. When a build includes Navara (the
|
||||
* ?engine=navara chunk), copy every such wasm the bundle lacks into
|
||||
* `assets/` under its own name. A build without Navara gets none.
|
||||
* @param {{ assetsDir?: string | null }} [options]
|
||||
*/
|
||||
export function navaraWorkerAssets({ assetsDir } = {}) {
|
||||
return {
|
||||
name: 'navara-worker-assets',
|
||||
generateBundle(_options, bundle) {
|
||||
const usesNavara = Object.values(bundle).some(
|
||||
(file) => file.type === 'chunk' && file.code.includes('navara_wasm'),
|
||||
);
|
||||
if (!usesNavara) return;
|
||||
const dir = assetsDir === undefined ? defaultAssetsDir() : assetsDir;
|
||||
if (!dir || !existsSync(dir)) return;
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
if (!name.endsWith('.wasm')) continue;
|
||||
const fileName = `assets/${name}`;
|
||||
if (bundle[fileName]) continue;
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName,
|
||||
source: readFileSync(path.join(dir, name)),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const NAVARA_MODULE = /\/src\/globe\/navara\//;
|
||||
|
||||
/**
|
||||
* Rollup chunkFileNames: the lazy chunk holding the Navara engine
|
||||
* (src/globe/navara, with the Navara packages it pulls in) is named
|
||||
* `navara-engine-[hash].js`; every other chunk keeps Vite's default name.
|
||||
* Naming only (not manualChunks, which would pull modules shared with the
|
||||
* Cesium entry into the Navara chunk), so the chunking is unchanged.
|
||||
* @param {{ isEntry?: boolean, moduleIds?: string[] }} chunk
|
||||
* @returns {string}
|
||||
*/
|
||||
export function navaraChunkFileNames(chunk) {
|
||||
const navara =
|
||||
!chunk.isEntry &&
|
||||
(chunk.moduleIds ?? []).some((id) =>
|
||||
NAVARA_MODULE.test(id.replaceAll('\\', '/')),
|
||||
);
|
||||
return navara ? 'assets/navara-engine-[hash].js' : 'assets/[name]-[hash].js';
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// The packages only the Navara engine brings: the dependency closure of
|
||||
// @navaramap/three and @navaramap/three-default-plugin (dependencies and
|
||||
// peer dependencies, read from node_modules), without the packages the
|
||||
// app's other bundled dependencies reach too (Cesium is external), plus
|
||||
// three and postprocessing, which
|
||||
// the Cesium-mode page never loads statically either. The initial-chunk
|
||||
// guard (initialChunkGuard.js) fails the build when one of them is in an
|
||||
// initial chunk.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const NAVARA_ROOTS = Object.freeze([
|
||||
'@navaramap/three',
|
||||
'@navaramap/three-default-plugin',
|
||||
]);
|
||||
const ALWAYS_ENGINE_ONLY = Object.freeze(['three', 'postprocessing']);
|
||||
// Not bundled: vite-plugin-cesium serves Cesium as a script, so what only
|
||||
// Cesium reaches (draco3d, …) is not in the app's bundled graph.
|
||||
const EXTERNAL = Object.freeze(['cesium']);
|
||||
|
||||
function readManifest(root, name) {
|
||||
const file = path.join(root, 'node_modules', name, 'package.json');
|
||||
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null;
|
||||
}
|
||||
|
||||
/** Every package reachable from `names` through installed manifests. */
|
||||
export function dependencyClosure(root, names) {
|
||||
const seen = new Set();
|
||||
const queue = [...names];
|
||||
while (queue.length) {
|
||||
const name = queue.pop();
|
||||
if (seen.has(name)) continue;
|
||||
const manifest = readManifest(root, name);
|
||||
if (!manifest) continue;
|
||||
seen.add(name);
|
||||
for (const next of Object.keys({
|
||||
...manifest.dependencies,
|
||||
...manifest.peerDependencies,
|
||||
}))
|
||||
queue.push(next);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** The engine-only package names, sorted. */
|
||||
export function navaraOnlyPackages(root) {
|
||||
const app = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
const navara = dependencyClosure(root, NAVARA_ROOTS);
|
||||
const appRoots = Object.keys(app.dependencies ?? {}).filter(
|
||||
(name) =>
|
||||
!navara.has(name) &&
|
||||
!ALWAYS_ENGINE_ONLY.includes(name) &&
|
||||
!EXTERNAL.includes(name),
|
||||
);
|
||||
const shared = dependencyClosure(root, appRoots);
|
||||
return [
|
||||
...new Set([
|
||||
...[...navara].filter((name) => !shared.has(name)),
|
||||
...ALWAYS_ENGINE_ONLY,
|
||||
]),
|
||||
].sort();
|
||||
}
|
||||
|
||||
/** A module-id pattern matching node_modules/<package>/ for the names. */
|
||||
export function packagePattern(names) {
|
||||
const escaped = names.map((name) =>
|
||||
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('/', '[\\\\/]'),
|
||||
);
|
||||
return new RegExp(
|
||||
`[\\\\/]node_modules[\\\\/](?:${escaped.join('|')})[\\\\/]`,
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,6 @@
|
||||
* PANEL_BASE next to the app.
|
||||
*/
|
||||
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
|
||||
// two in step.
|
||||
export const PANEL_BASE = '/panel/';
|
||||
@@ -21,27 +18,6 @@ export function panelBuildConfig(config) {
|
||||
return {
|
||||
...config,
|
||||
base: PANEL_BASE,
|
||||
plugins: (config.plugins ?? []).map((plugin) =>
|
||||
plugin?.name === 'vite-plugin-cesium' ? cesium() : plugin,
|
||||
),
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
alias: [
|
||||
// The panel runs Cesium: the Navara mode and Navara's wind mesh
|
||||
// (the panel's only paths to Navara and three) become stubs.
|
||||
{
|
||||
find: /^(?:\.\/|.*\/app\/)navaraMode\.js$|^.*\/globe\/navara\/windFlowMesh\.js$/,
|
||||
replacement: fileURLToPath(
|
||||
new URL('./panelNavara.js', import.meta.url),
|
||||
),
|
||||
},
|
||||
...(Array.isArray(config.resolve?.alias)
|
||||
? config.resolve.alias
|
||||
: Object.entries(config.resolve?.alias ?? {}).map(
|
||||
([find, replacement]) => ({ find, replacement }),
|
||||
)),
|
||||
],
|
||||
},
|
||||
build: {
|
||||
...config.build,
|
||||
outDir: PANEL_OUT_DIR,
|
||||
@@ -50,10 +26,6 @@ export function panelBuildConfig(config) {
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
...config.build?.rollupOptions,
|
||||
// The panel is a single chunk; its alias excludes the Navara engine.
|
||||
plugins: (config.build?.rollupOptions?.plugins ?? []).filter(
|
||||
(plugin) => plugin?.name !== 'gev-initial-chunk-guard',
|
||||
),
|
||||
output: { inlineDynamicImports: true },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// A Vite build through the panel alias resolves renderers.js's relative
|
||||
// import of Navara's wind mesh to the stub (scripts/build-panel.mjs uses the
|
||||
// same alias).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { build } from 'vite';
|
||||
import { panelBuildConfig } from './panel.js';
|
||||
|
||||
test('a Vite build resolves the relative wind-mesh import through the panel alias', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'panel-alias-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'src/app/layers'), { recursive: true });
|
||||
mkdirSync(join(root, 'src/globe/navara'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, 'src/app/layers/renderers.js'),
|
||||
"export const load = () => import('../../globe/navara/windFlowMesh.js');\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, 'src/globe/navara/windFlowMesh.js'),
|
||||
'export const real = true;\n',
|
||||
);
|
||||
const { resolve } = panelBuildConfig({ plugins: [] });
|
||||
const [output] = await build({
|
||||
configFile: false,
|
||||
root,
|
||||
logLevel: 'silent',
|
||||
resolve,
|
||||
build: {
|
||||
outDir: join(root, 'out'),
|
||||
write: false,
|
||||
lib: {
|
||||
entry: join(root, 'src/app/layers/renderers.js'),
|
||||
formats: ['es'],
|
||||
fileName: 'x',
|
||||
},
|
||||
rollupOptions: { output: { inlineDynamicImports: true } },
|
||||
},
|
||||
});
|
||||
const [chunk] = output.output;
|
||||
assert.match(chunk.code, /Navara is unavailable in the panel/);
|
||||
assert.doesNotMatch(chunk.code, /real = true/);
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
// The panel host transports Cesium assets; Navara workers need a web page.
|
||||
export function createNavaraScene() {
|
||||
throw new Error('Navara is unavailable in the panel');
|
||||
}
|
||||
|
||||
export function navaraToolBackend() {
|
||||
throw new Error('Navara is unavailable in the panel');
|
||||
}
|
||||
|
||||
export function createWindFlowMesh() {
|
||||
throw new Error('Navara is unavailable in the panel');
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBrowserViteConfig } from './vite.js';
|
||||
import { panelBuildConfig } from './panel.js';
|
||||
|
||||
test('panel HTML lists Cesium assets for the host resource loader', () => {
|
||||
const config = panelBuildConfig(createBrowserViteConfig());
|
||||
const plugin = config.plugins.find(
|
||||
({ name }) => name === 'vite-plugin-cesium',
|
||||
);
|
||||
plugin.config(config, { command: 'build' });
|
||||
assert.equal(typeof plugin.transformIndexHtml, 'function');
|
||||
const tags = plugin.transformIndexHtml();
|
||||
assert.ok(tags.some(({ attrs }) => attrs.src === '/panel/cesium/Cesium.js'));
|
||||
assert.ok(
|
||||
tags.some(
|
||||
({ attrs }) => attrs.href === '/panel/cesium/Widgets/widgets.css',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the panel replaces Navara entry points and retains browser aliases', () => {
|
||||
const browser = createBrowserViteConfig();
|
||||
const config = panelBuildConfig(browser);
|
||||
const alias = config.resolve.alias.find(({ find }) =>
|
||||
find.test('./navaraMode.js'),
|
||||
);
|
||||
assert.ok(alias.replacement.endsWith('/build/panelNavara.js'));
|
||||
// Navara's wind mesh imports three; the Cesium wind backend stays.
|
||||
assert.equal(
|
||||
'../../globe/navara/windFlowMesh.js'.replace(alias.find, alias.replacement),
|
||||
alias.replacement,
|
||||
);
|
||||
assert.equal(alias.find.test('../../globe/cesium/windFlow.js'), false);
|
||||
assert.ok(config.resolve.alias.includes(browser.resolve.alias[0]));
|
||||
});
|
||||
|
||||
test('Three imports and requires share its ESM entry', () => {
|
||||
const { resolve } = createBrowserViteConfig();
|
||||
assert.deepEqual(resolve.dedupe, ['three']);
|
||||
const alias = resolve.alias.find(({ find }) => find.test('three'));
|
||||
assert.ok(alias.replacement.endsWith('/three/build/three.module.js'));
|
||||
assert.equal(alias.find.test('three/addons/loaders/GLTFLoader.js'), false);
|
||||
});
|
||||
+2
-50
@@ -5,9 +5,6 @@ import { VUE_PLUGIN_OPTIONS } from './vueOptions.js';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
import { navaraChunkFileNames, navaraWorkerAssets } from './navaraAssets.js';
|
||||
import { initialChunkGuard } from './initialChunkGuard.js';
|
||||
import { navaraOnlyPackages, packagePattern } from './navaraDependencies.js';
|
||||
|
||||
/**
|
||||
* The repository root (this file lives at `<root>/build/vite.js`), as a
|
||||
@@ -186,9 +183,7 @@ export function createBrowserViteConfig({
|
||||
apiResponseHeadersPlugin(),
|
||||
// Next, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
// Keep the SDK asset serving/copying and external imports, but let the
|
||||
// scene load the script and stylesheet only when Cesium is requested.
|
||||
{ ...cesium(), transformIndexHtml: undefined },
|
||||
cesium(),
|
||||
// Application views are Vue single-file components.
|
||||
vue(VUE_PLUGIN_OPTIONS),
|
||||
...plugins,
|
||||
@@ -199,20 +194,6 @@ export function createBrowserViteConfig({
|
||||
// A production build must not clean the dependency cache a running dev
|
||||
// server is still serving optimized module URLs from.
|
||||
...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}),
|
||||
resolve: {
|
||||
dedupe: ['three'],
|
||||
alias: [
|
||||
{
|
||||
find: /^three$/,
|
||||
replacement: fileURLToPath(
|
||||
new URL(
|
||||
'../node_modules/three/build/three.module.js',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
// First reached through the SDR worker or a dynamic import. Pre-bundle
|
||||
// them at startup so first use cannot invalidate already-transformed
|
||||
@@ -225,18 +206,6 @@ export function createBrowserViteConfig({
|
||||
// Loaded on demand when the first street-level image opens.
|
||||
'mapillary-js',
|
||||
],
|
||||
// Navara's wasm-bindgen glue loads new URL('x_bg.wasm', import.meta.url)
|
||||
// and its workers by URL; both must resolve next to the package's own
|
||||
// files, not inside .vite/deps. Dev server only: the experimental
|
||||
// Navara engine is a dynamic import outside the Cesium-mode bundle.
|
||||
exclude: [
|
||||
'@navaramap/three',
|
||||
'@navaramap/three-default-plugin',
|
||||
'@navaramap/engine',
|
||||
'@navaramap/engine-api',
|
||||
'@navaramap/engine-worker',
|
||||
'@navaramap/engine-font-worker',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
host: host || 'localhost',
|
||||
@@ -289,23 +258,6 @@ export function createBrowserViteConfig({
|
||||
mapillaryToken ?? '',
|
||||
),
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
// Build only: the wasm Navara's workers load by name (the Navara
|
||||
// engine is a separate chunk loaded on ?engine=navara).
|
||||
// The lazy chunk with the Navara engine is named navara-engine.
|
||||
rollupOptions: {
|
||||
// initialChunkGuard: no three/Navara module in the initial chunks.
|
||||
plugins: [
|
||||
navaraWorkerAssets(),
|
||||
initialChunkGuard({
|
||||
pattern: packagePattern(
|
||||
navaraOnlyPackages(fileURLToPath(new URL('..', import.meta.url))),
|
||||
),
|
||||
}),
|
||||
],
|
||||
output: { chunkFileNames: navaraChunkFileNames },
|
||||
},
|
||||
},
|
||||
build: { chunkSizeWarningLimit: 1500 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120
|
||||
Québec camera locations, City of Ottawa traffic cameras (needs a free city
|
||||
access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,230 curated
|
||||
image-backed cameras from Oregon and Japan (InoukT, #1506).
|
||||
- Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and
|
||||
loads only after the viewer presses Play; enforce provider-specific frame
|
||||
refresh limits, and report recognized MLIT maintenance images as degraded
|
||||
instead of healthy camera snapshots.
|
||||
@@ -0,0 +1,5 @@
|
||||
- One world clock now drives everything with a time of its own: the sky,
|
||||
the HUD clock, satellites, launches and weather. A timeline along the
|
||||
bottom of the screen, shown while one of those layers is on, plays, pauses
|
||||
and scrubs it, with a tick for each launch or frame and a way back to
|
||||
live. Share links made away from live carry the time (samehkhamis, #1512).
|
||||
@@ -0,0 +1,7 @@
|
||||
- The timeline brings launches up as the time passes them, offers play
|
||||
speeds from real time to a week a second, and plays smoothly, holding only
|
||||
when the next weather frame has yet to load, with a spinner by the time
|
||||
while it waits. The time rides in a bubble over the handle. The track zooms
|
||||
with a scroll or a pinch, drags along, and goes to the time under a tap, on
|
||||
a phone as on a desktop. Zooming out on the globe stops while the globe is
|
||||
still in view instead of leaving it a speck (samehkhamis, #1513).
|
||||
@@ -0,0 +1,3 @@
|
||||
- The agent and voice query tools wrap whole-degree directions into 0–359, so
|
||||
a vessel course or heading, aircraft course, transit bearing, camera heading
|
||||
or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524).
|
||||
@@ -0,0 +1,3 @@
|
||||
- The Nominatim disk-cache test checks the cache directory and file modes
|
||||
only where POSIX modes exist, so it no longer always fails on Windows
|
||||
(quanticsoul4772, #1547).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Stop requesting Suomi NPP in Active Fires. NASA stopped delivering its data
|
||||
on November 2, 2026, so `VIIRS_SNPP_NRT` returned no rows while still costing
|
||||
a MAP_KEY transaction on every refresh; the layer now merges VIIRS NOAA-20,
|
||||
VIIRS NOAA-21 and MODIS (IMBrandao, #1552).
|
||||
@@ -1,10 +0,0 @@
|
||||
- Add Navara, an experimental three.js/WASM globe, as a second engine next to
|
||||
Cesium, switchable live under VISUAL PRESETS → Globe Engine or with
|
||||
`?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD
|
||||
(`?engine=navara-hd`) draws at the screen's full resolution and needs about
|
||||
1 GiB more on a Retina screen. Layers keep their data across a switch and
|
||||
draw on both engines; Cesium loads only when a Cesium globe or a
|
||||
Cesium-only feature needs it (German, @gekh, #1555).
|
||||
- Keep the cockpit camera steady near the ground: it no longer jumps up and
|
||||
down when an aircraft's reported altitude sits below the globe's terrain,
|
||||
on either engine (German, @gekh, #1555).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Ride the selected AIS vessel from its bridge: an eye height by vessel type,
|
||||
dead reckoning between fixes, a bridge HUD with a 3–24 NM radar, look-around
|
||||
with the arrow keys, and a waterline read from the scene so river boats sit on
|
||||
the river rather than at sea level (Denis Groshev, @ZloyS, #1557).
|
||||
@@ -0,0 +1,4 @@
|
||||
- File satellites under six classes, Stations, Imaging, Telecom, Starlink,
|
||||
Probes and Debris, each with a toggle; all but Starlink show by default.
|
||||
Classes come from CelesTrak SATCAT, the UCS Satellite Database and name rules
|
||||
(Denis Groshev, @ZloyS, #1558).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Ride a tracked satellite in an orbital view: a track-up nadir camera, 1–8×
|
||||
zoom, off-nadir tilt with an approximate swath, a CURRENTLY OVER line, and the
|
||||
owner's flag, operator and TLE identity. `C` no longer enters or leaves first
|
||||
person; double-click and the entry buttons do (Denis Groshev, @ZloyS, #1559).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Zoom imaging satellites past 8×, up to 128×, as far as their sensor's
|
||||
confirmed swath allows at the live altitude, with a to-scale swath strip, a
|
||||
SENSOR step, mouse-wheel zoom, imagery pre-loaded ahead of the ground track and
|
||||
the TLE's age (Denis Groshev, @ZloyS, #1560).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Show a fire perimeter under one acre at its reported size. The incident card
|
||||
rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac"
|
||||
(Rudy Celekli, @rudycelekli, #1569).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Say why Transit and Bike Share are empty outside coverage. Over a city with
|
||||
no feed or system (London, Tokyo) their Layers rows say so and count the
|
||||
covered regions or systems, or say how low to fly, instead of `never`;
|
||||
leaving coverage clears the last-update time (German, @gekh, #1576).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Hold the orbital camera on a mapped site as the satellite passes over it,
|
||||
from a click on its marker or its Live Signals row, and list nearby sites
|
||||
and satellites in orbit's Live Signals, where a row focuses a site or rides
|
||||
a satellite (Denis Groshev, @ZloyS, #1580).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Street Traffic offers separate Lines and Dots toggles while live flow is on:
|
||||
Lines draws each road with flow in green, amber or red, and Dots shows or
|
||||
hides the moving dots. A host can choose which road modes it offers; without
|
||||
OpenStreetMap the layer never draws simulated roads (#1609).
|
||||
@@ -0,0 +1,2 @@
|
||||
- French catalog review: missing narrow no-break spaces, apostrophes, one
|
||||
mistranslated message and the Starlink shell wording (abiyeenzo, #1611).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Typed chat on OpenAI answers again: OpenAI refused every message because
|
||||
`resolve_area`'s area-id pattern uses Unicode classes Chat Completions does
|
||||
not accept. Chat leaves those patterns out; area ids are still checked
|
||||
before the tool runs (samehkhamis, #1616).
|
||||
@@ -0,0 +1,12 @@
|
||||
- Add Icelandic road cameras from the Icelandic Road and Coastal Administration
|
||||
(Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to
|
||||
`gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166
|
||||
stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`,
|
||||
and only links inside that directory are registered. The catalog is fetched
|
||||
over https with redirects refused and a 2 MiB body cap. An explicit facing in
|
||||
the view description ("til vesturs", "í suður", "séð austur") sets a
|
||||
high-confidence heading; other views keep the low-confidence fallback pose.
|
||||
IRCA publishes the images under CC BY 4.0, credited in the Data attribution
|
||||
popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir,
|
||||
Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and
|
||||
`CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Add an optional Giroc / Webcam Romania CCTV source pack with corrected
|
||||
coordinates, live HLS, a snapshot fallback and provider attribution. Allow
|
||||
HLS segments up to 8 MiB so high-resolution cameras whose segments exceed
|
||||
4 MiB can play, while retaining the 24 MiB per-session cache and existing
|
||||
origin and session limits (NeoXrated23, #1619).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Turning CCTV on opens the camera nearest you instead of the first camera in
|
||||
the catalog, which showed a Sydney camera in London. A share link opens on
|
||||
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
|
||||
@@ -0,0 +1,14 @@
|
||||
- Add 34 keyless feeds to the Transit layer, the first in Central and Eastern
|
||||
Europe, Italy, Spain, France, Japan and Malaysia: Szczecin, Lisbon (Carris
|
||||
Metropolitana and the city's Carris), Prague, Warsaw, Kraków, Poznań,
|
||||
Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice
|
||||
(buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre,
|
||||
Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver,
|
||||
Edmonton, Halifax and Victoria. Each feed records its licence, or that none
|
||||
is stated, in the registry and DATA_SOURCES.md. Speeds published in km/h
|
||||
(Lisbon, Montpellier, Toulon) are converted to m/s by the proxy; route ids
|
||||
read as the line on the vehicle; Prague metro, trains, trams and ferries,
|
||||
Bordeaux trams and boats, the Rouen Métro, Le Havre trams and Venice
|
||||
waterbuses take their own mode. The Warsaw and Poznań credits carry the
|
||||
data date their terms ask for, and the data.gov.my feeds are fetched at
|
||||
most twice a minute to stay inside the API's rate limit (IMBrandao).
|
||||
@@ -0,0 +1,3 @@
|
||||
- An application built with fewer layers no longer restores, saves or shares a
|
||||
layer it does not have when a link or saved state from a full build names it
|
||||
(samehkhamis, #1634).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Draw rows take an application's own actions: `createApplicationTools({
|
||||
drawMarkActions })` adds buttons to drawn areas' and lines' rows, each given
|
||||
the shape's `[lon, lat]` geometry, with what it answers shown under the row.
|
||||
The standalone build passes none and is unchanged (samehkhamis, #1636).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Keep the orbital cockpit's sensor strip steady through a zoom step, load the
|
||||
satellite owner flags at startup from one generated 15 KB sheet, and name
|
||||
"Place names" in the loading status while the place-name packs load on first
|
||||
lookup (Sameh Khamis, @samehkhamis, #1637).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Voice on OpenAI connects again: OpenAI Realtime refused every session, so
|
||||
the mic failed with "Failed to create Realtime token", because
|
||||
`resolve_area`'s area-id pattern uses Unicode classes it no longer accepts.
|
||||
Voice leaves those patterns out, as typed chat does; area ids are still
|
||||
checked before the tool runs (avaldini, #1638).
|
||||
@@ -0,0 +1,7 @@
|
||||
- Keep buses and trains off rooftops and out of bridge decks on the photoreal
|
||||
3D tiles: the ground floor probes the rendered mesh where each vehicle is,
|
||||
keeps each reading at the point it was taken (up to sixteen a cell) and
|
||||
answers from the reading nearest the vehicle, instead of one probe at each
|
||||
110 m cell's centre that lifted every vehicle in the cell onto whatever lay
|
||||
there. Transit probes each vehicle's latest point, and grounded flights read
|
||||
the floor at their own position (Sameh Khamis, @samehkhamis, #1642).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Show a photo of the tracked aircraft. Tracking a civil flight asks
|
||||
Planespotters for that aircraft's photo, and the CONTEXT panel's CONTACTS view
|
||||
shows it with the photographer's credit and a link to the photo's page. A
|
||||
flight lookup (type, route or photo) that failed temporarily is now asked
|
||||
again after a minute instead of staying empty for the session, and a callsign
|
||||
change drops the previous callsign's route (Sameh Khamis, @samehkhamis, #1647).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Move the tile budget helpers `utcDayKey`, `normalizeBudget` and
|
||||
`isOverBudget` out of the TomTom module into `src/data/tileBudget.js`, so
|
||||
other keyed tile proxies can share them. `src/data/tomtomTiles.js`
|
||||
re-exports them; behaviour does not change (monwiss, #762).
|
||||
@@ -0,0 +1 @@
|
||||
- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (BreezeDelegate, #766; fixes #39).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET` on
|
||||
`/api/flights`, `/api/military` and `/api/route`, as the other query proxies
|
||||
do. They answered every verb with the same body. The refusal comes before
|
||||
rate limiting and any upstream call (daikaginza, #794).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Compress the live flight snapshot on the wire. `/api/flights` gzips its
|
||||
cached global snapshot and its regional adsb.lol fallback once per refresh
|
||||
and sends that frame to clients that offer gzip: about 860 KB becomes about
|
||||
250 KB. Other clients get the same bytes uncompressed, and both answers carry
|
||||
`Vary: Accept-Encoding` (daikaginza, #795).
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"id": "ro-giroc-webcamromania",
|
||||
"name": "Giroc — Webcam Romania",
|
||||
"city": "Giroc, Timiș",
|
||||
"cityId": "giroc",
|
||||
"provider": "Webcam Romania · webcamromania.ro",
|
||||
"sourceKind": "configured",
|
||||
"feedType": "hls",
|
||||
"url": "https://w4.webcamromania.ro/giroc/index.m3u8",
|
||||
"snapshotUrl": "https://webcamromania.ro/media/snapshots/01a0267e-d893-7203-a7eb-7ce3c8c99703/latest.jpg",
|
||||
"lat": 45.70028947266781,
|
||||
"lon": 21.233675914004227,
|
||||
"headingDeg": 0,
|
||||
"headingConfidence": "low",
|
||||
"pitchDeg": -17,
|
||||
"fovDeg": 74,
|
||||
"rangeM": 700,
|
||||
"mountHeightM": 24,
|
||||
"groundElevationM": 87,
|
||||
"license": "Public camera: https://webcamromania.ro/webcam-orase/webcam-giroc/ . Provider terms: https://webcamromania.ro/termeni-si-conditii/ . Video rights remain with the camera operator. Coordinates corrected by the contributor; direction, height, elevation and field of view are uncalibrated estimates."
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,155 @@
|
||||
# CCTV integration workflow
|
||||
|
||||
Use this workflow when adding a camera provider or repairing an existing feed.
|
||||
The CCTV catalog comes through the local server; media normally does too. Keep
|
||||
provider-specific URL policy on the server, including any validated browser-only
|
||||
media URL permitted by the exception below.
|
||||
|
||||
## 1. Check the source before coding
|
||||
|
||||
- Identify the catalog endpoint and the actual frame or live-media endpoint.
|
||||
They may be different hosts or use different formats.
|
||||
- Read the provider's license, terms, attribution, rate limits, and access
|
||||
requirements. Publicly reachable data is not automatically licensed for
|
||||
reuse. Record whether the app may display it and whether any use is
|
||||
restricted.
|
||||
- Prefer a public, keyless feed. If credentials are required, keep them in a
|
||||
server-only environment variable; never put a secret in browser code or a
|
||||
Vite-exposed variable. A pack that needs a key is opt-in by that key: with
|
||||
the variable unset it makes no request and logs once (Ontario 511 and
|
||||
Ottawa). Keep the key out of catalog URLs and add it only at fetch time. Use
|
||||
the provider's documented API, not a website endpoint that skips its key. If
|
||||
access terms require an agreement or remain unconfirmed, make the pack
|
||||
opt-in and ensure it makes no request by default.
|
||||
- Confirm the feed can be fetched from the app's Node server environment. A URL
|
||||
opening in a browser does not prove the server can reach it, and browser CORS
|
||||
behavior does not diagnose a server-side fetch.
|
||||
- Treat camera directories and aggregators such as [TW Live](https://tw.live/)
|
||||
as discovery aids. Trace each listing to the original agency or camera owner;
|
||||
their data and media terms govern reuse. For example, SkylineWebcams says
|
||||
only camera hosts may embed its live feed and its terms restrict copying or
|
||||
retransmitting footage, so do not proxy its stream without written
|
||||
authorization ([FAQ](https://www.skylinewebcams.com/support/faq.html),
|
||||
[terms](https://www.skylinewebcams.com/en/terms-of-use.html)).
|
||||
- Decide whether the provider supplies still snapshots or continuous video.
|
||||
Prefer stills unless live video is necessary and supported by the existing
|
||||
proxy path.
|
||||
|
||||
## 2. Add and register the provider pack
|
||||
|
||||
Use the smallest existing provider as a template. Current owners are:
|
||||
|
||||
- `server/providers/cctv/sources.js` for standard open-data loaders.
|
||||
- `server/providers/cctv/<provider>.js` for a provider with its own parser,
|
||||
fallback, or more involved URL policy.
|
||||
- `server/providers/cctv/catalog.js` to import the loader and add it to
|
||||
`LIVE_PACKS`. Each pack is independently gated and the shared catalog cap
|
||||
allocates space across packs.
|
||||
- `server/providers/cctv/constants.js` for provider endpoints, caps, and
|
||||
timeouts.
|
||||
|
||||
Return normalized camera records with stable unique IDs, readable names,
|
||||
`city`/`cityId`, provider attribution, valid `lat`/`lon`, and the correct
|
||||
`feedType`. Include camera heading only when the source actually provides a
|
||||
bearing; descriptive direction text is not a numeric heading. Mark uncertain
|
||||
heading data with the existing low-confidence conventions. Set `sourceKind` so
|
||||
health and source diagnostics identify the provider. Apply a bounded per-pack
|
||||
camera limit and geographic filtering where appropriate; avoid one provider
|
||||
silently consuming the whole shared catalog cap.
|
||||
|
||||
Camera coordinates need their own verification. Prefer the original provider's
|
||||
camera coordinates, then corroborate them against its location description,
|
||||
route/mile marker, or official map. An aggregator's map dot, a town/county name,
|
||||
or a geocoded attraction name is not a precise camera position. Do not pin a
|
||||
camera to a city center or nearby landmark; if its actual location cannot be
|
||||
verified, leave it out until it can. For manually curated coordinates, record
|
||||
the evidence in the provider data or an adjacent comment. For example, TW Live
|
||||
camera pages identify the originating agency; use that agency's catalog for
|
||||
the camera point when available.
|
||||
|
||||
If the source becomes opt-in, use the catalog's opt-in gate (`envOptIn`) and
|
||||
document the flag and reason in `DATA_SOURCES.md`. Otherwise use the normal
|
||||
enabled-by-default flag pattern and document how to disable the pack.
|
||||
|
||||
## 3. Keep frame and media requests server-registered
|
||||
|
||||
The browser should request the local `/api/cctv/frame/:id` or
|
||||
`/api/cctv/media/:id` endpoint. Do not pass an arbitrary upstream URL from the
|
||||
browser, and do not fetch an unvalidated URL supplied by a catalog record.
|
||||
Every provider must have a server-side rule that pins frame and media URLs to
|
||||
the expected scheme, host, and path; use HTTPS where the provider supports it.
|
||||
Use `pinnedCameraUrl()` for URLs that belong under a fixed provider directory,
|
||||
or a stricter provider-specific validator when hosts or paths vary. Register
|
||||
any new permitted image/media origin in the existing
|
||||
`server/providers/cctv` media policy.
|
||||
|
||||
Catalog and media loaders must also:
|
||||
|
||||
- refuse unsafe schemes, credentials, unexpected ports, redirects outside the
|
||||
registered origin/path, and malformed identifiers;
|
||||
- set request deadlines and cap catalog and buffered frame bodies;
|
||||
- parse only the expected format and validate coordinates before returning a
|
||||
camera;
|
||||
- handle offline cameras and partial provider failures without failing the
|
||||
entire merged catalog; and
|
||||
- avoid persistent frame storage unless the provider's terms explicitly allow
|
||||
it and the design has been reviewed.
|
||||
|
||||
For live media, prefer the existing proxy and supported feed type (`mjpeg`,
|
||||
`mp4`, `webm`, or `hls`). A direct browser-only video is a narrow fallback for
|
||||
a source whose terms allow embedding when the browser can play it but the app
|
||||
server cannot fetch it. Unconfirmed embedding terms make it opt-in like any
|
||||
other pack, and the browser loads it only after an explicit play action, never
|
||||
on selection, because the provider sees the viewer's IP address. Build its URL
|
||||
from a validated provider ID on the server; never accept a browser-supplied
|
||||
media URL. Render it in a native DOM
|
||||
`<video>` without `crossOrigin`, and do not read its pixels into canvas, WebGL,
|
||||
or a Cesium texture. If a map preview is needed, a second video request may be
|
||||
paused as a DOM overlay at the camera location; label it as an approximate
|
||||
still and scale it with map distance. If terms disallow embedding, use a source
|
||||
link instead. Read [SECURITY.md](../SECURITY.md) before changing URL
|
||||
validation, redirects, or proxy behavior.
|
||||
|
||||
When an image provider sets a minimum refresh interval, enforce it at the
|
||||
server frame boundary as well as in the client. A bounded in-memory cache and
|
||||
single-flight request per camera prevent projection, panel, and re-selection
|
||||
paths from multiplying upstream requests; do not persist frames unless the
|
||||
provider's terms explicitly allow it.
|
||||
|
||||
## 4. Add focused regression coverage
|
||||
|
||||
Add provider tests under `src/data/` following the existing CCTV test naming
|
||||
and fixtures. Cover at least:
|
||||
|
||||
- representative valid catalog rows and their normalized output;
|
||||
- missing or invalid IDs, coordinates, and frame URLs;
|
||||
- duplicate records and the pack's cap/filter behavior;
|
||||
- unsafe host/path, credentials, and redirect cases;
|
||||
- timeout, oversized, malformed, and unavailable upstream responses; and
|
||||
- the frame or live-media content type the provider actually serves.
|
||||
|
||||
Use `src/data/cctvProxy.test.mjs` for shared proxy/media behavior and
|
||||
`src/data/cctvCatalogCap.test.mjs` for shared catalog allocation. For a
|
||||
browser-only source, verify native playback, denied canvas pixel access, the
|
||||
paused second-player overlay, and its cleanup separately from proxy tests. Run
|
||||
the focused provider and proxy tests, then the standard checks from
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md) that apply to the change. For runtime
|
||||
verification, inspect `/api/cctv/sources`, `/api/cctv/health`, and the camera's
|
||||
`/api/cctv/frame/:id` or `/api/cctv/stream/:id` response locally. Confirm a
|
||||
successful frame is from the upstream camera by checking its content type and
|
||||
`X-CCTV-Source`; a fallback image is not proof that the upstream feed works.
|
||||
|
||||
## 5. Update source documentation and submit
|
||||
|
||||
- Add the provider, endpoint, terms/license, required attribution, key status,
|
||||
caching behavior, and any restrictions to `DATA_SOURCES.md`.
|
||||
- Update the CCTV source section in `docs/CURRENT-STATE.md` when runtime
|
||||
behavior, flags, caps, or fallback behavior changes.
|
||||
- Register in-app attribution through the existing source/provider credit
|
||||
fields; update `src/data/dataCredits.js` when the source needs a layer-level
|
||||
credit.
|
||||
- Add the required `changelog.d/<pr-number>-<slug>.md` entry for a runtime
|
||||
change, and include the source, license, attribution, key/runtime-fetch
|
||||
details, and verification evidence in the PR description.
|
||||
- For a new layer, also follow the camera/source requirements in the
|
||||
[new-layer PR checklist](../CONTRIBUTING.md#adding-a-new-layer).
|
||||
+17
-93
@@ -30,10 +30,8 @@ export inventory. Use declared exports rather than reaching into internal files.
|
||||
| `src/standalone/` | Select the default catalog, local sources and setup controls |
|
||||
| `src/ui/` | Navigation generations, restoration, visual state, panel snapshots and their subscriptions |
|
||||
| `src/data/` | Lifecycle, context and feed state; legacy default-layer facades |
|
||||
| `src/layers/<family>/` | Source acquisition, records and shared contract displays with explicit lifecycle owners |
|
||||
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
|
||||
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
|
||||
| `src/geo/` | Engine-neutral WGS84 maths, ported from Cesium, never imports Cesium |
|
||||
| `src/globe/` | Engine-neutral globe contract and its test support; engines only in `globe/cesium`, `globe/navara` |
|
||||
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
|
||||
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
|
||||
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
|
||||
@@ -55,53 +53,18 @@ See [application construction](APPLICATION.md) and the
|
||||
2. `scripts/check-package-boundaries.mjs` builds every declared export without
|
||||
app Vite configuration or environment files. `scripts/package-boundaries.json`
|
||||
assigns each export exactly once and lists its owned modules and external
|
||||
dependencies; a module entry ending in `/` (such as `src/geo/`) owns every
|
||||
file under that directory. Unused imports still count. Node exports have only a `node`
|
||||
dependencies. Unused imports still count. Node exports have only a `node`
|
||||
condition; browser groups cannot use build-only dependency exceptions.
|
||||
|
||||
Portable source graphs cannot reach application/rendering, Node, Cesium or
|
||||
browser globals. This includes `sources/*`, dedicated `layers/*/source` exports,
|
||||
flight/military/vessel record and ingestion exports, action schemas, the session
|
||||
interface, lifecycle and feed state, and every runtime file in `src/geo/` (the
|
||||
engine-neutral geometry ported from Cesium). The browser-global rule reserves
|
||||
platform names such as `document` and `window` in these modules; it is an
|
||||
architectural check, not a JavaScript sandbox. The globe contract
|
||||
(`src/globe/*.js` and `src/globe/testing/**`) cannot reach `cesium`,
|
||||
`@cesium/*`, `three`, `postprocessing` or `@navaramap/*`, directly or through
|
||||
helpers, nor import an engine implementation; the `src/globe/cesium/**` graph
|
||||
reaches no Navara/three package or `src/globe/navara/**` module, and the
|
||||
`src/globe/navara/**` graph no Cesium package or `src/globe/cesium/**` module,
|
||||
through helpers too. Outside its own directory, `src/globe/navara/**` is
|
||||
reached only through a literal `import()` (the app's `?engine=navara` mode), so
|
||||
Navara stays out of the Cesium-mode bundle. Common voice controls cannot depend on a
|
||||
interface, lifecycle and feed state. The browser-global rule reserves platform
|
||||
names such as `document` and `window` in these modules; it is an architectural
|
||||
check, not a JavaScript sandbox. Common voice controls cannot depend on a
|
||||
Realtime protocol implementation. Negative fixtures cover indirect helpers,
|
||||
self-package imports, symlinks and unreachable files.
|
||||
|
||||
Runtime SDK imports are restricted by directory, including unreachable modules:
|
||||
`cesium` and `@cesium/*` belong in `src/globe/cesium/`; `three`,
|
||||
`postprocessing` and `@navaramap/*` belong in `src/globe/navara/`. Package
|
||||
subpaths, re-exports and literal dynamic imports obey the same rule, and the
|
||||
`<script>` blocks of Vue components count as runtime modules. A module outside
|
||||
the Navara engine reaches it only through `import()`: a static import of the
|
||||
same specifier fails even beside a dynamic one. Tests and Node test support
|
||||
retain their existing exemptions. The production script loader is
|
||||
`src/app/loadCesium.js`; it imports no SDK package and needs no exception, and
|
||||
it adds no `widgets.css` when the page's markup has already run `Cesium.js`
|
||||
(the MCP panel). The panel build replaces `src/app/navaraMode.js` and
|
||||
`src/globe/navara/windFlowMesh.js` with stubs (`build/panelNavara.js`), so the
|
||||
panel carries no Navara or three. There is no blanket exception for layer
|
||||
`render/` directories.
|
||||
|
||||
The temporary `engineImportExceptions` list in `scripts/package-boundaries.json`
|
||||
contains exact file/package pairs with reasons. The checker reports the used
|
||||
entries and rejects duplicates, missing reasons and unused entries. Its three
|
||||
exceptions are the Bhote Koshi event, locator and embedded-media models in
|
||||
`src/data/`, which still construct Cesium property records. Their drawing uses
|
||||
shared displays. Exceptions do not relax the portable, contract or cross-engine
|
||||
graph checks. Cesium annotation renderers live in `src/globe/cesium/annotations/`;
|
||||
wind backend adapters live in their engine folders. Wind's older injected-SDK
|
||||
helpers and the remaining annotation migration are separate debt.
|
||||
|
||||
Source-only factories use dedicated `layers/<family>/source` exports where
|
||||
available, preserving the same factory implementation without loading rendering.
|
||||
Record normalization and endpoint policies have plain owners separate from
|
||||
@@ -173,6 +136,9 @@ socket/watchdog on server close and re-reads configuration after restart.
|
||||
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
|
||||
records without importing Node middleware or a renderer. Browser layer/controller
|
||||
separation is outside this server extraction.
|
||||
The portable `gods-eye-view/sources/planespotters` export reads a Planespotters
|
||||
photo answer (upstream or a server's cached shape) into one host-checked,
|
||||
credited thumbnail; it has no fetch, DOM or Node dependency.
|
||||
|
||||
## Place-search and routing providers
|
||||
|
||||
@@ -200,7 +166,8 @@ local composition mounts them in the same order. Importing the entry performs
|
||||
no acquisition; factory calls create independent cache state.
|
||||
|
||||
`gods-eye-view/sources/space` owns only fixed upstream URL construction: the
|
||||
CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed.
|
||||
CelesTrak group/TLE and SATCAT record queries and Launch Library's recent
|
||||
30-day detailed feed.
|
||||
Callers supply the group or end date and own validation, credentials, transport,
|
||||
response limits and cache policy. The boundary gate checks this portable entry
|
||||
separately from the Node providers. Satellite rendering and launch replay remain
|
||||
@@ -424,12 +391,10 @@ the caller.
|
||||
## Visual effects
|
||||
|
||||
`ui/effects` exports the effects controller and existing preset definitions.
|
||||
It owns transitions and their clock, with explicit render ownership callbacks,
|
||||
and writes styles, bloom and sharpening through `engine.render`. Construction
|
||||
installs no stages or frame callbacks. Stop animation before releasing UI
|
||||
consumers; controller destruction disables its effects. Cesium's
|
||||
`src/globe/cesium/postStages.js` owns stage creation and destruction and restores
|
||||
the borrowed bloom state when the engine is destroyed. `ui/effects/bloom` exposes the pure intensity/version helpers
|
||||
It owns shader stages and their clock, with explicit render ownership callbacks.
|
||||
Construction installs no stages or frame callbacks. Stop animation before
|
||||
releasing UI consumers, then destroy to remove owned stages and restore the
|
||||
borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers
|
||||
without loading the renderer. UI presentation and product-action coordination
|
||||
remain in their callers.
|
||||
|
||||
@@ -541,7 +506,7 @@ perform no network requests and discover no additional modules.
|
||||
`maps/controller` coordinates scene changes and lifetimes. `maps/imagery`,
|
||||
`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the
|
||||
standard sources and their setup/fallback policy. The standalone facade wires
|
||||
these to their engine's render requests.
|
||||
these to the application's render governor.
|
||||
|
||||
A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`.
|
||||
Each source has a user-facing `descriptor`, availability/reason, and either an
|
||||
@@ -585,22 +550,12 @@ services into each family factory. Standalone provider selection lives in
|
||||
`src/standalone/layerSources.js`. The construction export has its own checked
|
||||
dependency graph, excluding standalone setup and compatibility layer instances.
|
||||
|
||||
`src/app/layers/renderers.js` is the layer manifest. Its lazy display loaders
|
||||
resolve the shared factories exported by `displays.js` through `display`. All
|
||||
five drawing primitives are required by the globe contract. The only display
|
||||
backend options are wind streamlines, loaded from the engine folders. A missing wind backend leaves the raster field
|
||||
available. Wildlife Sightings draws itself through the contract; Sea Marks and
|
||||
Ski Pistes use their overlay controller. No layer display imports an engine SDK.
|
||||
`displayRehearsal.test.mjs` loads every manifest display on the fake engine with
|
||||
an unknown kind and no extensions, draws a small fixture and checks cleanup.
|
||||
|
||||
Both aircraft layers share the catalog's classification registry; launches use
|
||||
its satellites and Contacts uses its aircraft, vessels and installations. Data
|
||||
registration, controls and voice actions read those same instances. Destruction
|
||||
remains the manager's responsibility; classification also observes application
|
||||
abort when startup has not reached registration. The session retains models while
|
||||
scene attachment builds displays on the active engine. A parked engine can remain
|
||||
allocated for a warm switch; its layer drawing is detached.
|
||||
abort when startup has not reached registration. Scene engines remain page-owned,
|
||||
so this change does not introduce multiple simultaneous viewers.
|
||||
|
||||
Direct `src/data` compatibility entries retain their old defaults and testing
|
||||
exports. Browser regression probes use the registered instance's testing surface
|
||||
@@ -639,23 +594,6 @@ listener cleanup. Application data assembly constructs both owners explicitly;
|
||||
`data/manager` is the compatibility facade for direct callers. The ordinary
|
||||
state subscriptions retain their existing event contract.
|
||||
|
||||
`data/layerSceneBinding.js` owns renderer attach/detach, asynchronous settlement,
|
||||
parking and failed-release retries. Lifecycle keeps visibility intentions and
|
||||
refresh policy. `app/layerSession.js` defines the retained session fields and
|
||||
handoff predicate. `standalone/sceneSwitcher.js` coordinates page handoff;
|
||||
`ui/shellScene.js` owns scene-bound shell construction, while `applicationShell`
|
||||
keeps page controls and policy. `app/sceneHost.js` owns the transition queue,
|
||||
request ordering and recovery decision. Application acquisition, attachment,
|
||||
restoration and renderer release run in that queue, and shell calls use it too.
|
||||
Nested work receives an explicit transaction; outside calls wait for the whole
|
||||
transition. A replacement application shares the transition owner with fresh
|
||||
parts, and a shell used alone creates its own host. Pick ownership, selection
|
||||
dispatch and render demand belong to
|
||||
`scene.services`; render demand drives the engine contract, with no dependency
|
||||
from the Cesium render backend to the governor. Overlay and tracked-readout
|
||||
services still include module singletons; debug access still exposes the native
|
||||
viewer and tileset.
|
||||
|
||||
## UI state owners
|
||||
|
||||
- `ui/navigation` owns navigation generations, pending search presentation and
|
||||
@@ -829,17 +767,3 @@ 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 rail card keeps node identity, header focus,
|
||||
disclosure and teardown, and does not announce every card update.
|
||||
|
||||
## Engine implementation modules
|
||||
|
||||
Cesium's viewer construction, atmosphere compatibility, zoom-out guard, starry
|
||||
sky, map scene application, imagery and terrain live in `src/globe/cesium/`.
|
||||
The public viewer export still resolves through `package.json`. Celestial
|
||||
rendering, screen-event constants, scene projection and the legacy trail
|
||||
backend also live there. Sonar commands and post stages are engine internals;
|
||||
feature controllers call `engine.render`.
|
||||
|
||||
Imported-file parsing (`geoFileImport.js`, `geoImportRecords.js`,
|
||||
`geoImportKml.js`) returns plain records. `geoImportDisplay.js` consumes them
|
||||
through ground, movers, lines and imagery. The UI owns file bytes, visibility
|
||||
and attachment lifetime, with no DataSource or engine-specific import backend.
|
||||
+582
-403
File diff suppressed because it is too large.
Load diff
@@ -90,7 +90,7 @@ load, removes partial results and reports a stable error without exposing a URL.
|
||||
Successful loading leaves the attribution cards visible with the assets.
|
||||
|
||||
`createApplicationTools({ sceneDataPacks: { sources } })` and
|
||||
`new SceneDirector(scene, style, data, { dataPacks: { sources } })` accept a
|
||||
`new SceneDirector(viewer, style, data, { dataPacks: { sources } })` accept a
|
||||
registry of source functions. Each receives `{ path, signal, maxBytes }` and
|
||||
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
||||
signal, bound reads and return the real MIME type. They remain application code,
|
||||
|
||||
@@ -1,641 +0,0 @@
|
||||
# Globe primitives
|
||||
|
||||
A layer draws through the globe-engine contract (`src/globe/contract.js`),
|
||||
not through Cesium or Navara. The contract has five drawing primitives:
|
||||
|
||||
- `engine.movers`: things at points;
|
||||
- `engine.lines`: lines in the air;
|
||||
- `engine.ground`: fills on the ground;
|
||||
- `engine.imagery`: rasters on the ground;
|
||||
- `engine.meshes`: textured or coloured world triangles.
|
||||
|
||||
Each engine implements all five. Cesium's are in `src/globe/cesium/`,
|
||||
Navara's in `src/globe/navara/`, and the fake engine's in
|
||||
`src/globe/testing/fakeEngine.js`. The checks every engine must pass are in
|
||||
`src/globe/testing/contractSuite.mjs`.
|
||||
|
||||
## Movers: things at a point
|
||||
|
||||
`engine.movers.createSet({ id, kind, capacity, ... })` makes a set of up to
|
||||
`capacity` instances drawn as one batch. Write them with
|
||||
`set(index, fields)` and `setCount(n)`, then upload the changes with one
|
||||
`commit()`.
|
||||
|
||||
There are three kinds:
|
||||
|
||||
- `sprite`: an image from an atlas, a square of `sizePx`, turned by
|
||||
`rotation`;
|
||||
- `point`: a filled disc with an outline;
|
||||
- `model`: a glTF in metres, at least `minimumPixelSize` on screen.
|
||||
|
||||
Use movers for aircraft, vessels, satellites, cameras, stations: anything
|
||||
that has a position and is drawn the same size on screen (or as a model).
|
||||
|
||||
Point instances may also set `outlineColor` independently of the fill,
|
||||
so an expanding callout can fade its outline without changing its width.
|
||||
|
||||
Instances move every frame without a rebuild. `depthTest` decides whether
|
||||
the scene hides them. A pick returns the instance's `pickId` when the set is
|
||||
`pickable`. `ready(index?)` says whether a model's glTF or a sprite atlas has
|
||||
loaded and `failed(index?)` whether it failed to: a failed set is not loaded
|
||||
again, so a layer that retries makes a new set. Cesium loads a model per
|
||||
instance: an index written with another `pickId` loads its own model, so an
|
||||
instance moved into a failed index does not inherit the failure.
|
||||
|
||||
## Lines: polylines in the air
|
||||
|
||||
`engine.lines.createLine({ id, widthPx, color, ... })` makes one polyline.
|
||||
You can change its positions, colour and model matrix at any time.
|
||||
|
||||
The width is in CSS pixels. The line follows the 3D positions it is given;
|
||||
it does not follow the ground. Options:
|
||||
|
||||
- `arc: 'geodesic'` bends it along great circles;
|
||||
- `dash` draws dashes;
|
||||
- `depthFailAlpha` draws a dimmer copy where the scene hides it.
|
||||
|
||||
Use lines for trails, orbits, launch paths, a ring at a fixed height, and
|
||||
leader lines.
|
||||
|
||||
## Ground: fills on the ground
|
||||
|
||||
`engine.ground.createSet({ id, kind, order, pickable, drape })` makes a set
|
||||
of flat fills that drape over the terrain and the 3D tiles. Nothing is
|
||||
extruded.
|
||||
|
||||
Work with a set like this:
|
||||
|
||||
- `replace(items)` checks and stores all its items at once;
|
||||
- `commit()` draws them in place of the last ones;
|
||||
- `show(visible)` shows or hides the whole set;
|
||||
- `list()` names the sets.
|
||||
|
||||
Positions are `[longitude, latitude]` in degrees, as GeoJSON stores them.
|
||||
Edges follow great circles.
|
||||
|
||||
There are four kinds:
|
||||
|
||||
| kind | item | for |
|
||||
| ---------- | ------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `polygon` | `rings: [outer, ...holes]`, `fill`, `outline` | perimeters, airspace, footprints, cones, KML polygons |
|
||||
| `disc` | `center`, `radiusM` (metres along the ground) | earthquakes, air-quality stations, a pad zone |
|
||||
| `cell` | `west`, `south`, `east`, `north` (meridians, parallels) | GNSS and FIRMS cells, privacy cells |
|
||||
| `corridor` | `positions`, `color`, `widthPx` or `widthM` | routes, cables, roads, coverage lines, outlines in metres |
|
||||
|
||||
Item options:
|
||||
|
||||
- Every item has its own colours, rgba in 0..1; the alpha is the opacity.
|
||||
- `fill` and `outline` (`{ color, widthPx }`) are optional; an outline runs
|
||||
along every ring.
|
||||
- Within a set, a later item draws on top. Between sets, a higher `order`
|
||||
draws on top. All ground fills draw under movers, lines and models.
|
||||
- `pickable: true` makes picks return an item's `pickId`, with `owner` (the
|
||||
set) and `instance` (the item's index).
|
||||
- `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Both engines
|
||||
honour it: Navara tells 3D tiles from terrain by its globe depth, and its
|
||||
CPU picks skip a set draped only on the surface not shown (terrain while
|
||||
photoreal tiles show, tiles otherwise).
|
||||
|
||||
Discs with a fill may use `radial: { fillAlpha, rimAlpha, rimStart, rimEnd,
|
||||
emission }`. Alpha multipliers are in 0..1; the radius fractions satisfy
|
||||
`0 <= rimStart < rimEnd < 1`; emission is nonnegative. The rim grows with
|
||||
smoothstep between the two radii, then fades smoothly to zero at the outer
|
||||
edge. Space Missions uses a 500 m disc with alpha 0.105 inside and 0.72 at
|
||||
the rim, transitions at 0.952 and 0.985, and emission 0.35. Cesium retains
|
||||
its textured ground material and polygon depth bias. Navara composes the
|
||||
same radial envelope in its ground pass after tone mapping.
|
||||
|
||||
An unpickable pixel corridor set with `reveal: true` accepts
|
||||
`setReveal(fraction)` in 0..1. Each path is clipped at that fraction of its
|
||||
length. Geometry stays intact: Cesium updates material uniforms and Navara
|
||||
updates the corridor item texture. Hiding, showing and committing a new
|
||||
path retain the fraction. A corridor may have `backing: { widthPx, color }`:
|
||||
a wider edge with its own colour, with the core blended only once.
|
||||
|
||||
Transit drapes its history and active head through ground corridors. Its
|
||||
completed runs retain their age bands, 5 px dark backing and 3 px colour,
|
||||
with band updates once per displayed second. The active head is one backed
|
||||
corridor revealed along the whole current segment; only a segment crossing
|
||||
rebuilds its geometry. Transit uses dynamic sets so new history and head
|
||||
corridors build synchronously once Cesium's terrain bounds are available.
|
||||
The ground pass handles occlusion on both engines.
|
||||
|
||||
Cesium also builds reveal sets synchronously after loading those bounds.
|
||||
For nonempty sets, `ready()` includes the visible globe's tile readiness:
|
||||
classification needs the current surface depth as well as built geometry.
|
||||
|
||||
A `widthPx` corridor can also take the looks Cesium's ground polyline
|
||||
materials gave it. The GPU works them out every frame from the time, so a
|
||||
commit never rebuilds them:
|
||||
|
||||
| look | fields | Cesium's |
|
||||
| ------- | ------------------------------------------------ | ---------------------------------------------------------------- |
|
||||
| `glow` | `power`, `taper` (1: none) | `PolylineGlow`'s `glowPower` and `taperPower` |
|
||||
| `pulse` | `periodMs`, `min`, `max` | the alpha times a factor from `min` to `max` and back, a sine |
|
||||
| `dash` | `cells`, `duty`, `speed`, `gapAlpha`, `emission` | the annotation route's `GevRouteFlow`: dashes flowing to the end |
|
||||
|
||||
- `glow` brightens the line toward white along its centre and fades it to
|
||||
nothing at its edges.
|
||||
- `pulse` follows the page's monotonic clock (`performance.now()`), as the
|
||||
old materials did, not the app clock.
|
||||
- `dash` lays `cells` dashes along the whole line. Each is lit for its first
|
||||
`duty`, with soft edges of 0.08 of a cell, and brightened by `emission`
|
||||
times its colour. Between them the alpha is times `gapAlpha`. The dashes
|
||||
move toward the line's end at `speed` cells a second.
|
||||
- A shown set with a `pulse` or a `dash` makes the engine draw a frame after
|
||||
every frame, as Cesium's continuous render did. `list()` reports it as
|
||||
`animated`. A `glow` alone does not animate.
|
||||
|
||||
Traffic's jam heat lines glow (power 0.25) and pulse (alpha 0.35 to 0.75,
|
||||
1.63 s). The Directions route's 64 dashes flow at 0.55 cells a second.
|
||||
|
||||
A pixel corridor may instead use `dashPattern: { lengthPx, pattern: 255 }`,
|
||||
a static 16-bit screen-space dash mask with transparent gaps, as Cesium's
|
||||
PolylineDash material. It cannot combine with the other corridor looks.
|
||||
Bhote Koshi uses an 18 px dash behind its 5 px glowing flood core.
|
||||
|
||||
Use `dynamic: true` on a set whose path changes each frame. Cesium waits
|
||||
for its shared terrain-height bounds, then builds these small reveals
|
||||
synchronously so successive frames do not discard unfinished worker jobs.
|
||||
Other sets retain asynchronous building.
|
||||
|
||||
### Radial pulse fields
|
||||
|
||||
`engine.ground.createPulseField({ id, center, radiusM, heightM, color,
|
||||
lineColor, haloColor, depth, lineWidth, haloWidth })` draws up to four
|
||||
concentric pulse fronts. The centre is Earth-fixed metres; the radius is
|
||||
metres along the surface. The disc lies on the ellipsoid raised by
|
||||
`heightM` (default 0), with normal scene depth testing. Colours are rgba in
|
||||
0..1, widths are CSS pixels, and `depth` is the inward band depth as a
|
||||
fraction of the radius. A band's opacity falls quadratically to zero.
|
||||
|
||||
Call `draw({ radii, alphas, level })` with up to four radius fractions and
|
||||
opacities in 0..1. Missing fronts are zero. `level` controls visibility;
|
||||
the caller includes it in the alphas. The field starts hidden. `move(center)`
|
||||
follows a new centre; `destroy()` releases it and is safe to repeat.
|
||||
`ground.list()` includes it as `kind: 'pulse-field'`. The caller owns its
|
||||
clock and render requests; the field starts no animation loop.
|
||||
|
||||
Global Context keeps its 2,500 m pulse height, fixed-width front and halo,
|
||||
wake and rest windows, reduced-motion behaviour and 80 ms frame cadence.
|
||||
Cesium retains one material disc, updating its uniforms and rotating for
|
||||
small moves. Navara evaluates the same bands after tone mapping against a
|
||||
raised ellipsoid and scene depth; its shader lives in
|
||||
`src/globe/navara/ground/pulse.js`. The fake engine records the same state.
|
||||
|
||||
Bhote Koshi's event and locator share `src/layers/bhoteKoshi/render/story.js`:
|
||||
the authored reveal properties feed ground corridors and polygons, and
|
||||
point movers retain their independent fill and outline opacity. No story
|
||||
backend is selected by engine. The authored model still uses its existing
|
||||
property records; removing that model dependency is separate work.
|
||||
|
||||
### Ground drawing guarantees
|
||||
|
||||
Both engines preserve item order, fill and outline colours, pixel and metre
|
||||
widths, visibility, and pick ownership. Non-pickable sets let picks through.
|
||||
`ready()` reports when committed geometry is ready to draw. Animated corridor
|
||||
looks use the shared ground time and request frames while shown.
|
||||
|
||||
Cesium translates ground items to classified fills and polylines. Navara
|
||||
classifies visible scene depth and blends the ground in display space.
|
||||
Navara picks ground items at the surface under the pointer. Packing, tiling
|
||||
and bin sizes are implementation details in `src/globe/navara/ground/`.
|
||||
|
||||
## Imagery: rasters on the ground
|
||||
|
||||
`engine.imagery.createLayer({ id, source, alpha, show, split, order, raise })`
|
||||
makes one raster layer draped over the terrain and the 3D tiles, under
|
||||
every ground fill, mover, line and model. Its colours are the image's own,
|
||||
blended by the texel's alpha times the layer's `alpha`.
|
||||
|
||||
There are two kinds of source:
|
||||
|
||||
| kind | source | for |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `tiles` | `url` with `{z}`, `{x}`, `{y}` (and `{s}` of `subdomains`), `minLevel`, `maxLevel`, `tileSize` | Recent Imagery's GIBS days, weather tiles, open overlays |
|
||||
| `image` | `image`: a canvas, an ImageBitmap or image element, or `{ width, height, data }` RGBA bytes; or `url` | the FIRMS heat raster, a field image, event imagery |
|
||||
|
||||
Both take:
|
||||
|
||||
- `rectangle` (`{ west, south, east, north }` in degrees; west > east
|
||||
crosses the antimeridian): where the layer draws, the globe when omitted;
|
||||
- `credit`: HTML shown while the layer is.
|
||||
|
||||
Tiles are Web Mercator XYZ tiles, which stop at ±85.05°, or with
|
||||
`scheme: 'geographic'` EPSG:4326 tiles (2×1 at level 0, y down from 90°N,
|
||||
Cesium's `GeographicTilingScheme`). `opaque` tiles have no alpha channel,
|
||||
and `maxRequests` caps the layer's tile requests in flight.
|
||||
`transform(image)` turns each decoded tile (row 0 on the north edge) into
|
||||
the canvas or image that draws: the weather's infrared mask. An image is
|
||||
stretched over its rectangle in longitude and latitude, row 0 on the north
|
||||
edge; one over the whole globe wraps across 180°.
|
||||
|
||||
`raise: heightM` puts an image layer on a shell that high above the
|
||||
ellipsoid instead of draping it: it draws over terrain, 3D tiles and the
|
||||
sky's edge, hidden only by what is nearer. The shell rises with the camera
|
||||
(4 m per kilometre of camera height, at most 60 km, in 500 m steps) so the
|
||||
coarse 3D tiles seen from far never hide it. Raised layers take image
|
||||
sources only.
|
||||
|
||||
Work with a layer like this:
|
||||
|
||||
- `setSource(source)` draws another source in its place, at its place in
|
||||
the order; the same canvas or bytes again draw their new pixels, and a
|
||||
source that differs only in its `credit` keeps its pixels;
|
||||
- `setSource(source, { staged: true, signal })` keeps the drawn source
|
||||
until the new one is complete (the image loaded; the tiles in view in),
|
||||
then swaps them. It resolves true then, and false when the new source
|
||||
failed, `signal` aborted it, or another `setSource` replaced it first. A
|
||||
hidden layer takes it at once. `staging()` says one is on its way;
|
||||
- `setDetail({ image, rectangle } | null)` (raised layers): inside the
|
||||
rectangle the layer draws the detail image in place of its own (a
|
||||
sharper view of the area in view). The last detail draws until the new
|
||||
one is uploaded; it resolves true once it draws. null clears it;
|
||||
- `setAlpha(alpha)` fades it: at 0 it draws nothing but stays on its
|
||||
surface, so it comes back without a gap (the FIRMS hand-off);
|
||||
- `show(visible)` shows or hides it (Cesium unmaps a hidden layer's tiles);
|
||||
- `setSplit('left' | 'right' | 'none')` puts it on one side of the split;
|
||||
- `ready()` says the source is built (an image URL loaded); tiles arrive
|
||||
after it;
|
||||
- `destroy()`.
|
||||
|
||||
On the engine:
|
||||
|
||||
- `list()` names the layers in draw order, bottom first: a higher `order`
|
||||
draws on top, then the later layer;
|
||||
- `setSplitPosition(fraction)` sets where the split runs, a fraction of the
|
||||
canvas width from the left (one per engine, Cesium's
|
||||
`scene.splitPosition`); `splitPosition()` reads it;
|
||||
- `surface()` says where imagery drapes now: `'globe'`, `'tiles'` (3D tiles)
|
||||
or `'none'`.
|
||||
|
||||
### Imagery drawing guarantees
|
||||
|
||||
Both engines draw imagery below ground shapes, movers, lines and models,
|
||||
in layer order. Each texel blends by its alpha times the layer alpha.
|
||||
Visibility, split position, rectangle bounds and raised-shell height follow
|
||||
the imagery contract. Replacing or destroying a source releases its owned
|
||||
resources; stale loads cannot replace the current source.
|
||||
|
||||
Cesium translates sources to imagery providers and raised shells. Navara
|
||||
samples imagery in its surface pass, including raised ellipsoid intersections.
|
||||
The adapters own texture storage, tile selection and depth reconstruction.
|
||||
|
||||
Cesium moves draped layers between the visible globe and tileset surfaces;
|
||||
without either surface, layers wait. Its split applies to globe imagery,
|
||||
not raised shells. Navara draws the top eight visible imagery slots, with
|
||||
detail images consuming a slot. Staged sources keep the current image visible
|
||||
until their replacement is ready; a failed source leaves it intact.
|
||||
|
||||
Open map overlays use `engine.imagery` when `maps.addOverlay` is absent.
|
||||
Navara's optional native raster path retains terrain detail, but does not
|
||||
provide rectangle bounds, split, alpha, order, or drawing on 3D tiles.
|
||||
|
||||
### Who draws through imagery
|
||||
|
||||
- **FIRMS heat** (`src/layers/firms/render/display.js`): one image.
|
||||
- **Recent Imagery** (`src/layers/recentImagery/render/display.js`): two
|
||||
Web Mercator tile layers under a split.
|
||||
- **Weather radar, satellite clouds and lightning**
|
||||
(`src/layers/weather/render/display.js`): on the globe, the product's
|
||||
geographic tiles (the regional infrared through its mask, a tile
|
||||
transform), or the global infrared mosaic as one image; each new frame a
|
||||
staged source; clouds, radar and lightning at orders 21, 22, 23. On 3D
|
||||
tiles, a raised layer at the product's shell height
|
||||
(`shellRendering.js` `WEATHER_SHELL_HEIGHTS`) with the detail window
|
||||
around the view as its detail.
|
||||
- **The wind field** (`src/layers/wind/render/display.js`): one image over
|
||||
the globe at order 20, faded with the camera's height while streamlines
|
||||
draw, raised at 5 km on 3D tiles. The streamlines stay each engine's,
|
||||
behind the display's `flow` backend, loaded by the manifest:
|
||||
`src/globe/cesium/windFlow.js` supplies Cesium's streamlines
|
||||
(`src/layers/wind/rendering.js`), and `src/globe/navara/windFlow.js`
|
||||
supplies Navara's flow mesh.
|
||||
- **The Bhote Koshi event imagery** (`src/layers/bhoteKoshi/render/display.js`):
|
||||
two `url` images over the event's rectangle at alpha 0.96, split left
|
||||
and right at the engine's split position, order 15. The event's and the
|
||||
locator's story graphics use the shared ground and mover display described
|
||||
above.
|
||||
|
||||
- **Imported KML/KMZ ground images** (`src/data/geoImportDisplay.js`): one
|
||||
image layer per rectangle at order 30, with the authored alpha capped at
|
||||
0.99 as on Cesium; a tinted or rotated image is drawn on a canvas first,
|
||||
and an absolute one is raised to its altitude.
|
||||
- **Sea Marks and Ski Pistes** (`src/data/osmOverlays.js`): tile layers at
|
||||
order 10 on engines without `maps.addOverlay`.
|
||||
|
||||
## Imported files
|
||||
|
||||
`geoFileImport.js` reads GeoJSON, GPX, KML and KMZ into the plain records in
|
||||
`src/data/geoImportRecords.js`. `geoImportKml.js` resolves KML styles with
|
||||
Cesium's precedence; its tests compare the records with `KmlDataSource`.
|
||||
No import loads the Cesium SDK. One `geoImportDisplay.js` draws the records
|
||||
on every engine: points on movers, ground paths and polygon fills on ground,
|
||||
raised paths and outlines on lines, raised polygon fills on meshes, and packed
|
||||
ground images on imagery.
|
||||
Every owned id starts with `geo-import-`; the file controller owns visibility,
|
||||
framing, refresh and destruction across engine switches.
|
||||
|
||||
Pins use composed square sprites to retain Cesium's pin tip, hot spot,
|
||||
subregion, colour, scale and rotation; a KML heading turns with the camera's
|
||||
heading, so it stays measured from north. Icon textures share a per-file
|
||||
budget of 16 million pixels: many distinct styles get lower-resolution
|
||||
textures at the same size. Ground heights come from the loaded surface,
|
||||
refreshed after the camera or map settles, in batches of 256; on 3D tiles
|
||||
only pins in the window are clamped to the tiles drawn. Ground pins ignore
|
||||
scene depth and are culled beyond the horizon; absolute pins retain their
|
||||
height.
|
||||
|
||||
Raised polygons (KML absolute or relative) draw their fills as meshes and
|
||||
their outlines as lines at the vertices' heights. A ground overlay's colour
|
||||
tints its image and its alpha fades it; a rotated overlay's image is turned
|
||||
on a canvas over the box around it, as Cesium turns its rectangle in degrees,
|
||||
and an absolute overlay is raised to its altitude (Cesium raises only
|
||||
absolute ones). The shared display has known limits against Cesium: wall
|
||||
fills and extruded prisms are omitted (their top geometry remains), and
|
||||
`gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised
|
||||
plain-colour overlay are omitted. These are counted as undrawn. Ground edges
|
||||
use great circles rather than rhumb lines.
|
||||
|
||||
## Input ownership
|
||||
|
||||
`engine.input.on(type, listener, { pick, drill, pickWindow })` accepts
|
||||
`pickWindow: { width, height }` with positive integer dimensions passed to
|
||||
the picker. Omitted dimensions keep the engine default. Listeners share a
|
||||
pick only when their windows match, and each receives its requested drill
|
||||
limit. Pointer-down picks are synchronous. Moves, including drills, coalesce
|
||||
to the latest position per frame. Navara clicks still use one GPU answer
|
||||
from the gesture; a missing hit falls back to the requested CPU window.
|
||||
Street Level requests 9 × 9 px; CCTV's gizmo requests its own window and
|
||||
limit for presses and hover. Pointer ownership and removal still suppress
|
||||
delivery while a pick is pending.
|
||||
|
||||
`engine.projector.horizonVisible(position, { ellipsoid })` accepts a plain
|
||||
ellipsoid with positive finite `radii: { x, y, z }` in metres. Without the
|
||||
option it uses WGS84. Street Level supplies WGS84 radii reduced by 1,000 m
|
||||
so nearby cones remain visible below sea level. The shared math is tested
|
||||
against the native ellipsoidal occluder, including points near the limb.
|
||||
|
||||
`engine.input.suspendCameraInputs()` disables camera gestures and returns an
|
||||
idempotent resume function. Suspensions are counted: only the last resume
|
||||
restores the state captured before the first suspension. This preserves an
|
||||
already-disabled camera. `cameraInputsEnabled()` reads the current state.
|
||||
Cockpit, imagery-box drawing, CCTV calibration and infrastructure flights use
|
||||
these methods on every engine. Pointer claims remain separate from camera
|
||||
input suspension.
|
||||
|
||||
Pick ownership belongs to the scene attachment, not to an engine implementation.
|
||||
`scene.services.picking` owns its predicates and `scene.services.selection` owns
|
||||
its shared click dispatcher. Layer entry points resolve these services using
|
||||
their attached engine (`sceneServicesFor(engine)`); the pick-registry helper
|
||||
functions take that engine as their final argument. Standalone entries acquire
|
||||
the same services on first use. The application destroys them on detach and
|
||||
creates fresh services when reattaching a parked engine. Identical layer IDs in
|
||||
simultaneous scenes cannot replace or unregister one another's picks.
|
||||
|
||||
## Render demand
|
||||
|
||||
`scene.services.render` owns continuous-render holds and the last 16 idle
|
||||
request reasons for one attachment. `holdContinuousRender(id)` and
|
||||
`releaseContinuousRender(id)` are idempotent per owner; the last release
|
||||
requests one settling frame. `governorRequestRender(reason)` forwards a
|
||||
one-shot request even while another owner holds continuous rendering.
|
||||
The tools call `install()` after scene consumers have registered their holds.
|
||||
|
||||
The governor drives `engine.render` on every engine. Cesium implements
|
||||
`setContinuous` with its scene's `requestRenderMode` and disables automatic
|
||||
simulation-time renders with `maximumRenderTimeChange = Infinity`. Engine
|
||||
internals request their own scene directly and never import the governor.
|
||||
Layer service helpers take their attached engine as the final argument;
|
||||
attachment-bound callbacks can capture `renderGovernorFor(engine)` directly.
|
||||
Destroying the scene clears its holds and makes the captured service inert.
|
||||
A parked engine gets a fresh service when reattached.
|
||||
|
||||
## World overlays
|
||||
|
||||
`scene.services.overlay` owns one attachment's world labels and cards. It
|
||||
keeps its sources, collision domains, paint lanes, canvases, frame listeners,
|
||||
observers, timers and accessible action subscribers. `init()` installs its
|
||||
DOM inside `engine.host.container`; the detection blend surface stays beside
|
||||
the card root so it can blend against the scene.
|
||||
|
||||
Publish with `setEntries(sourceId, entries, options)`, `setVisible(sourceId,
|
||||
visible)` and `clearSource(sourceId)`. Read painted rectangles with
|
||||
`getPaintRect(sourceId, entryId)` and register custom painters with
|
||||
`registerPaintLane(laneId, painter, options)`. `subscribeActions(listener)`
|
||||
provides the accessible mirror; `activateAction(owner, key)` rejects an old
|
||||
host's actions. The shell reconnects these operations on every attachment.
|
||||
|
||||
`worldOverlayFor(engine)` resolves the same service for standalone consumers.
|
||||
The module helper functions take the engine first. Retained layer models use
|
||||
`bindOverlayHost` with their attached-engine getter; injected hosts without a
|
||||
`forEngine` resolver keep their own operations. Publications can buffer before
|
||||
`init()`. Destruction is terminal, including before initialization; a parked
|
||||
engine needs fresh scene services. An old paint-lane handle cannot mutate its
|
||||
replacement. Font measurements are shared until the last live host releases
|
||||
them.
|
||||
|
||||
`scene.services.trackedReadout` bridges the follow handle's label and selected
|
||||
ALPR or Local ADS-B card into this overlay. `init()` subscribes to that engine's
|
||||
follow changes and that attachment's context events; `refresh(entity)` republishes
|
||||
changed label text, and `activeId()` resolves the card for annotation placement.
|
||||
Positions stay live cached visual anchors. `trackedReadoutFor(engine)` resolves
|
||||
the service; destruction removes its listeners and clears only its own source.
|
||||
|
||||
`scene.services.context` owns selection records and notifications. Layer publishers
|
||||
use `bindOverlayHost` with their current engine, or a context service captured by
|
||||
their display. It mirrors records to the page context slot and preserves the
|
||||
`gev:entity-selected`, `gev:entity-selection-cleared` and aircraft awareness event
|
||||
payloads for page consumers. A clear reaches the page only when that attachment
|
||||
owns the page's selected record. Readouts listen only to their scene's event target.
|
||||
Identical IDs in different scenes cannot share a card or clear one another's
|
||||
selection. Retired context services cannot publish into a replacement attachment.
|
||||
An engine handoff carries the page-selected record through the layer session.
|
||||
The incoming context adopts it only while the page still selects that record;
|
||||
a clear between attachments wins. Rebuilt markers replace the retained carrier
|
||||
in the incoming readout without announcing another page selection.
|
||||
Readout initialization reads the retained record without testing the outgoing
|
||||
carrier's visibility; layers rebuild their carriers after camera restoration.
|
||||
The page chrome and detection controller still have page lifetimes.
|
||||
|
||||
## Render effects
|
||||
|
||||
The optional `visualStyles`, `bloom` and `contactSonar` capabilities are
|
||||
available on Cesium and Navara through `engine.render`. Cesium implements
|
||||
styles, sharpening, borrowed bloom and the Anime Milky Way fade in
|
||||
`src/globe/cesium/postStages.js`; contact sonar is in `sonar.js` and
|
||||
`sonarCommands.js`. Cesium does not advertise `cloudCover`.
|
||||
|
||||
`VisualEffects` drives transitions through the render contract. UI teardown
|
||||
stops animation and disables effects; engine destruction removes its stages
|
||||
and restores the borrowed bloom state. Sharpen intensity zero disables
|
||||
sharpening on both engines, following Cesium.
|
||||
|
||||
## Wildlife privacy cells
|
||||
|
||||
Wildlife privacy cells use one `cell` ground set for distinct bounds and taxon
|
||||
accents. The fill is 25% opaque and the 2 px outline is 80% opaque, matching
|
||||
Cesium's original ground geometry. Both drape on terrain and tiles and pass
|
||||
picks through to the badges. Wide views and detach release the set.
|
||||
|
||||
## Batched line segments
|
||||
|
||||
`engine.lines.createSegments({ id, widthPx, color, order })` groups independent
|
||||
straight segments in one batch. `replace(items)` copies pairs of Earth-fixed
|
||||
endpoints and optional per-item `widthPx`, `color` and `pickId`; `commit()`
|
||||
rebuilds the drawing once. Segments never connect to one another. `show()`
|
||||
and `destroy()` affect the whole batch; an empty replacement clears it.
|
||||
The batch shares `createLine`'s id namespace and `list()`, which includes its
|
||||
endpoint count and committed `pickIds` in item order.
|
||||
|
||||
Cesium draws one synchronous primitive with per-instance colours and widths.
|
||||
Navara draws one ribbon mesh in the overlay after tone mapping, with scene
|
||||
depth and independent segment picks. Batches are solid and depth-tested;
|
||||
curved, dashed, transformed and depth-fail lines still use `createLine`.
|
||||
Infrastructure and cable references each use one batch for their visible
|
||||
stems, preserving Cesium's colours, widths, label winners and update cadence.
|
||||
Infrastructure excludes the whole stem batch while sampling its bases.
|
||||
|
||||
### Choosing a primitive
|
||||
|
||||
- **A position and an on-screen size:** a mover.
|
||||
- **A line at given heights, or one that moves or is restyled every frame**
|
||||
(trails, orbits, a ring at the terrain height): a line.
|
||||
- **Anything that lies on the ground** and follows its relief:
|
||||
- an area: `polygon`, or `disc` or `cell` when that is its shape (they
|
||||
are exact and cheaper);
|
||||
- a line on the ground: `corridor`, with `widthM` when its width is a
|
||||
real width (an outline in metres) and `widthPx` otherwise.
|
||||
- Group items that change together into one set, and replace them in bulk.
|
||||
A ground set rebuilds on commit. Small paths revealed each frame use
|
||||
`dynamic: true`; corridor looks animate without a rebuild, and radial
|
||||
fronts use `ground.createPulseField`.
|
||||
- **Pixels on the ground** (tiles, a raster, a photo): an imagery layer.
|
||||
Fade it with `setAlpha`, and give a new raster to `setSource`.
|
||||
|
||||
## Meshes: world triangles
|
||||
|
||||
`engine.meshes.createMesh({ id, positions, indices, uv, image, color, show,
|
||||
depthTest, pickId })` draws indexed triangles on both sides. Positions are
|
||||
flat Earth-fixed xyz metres and indices name their vertices. Optional UVs run
|
||||
from (0, 0) at the image's bottom left to (1, 1) at its top right. The engine
|
||||
copies geometry arrays; the caller owns the canvas, image or video.
|
||||
|
||||
`update(patch)` replaces only supplied fields. Repeating `image` uploads new
|
||||
canvas pixels; videos update on rendered frames. RGBA `color` multiplies the
|
||||
image, white by default. `show()` reads visibility and `show(boolean)` changes
|
||||
it. `depthTest` is `normal` or `always`; null `pickId` disables picking. Picks
|
||||
return the authored id and the mesh id as owner. `destroy()` is idempotent;
|
||||
`engine.meshes.list()` returns live ids in creation order.
|
||||
|
||||
Cesium draws a synchronous Primitive with a flat material, no back-face
|
||||
culling and no depth writes. Colour, image and visibility changes retain the
|
||||
primitive; geometry, pick-id and depth-test changes replace it. The engine
|
||||
requests frames while the primitive or its image is preparing. Navara draws
|
||||
local coordinates relative to a double-precision world origin in its
|
||||
transparent scene pass after tone mapping, with scene depth and triangle
|
||||
picking. Their authored colours are blended in display space.
|
||||
Both engines own geometry, material and texture disposal. The fake engine
|
||||
keeps the same state and tests triangle intersections for picks.
|
||||
|
||||
CCTV's `render/meshes.js` describes its live monitor quad and viewshed pyramid
|
||||
through this primitive. Its markers and outlines remain movers and lines.
|
||||
Obstruction probes use `engine.surface.raycast`; exclusions accept owned mesh,
|
||||
line and mover handles on Cesium and restore their visibility after the query.
|
||||
|
||||
Data packs use one `src/scenes/dataPacks/display.js`: points on movers,
|
||||
straight raised paths on lines, raised polygons and image rectangles on
|
||||
meshes, and media markers with captions in a composed sprite. Polygon holes
|
||||
and authored vertex heights are retained. Shared triangulation uses `earcut`,
|
||||
with tests comparing its area to Cesium's polygon geometry. Images retain
|
||||
Cesium's one-degree rectangle grid. Scene detach releases drawn resources and
|
||||
pick targets; reattach redraws the retained decoded assets.
|
||||
|
||||
Annotation drapes still use their existing renderers. Their bounded building
|
||||
classification volumes, animated fills and wire cages are not ordinary world
|
||||
triangles; they need a classification contract before that migration can
|
||||
preserve Cesium's behaviour.
|
||||
|
||||
## Shared follow offsets and manual drawing
|
||||
|
||||
`engine.camera.follow({ position, viewFrom, swingTo })` can swing from the
|
||||
initial follow frame to `swingTo`, an east/north/up offset in metres. Each
|
||||
frame turns at most 60 degrees and interpolates range, preserving Cesium's
|
||||
tracked-camera crossing guard. `handle.retarget({ swingTo })` changes only
|
||||
the pending swing; `null` cancels it. Stopping or replacing the follow, a
|
||||
camera command or engine destruction ends it. Share links read the current
|
||||
handle's target and label identity and the camera pose through the contract.
|
||||
|
||||
`engine.input.acquirePointer()` returns an exclusive claim, or `null` if
|
||||
one is held. Pass it to `input.on(type, listener, { pointer: claim })` and
|
||||
call `claim.release()` when done. Other screen subscriptions yield, including
|
||||
picks that settle after ownership changes. Camera gestures stay enabled.
|
||||
Cesium suspends its stock select and track clicks and restores the original
|
||||
functions when the claim is released or the engine is destroyed.
|
||||
|
||||
The Draw tool uses one `drawSurface.js` on every engine: a dashed geodesic
|
||||
line and a point mover set at the clicked heights. The dim, occluded band
|
||||
uses the selected colour as well as the visible band. Finished geometry still
|
||||
goes through the annotation board. Detaching the scene releases the claim,
|
||||
listeners and preview while retaining the chosen shape and colour. The
|
||||
page's pointer lease still arbitrates Draw against other tools.
|
||||
|
||||
## How to add an engine
|
||||
|
||||
Put the implementation in a new `src/globe/<name>/` folder. Keep SDK classes,
|
||||
shaders, resource disposal and SDK workarounds there. Use
|
||||
`src/globe/testing/fakeEngine.js` as the contract model, and
|
||||
`src/globe/contract.js` as the authoritative member and data-shape reference.
|
||||
Coordinates, colours, picks and display options crossing the boundary are plain
|
||||
data; displays do not receive native viewers or SDK types.
|
||||
|
||||
Implement the required host, camera, picker, projector, frame, render, surface,
|
||||
input, credit, clock and map interfaces, plus all five drawing primitives:
|
||||
`movers`, `lines`, `ground`, `imagery` and `meshes`. `assertGlobeEngine(engine)`
|
||||
checks these required interfaces. `engineCapabilities(engine)` reports only the
|
||||
optional visualStyles, bloom, cloudCover and contactSonar groups. An optional
|
||||
group must be complete or absent. Do not advertise a capability with a no-op implementation. Match Cesium's behavior where the
|
||||
contract permits both engines to implement it.
|
||||
|
||||
Run `createContractSuite(engine, { waitFrames, ... })` from
|
||||
`src/globe/testing/contractSuite.mjs`. Supply real pointer delivery, canvases and
|
||||
pixel reads for browser checks; keep the fake-engine tests as fast contract
|
||||
checks. Test ownership and repeated destruction as well as visible output.
|
||||
The browser harness is `scripts/qa-contract-suite.mjs`; use
|
||||
`--only=ground:,imagery:` for ground and imagery pixel checks.
|
||||
|
||||
The application currently registers two kinds: adding another still requires
|
||||
registration in `ENGINE_KINDS`, scene construction/choice and the corresponding
|
||||
QA harnesses. `assertGlobeEngine` currently validates that registration. The
|
||||
folder and passing contract suite are the implementation boundary, not a claim
|
||||
that arbitrary engine folders are auto-discovered today. Layers and their
|
||||
fixtures should need no new drawing implementation.
|
||||
|
||||
The layer manifest (`src/app/layers/renderers.js`) loads shared factories through
|
||||
`display`. Every valid engine implements all five primitives; the manifest has
|
||||
no per-kind primitive lists or `needs` declarations. `backends` supplies optional
|
||||
engine-specific display options, currently only wind's `flow`, loaded dynamically
|
||||
from its engine folder. A missing wind backend leaves the shared raster field
|
||||
available. Self-drawing layers and map overlays use the same contract and do not
|
||||
register supported engine kinds in the manifest.
|
||||
|
||||
Run `src/app/layers/displayRehearsal.test.mjs`: it loads every manifest display
|
||||
on the fake engine with an unknown kind and empty extensions. Its small fixtures
|
||||
exercise attachment, drawing, hiding or release, and destruction; primitive
|
||||
registries, frame/camera/input subscriptions, follow state and service records
|
||||
must be clean before the engine itself is destroyed. New display entries need a
|
||||
fixture. Wildlife Sightings and the two map overlays are explicit skips because
|
||||
they have no display factory; their layer tests remain the coverage for those
|
||||
controllers. Native wind streamlines, DOM rasterization and GPU pixels are not
|
||||
covered by this rehearsal.
|
||||
|
||||
`node scripts/check-import-directions.mjs` enforces SDK ownership for static
|
||||
imports, re-exports and literal dynamic imports, including package subpaths.
|
||||
Cesium packages belong in `src/globe/cesium/`; three, postprocessing and Navara
|
||||
packages belong in `src/globe/navara/`. Add the new SDK's ownership rule when
|
||||
registering it. Contract graphs cannot reach any engine, and the existing
|
||||
engines cannot reach one another. The temporary exact file/package exceptions
|
||||
in `scripts/package-boundaries.json` must have reasons and must still be used;
|
||||
the checker prints them. They are migration debt, not a pattern for new layers.
|
||||
Run package boundaries, formatting, the full unit suite and the coupling report
|
||||
as well; engine coupling counts may not grow.
|
||||
@@ -3,7 +3,7 @@
|
||||
`gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`.
|
||||
It returns fresh datacenter and dam layers in that order, with the existing IDs,
|
||||
appearance, label budgets and bundled public datasets. Importing the module or
|
||||
calling the factory does not load data, create an engine, or start the application.
|
||||
calling the factory does not load data, create a viewer, or start the application.
|
||||
The asset URLs resolve relative to the module; Vite rewrites them for production
|
||||
base paths. This browser source export expects a Vite-compatible asset build.
|
||||
|
||||
@@ -21,13 +21,7 @@ const layers = createInfrastructureLayers({
|
||||
governorRequestRender,
|
||||
});
|
||||
|
||||
// Attach each layer to the current engine, then enable it when requested.
|
||||
for (const layer of layers) layer.init(null, { engine });
|
||||
await layers[0].enable();
|
||||
|
||||
// On a scene switch, keep the parsed data and replace only its display.
|
||||
for (const layer of layers) layer.detachRenderer();
|
||||
for (const layer of layers) await layer.attachRenderer(null, { engine: nextEngine });
|
||||
// Register these layers with the existing data-layer manager and viewer.
|
||||
```
|
||||
|
||||
| Function | Arguments / responsibility |
|
||||
@@ -41,39 +35,28 @@ for (const layer of layers) await layer.attachRenderer(null, { engine: nextEngin
|
||||
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
||||
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||
|
||||
Each returned session layer supports `init(null, { engine })`,
|
||||
`attachRenderer(null, { engine, signal })`, `detachRenderer()`, `enable()`,
|
||||
`disable()`, `update()`, `destroy()`, `getStats()`, `getLodDiagnostics()` and
|
||||
`getAnalystRecords(limit)`. The first argument is retained for lifecycle
|
||||
compatibility; drawing uses the engine passed in the options. Attach once per
|
||||
scene, detach before attaching another engine, and destroy when the session ends.
|
||||
Separate applications may use identical IDs with separate context/overlay hosts.
|
||||
Callbacks must remain usable through destruction.
|
||||
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
||||
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
||||
`getAnalystRecords()`.
|
||||
Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay
|
||||
host permits one live instance per layer ID; destroy an old instance before
|
||||
replacing it. Separate applications may use identical IDs with separate hosts.
|
||||
Callbacks must be ready before enable and remain usable through destruction.
|
||||
|
||||
`src/layers/infrastructure/index.js` owns the parsed dataset, shared in-flight
|
||||
load, enabled intention and selection across scene switches. Enable loads on
|
||||
first use. Disable hides the drawing and clears selection but retains parsed
|
||||
data. Detach destroys scene resources and retains the dataset and selected ID;
|
||||
reattach redraws enabled data and restores selection when no other selection
|
||||
owns the context. The attachment signal cancels that scene's wait, not the
|
||||
session's fetch. Destroy aborts the fetch, discards late results and releases
|
||||
both data and drawing.
|
||||
Disable retains the loaded dataset for reuse and clears selection and listeners.
|
||||
Concurrent enables share one load. Destroy is permanent, aborts pending fetches,
|
||||
discards late results, and clears the owned data source, context records, overlays,
|
||||
input handler and listeners. The layer never creates an application context store,
|
||||
overlay host, viewer, or render scheduler. The consumer must resolve one compatible
|
||||
Cesium installation shared with its viewer; it must not bundle separate copies.
|
||||
|
||||
`src/layers/infrastructure/render/display.js` draws the same records on every
|
||||
engine: point movers for tips and pins, lines for stems, ground polygons for
|
||||
footprints and ground corridors for paths. It owns the LOD walk, surface-height
|
||||
refresh, picks, context handles and overlay publications. Destruction releases
|
||||
its primitives and frame, camera and input subscriptions. The layer never
|
||||
creates an overlay host, engine or render scheduler and does not import Cesium.
|
||||
|
||||
`gods-eye-view/infrastructure/geojson` exports the overlay publication,
|
||||
selection, label and error helpers in `src/data/localGeojsonCore.js`; it no
|
||||
longer exports `createLocalGeoJsonLayer`. `gods-eye-view/infrastructure/lod`
|
||||
exports the LOD policy helpers. The package exports do not import standalone
|
||||
application globals.
|
||||
`gods-eye-view/infrastructure/geojson` exports the lower-level
|
||||
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
|
||||
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers.
|
||||
Consumers use the package exports, which do not import standalone application
|
||||
globals.
|
||||
|
||||
Dataset files and source/license notices remain under `src/data/local_data/`.
|
||||
Their input counts are 4,352 datacenter features and 704 dam features. The session
|
||||
splits multipart geometries using Cesium's stable repeated-id suffixes on every
|
||||
engine. Feature, mover and stem counts are different measurements. These files
|
||||
and licenses are independent of the renderer implementation.
|
||||
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
|
||||
expand multipart geometries into multiple entities; entity and stem counts are
|
||||
different measurements. This change does not refresh or relicense the datasets.
|
||||
@@ -106,7 +106,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
||||
| 13 | thing | LEN | none | sub-message, see thing views |
|
||||
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm, sentinel2 |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
|
||||
| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
|
||||
| 17 | `bloomIntensity` | uint | style | |
|
||||
| 18 | `bloomVersion` | uint | 2 | |
|
||||
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||
|
||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||
so v1 never writes it; a later view kind writes its code.
|
||||
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
|
||||
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
||||
layout and restores it as `tactical`. The code stays reserved.
|
||||
|
||||
Field 28 is the world time the link was made at, written only when it was away
|
||||
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
|
||||
link's creation (a forecast moment), and it opens paused at that time.
|
||||
|
||||
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||
|
||||
|
||||
@@ -270,9 +270,9 @@ a feature supplying only its accent.
|
||||
the tracked-entity fade is measured once the frame is in the document;
|
||||
the styles it injected now live in `src/ui/styles/annotations.css`), the
|
||||
visible map target list (the scene chrome renders the buttons and status
|
||||
the world overlay presents through `subscribeActions()`, which
|
||||
the world overlay presents through `subscribeWorldOverlayActions()`, which
|
||||
the application shell publishes to the `gev-world-overlay-actions` store
|
||||
and answers with `activateAction()`; the module needs no Vue
|
||||
and answers with `activateWorldOverlayAction()`; the module needs no Vue
|
||||
for the portable transit entry, and its card and detection canvases stay
|
||||
painting surfaces it draws on, like the Cesium canvas), the Global
|
||||
Context compass and contact bearings (`AwarenessDirection` teleports into
|
||||
|
||||
+1
-1
@@ -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,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" 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,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
Generated
+13
-1113
File diff suppressed because it is too large.
Load diff
+7
-11
@@ -54,28 +54,21 @@
|
||||
"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",
|
||||
"qa:transit": "node scripts/qa-transit.mjs",
|
||||
"mcp": "node server/mcp/stdio.js",
|
||||
"check:main-chunk": "node scripts/check-main-chunk.mjs"
|
||||
"mcp": "node server/mcp/stdio.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jtarrio/signals": "^0.10.0",
|
||||
"@jtarrio/webrtlsdr": "^3.0.6",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"@navaramap/three": "0.2.1",
|
||||
"@navaramap/three-default-plugin": "0.2.1",
|
||||
"cesium": "^1.124.0",
|
||||
"earcut": "^3.0.2",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"hls.js": "^1.7.3",
|
||||
"kdbush": "4.0.2",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"mgrs": "^2.1.0",
|
||||
"pbf": "^5.1.2",
|
||||
"pinia": "^2.3.1",
|
||||
"postprocessing": "6.39.4",
|
||||
"satellite.js": "^6.0.2",
|
||||
"three": "0.185.1",
|
||||
"vue": "^3.5.44",
|
||||
"vue-i18n": "^11.4.15"
|
||||
},
|
||||
@@ -83,6 +76,7 @@
|
||||
"@happy-dom/global-registrator": "^20.14.6",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"flag-icons": "7.5.0",
|
||||
"happy-dom": "^20.14.6",
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
@@ -96,7 +90,8 @@
|
||||
"./infrastructure/geojson": "./src/data/localGeojsonCore.js",
|
||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||
"./application": "./src/app/application.js",
|
||||
"./application/viewer": "./src/globe/cesium/viewer.js",
|
||||
"./application/viewer": "./src/app/viewer.js",
|
||||
"./time": "./src/time/worldClock.js",
|
||||
"./build/vite": {
|
||||
"node": "./build/vite.js"
|
||||
},
|
||||
@@ -104,6 +99,7 @@
|
||||
"node": "./server/providers/live.js"
|
||||
},
|
||||
"./sources/adsb-lol": "./src/data/adsbLolFallback.js",
|
||||
"./sources/planespotters": "./src/data/planespottersPhoto.js",
|
||||
"./server/providers/places": {
|
||||
"node": "./server/providers/places.js"
|
||||
},
|
||||
@@ -205,8 +201,8 @@
|
||||
"./geography/neighborhoods": "./src/data/neighborhoodPolygons.js",
|
||||
"./maps/controller": "./src/maps/controller.js",
|
||||
"./maps/catalog": "./src/maps/catalog.js",
|
||||
"./maps/imagery": "./src/globe/cesium/mapImagery.js",
|
||||
"./maps/terrain": "./src/globe/cesium/terrain.js",
|
||||
"./maps/imagery": "./src/maps/imagery.js",
|
||||
"./maps/terrain": "./src/maps/terrain.js",
|
||||
"./maps/3d": "./src/maps/google3d.js",
|
||||
"./maps/defaults": "./src/maps/defaultSources.js",
|
||||
"./voice/controller": "./src/voice/realtimeController.js",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"source":"Revos addons/Qc cams/quebec_all_cameras.geojson","count":1120,"cameras":[{"id":"4103","kind":"traffic","name":"Route 117 à la jonction de la route 101 et à la hauteur du carrefour giratoire (Rouyn-Noranda)","nameEn":"Junction of route 117 and route 101 and at roundabout (Rouyn-Noranda)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Abitibi-Témiscamingue","lat":48.26461,"lon":-79.01808,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=4103","notes":"Live since 2025/11/26"},{"id":"3820","kind":"traffic","name":"Aut. 20 à la hauteur de la montée Industrielle-et-Commerciale","nameEn":"Aut. 20 at montée Industrielle-et-Commerciale","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.43993,"lon":-68.47784,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3820","notes":"Live since 2018/12/12"},{"id":"3821","kind":"traffic","name":"Montée Industrielle-et-Commerciale à Rimouski","nameEn":"Montée Industrielle-et-Commerciale at Rimouski","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.44639,"lon":-68.48528,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3821","notes":"Live since 2018/12/12"},{"id":"3822","kind":"traffic","name":"Route 132 à la hauteur du Bic à la jonction de l'Aut. 20","nameEn":"Route 132 at Bic at the junction of Aut. 20","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.3775,"lon":-68.68694,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3822","notes":"Live since 2018/12/12"},{"id":"3834","kind":"traffic","name":"Route 132 à la hauteur de Saint-Fabien","nameEn":"Route 132 at Saint-Fabien","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.32927,"lon":-68.80262,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3834","notes":"Live since 2019/02/07"},{"id":"3835","kind":"traffic","name":"Route 132 à la hauteur de Cap-Chat (secteur Capucins)","nameEn":"Route 132 at Cap-Chat (Capucins sector)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.04153,"lon":-66.86847,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3835","notes":"Live since 2019/02/07"},{"id":"3856","kind":"traffic","name":"Route 132 à l'est de Gros-Morne","nameEn":"Route 132, east of Gros-Morne","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.25302,"lon":-65.48355,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3856","notes":"Live since 2019/12/06"},{"id":"3836","kind":"traffic","name":"Route 132 à la hauteur de la baie du Petit Pabos","nameEn":"Route 132 at baie du Petit Pabos","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.37216,"lon":-64.58866,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3836","notes":"Live since 2019/02/07"},{"id":"3837","kind":"traffic","name":"Route 132 à la hauteur de Bonaventure","nameEn":"Route 132 at Bonaventure","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.03448,"lon":-65.46953,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3837","Line truncated
|
||||
@@ -7,13 +7,16 @@
|
||||
* Natural Earth 10m admin-1 states and provinces (public domain).
|
||||
* src/data/local_data/us_census_counties/counties.json
|
||||
* US Census Bureau cartographic boundary counties (public domain).
|
||||
* src/data/local_data/natural_earth/populated_places.json
|
||||
* Natural Earth 10m populated places as points, for the orbital view's
|
||||
* "near <city>" (`src/data/placeBelow.js`; public domain).
|
||||
*
|
||||
* Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs
|
||||
* byte for byte. Downloads are cached outside the repository (default: the
|
||||
* system temp directory) and are never committed.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties]
|
||||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties|places]
|
||||
*
|
||||
* The provenance READMEs beside each pack record these parameters.
|
||||
*/
|
||||
@@ -101,6 +104,9 @@ export const PARAMS = Object.freeze({
|
||||
elsewhereMinPopulation: 50_000,
|
||||
cityStateMaxKm2: 1_000,
|
||||
},
|
||||
// Every populated place, kept as a point: name, position and the two
|
||||
// prominence ranks the orbital view picks "near <city>" by.
|
||||
places: { decimals: 3 },
|
||||
});
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -688,6 +694,47 @@ async function buildAdmin1() {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPlaces() {
|
||||
const params = PARAMS.places;
|
||||
const source = await fetchPinned('places');
|
||||
const factor = 10 ** params.decimals;
|
||||
const round = (value) => Math.round(value * factor) / factor;
|
||||
const places = JSON.parse(source.bytes.toString('utf8'))
|
||||
.features.map((f) => f.properties)
|
||||
.filter(
|
||||
(p) =>
|
||||
p.name &&
|
||||
Number.isFinite(p.latitude) &&
|
||||
Number.isFinite(p.longitude) &&
|
||||
Number.isFinite(p.scalerank),
|
||||
)
|
||||
.map((p) => [
|
||||
p.name,
|
||||
round(p.latitude),
|
||||
round(p.longitude),
|
||||
p.scalerank,
|
||||
Number.isFinite(p.rank_max) ? p.rank_max : 0,
|
||||
])
|
||||
.sort((a, b) => a[3] - b[3] || b[4] - a[4] || a[0].localeCompare(b[0]));
|
||||
return {
|
||||
meta: {
|
||||
title: 'Natural Earth populated places',
|
||||
source: 'ne_10m_populated_places_simple',
|
||||
url: SOURCES.places.url,
|
||||
sha256: source.digest,
|
||||
commit: NE_COMMIT,
|
||||
license:
|
||||
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
|
||||
schema:
|
||||
'places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]',
|
||||
decimals: params.decimals,
|
||||
curation: params,
|
||||
script: 'scripts/build-admin-packs.mjs',
|
||||
},
|
||||
places,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCounties() {
|
||||
const params = PARAMS.counties;
|
||||
const source = await fetchPinned('counties');
|
||||
@@ -768,4 +815,13 @@ if (isMain) {
|
||||
'src/data/local_data/us_census_counties/counties.json',
|
||||
await buildCounties(),
|
||||
);
|
||||
if (!only || only === 'places') {
|
||||
const relative = 'src/data/local_data/natural_earth/populated_places.json';
|
||||
const pack = await buildPlaces();
|
||||
const text = JSON.stringify(pack) + '\n';
|
||||
await writeFile(path.join(ROOT, relative), text);
|
||||
console.log(
|
||||
`${relative}: ${pack.places.length} places, ${Buffer.byteLength(text)} bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the owner flag sheet the orbital Cockpit marks a satellite's owner
|
||||
* country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES
|
||||
* (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs
|
||||
* at twice the readout's 20 × 15 px, in one PNG the app loads at startup.
|
||||
*
|
||||
* src/data/local_data/owner_flags/owner_flags.png the sheet
|
||||
* src/ui/ownerFlagSheet.js its layout (generated)
|
||||
*
|
||||
* Flag i (in the sorted code list) sits at column i % COLUMNS, row
|
||||
* floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions
|
||||
* reproduces both files.
|
||||
*
|
||||
* Usage: node scripts/build-owner-flags.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as prettier from 'prettier';
|
||||
import sharp from 'sharp';
|
||||
import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const FLAGS_DIR = path.join(
|
||||
path.dirname(require.resolve('flag-icons/package.json')),
|
||||
'flags/4x3',
|
||||
);
|
||||
const FLAG_ICONS_VERSION = require('flag-icons/package.json').version;
|
||||
|
||||
/** One flag's cell, CSS pixels × 2. */
|
||||
export const CELL = Object.freeze({ width: 40, height: 30 });
|
||||
/**
|
||||
* Transparent sheet pixels after each flag, so a browser smoothing a scaled
|
||||
* flag (a 125% display) never reaches into its neighbour.
|
||||
*/
|
||||
export const GUTTER = 2;
|
||||
export const COLUMNS = 16;
|
||||
|
||||
const SHEET = path.join(
|
||||
ROOT,
|
||||
'src/data/local_data/owner_flags/owner_flags.png',
|
||||
);
|
||||
const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js');
|
||||
|
||||
async function flagCell(code) {
|
||||
const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`));
|
||||
return sharp(svg, { density: 300 })
|
||||
.resize(CELL.width, CELL.height, { fit: 'fill' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const codes = [...OWNER_COUNTRY_CODES];
|
||||
const rows = Math.ceil(codes.length / COLUMNS);
|
||||
const pitchX = CELL.width + GUTTER;
|
||||
const pitchY = CELL.height + GUTTER;
|
||||
const cells = await Promise.all(codes.map(flagCell));
|
||||
const sheet = await sharp({
|
||||
create: {
|
||||
width: COLUMNS * pitchX,
|
||||
height: rows * pitchY,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
})
|
||||
.composite(
|
||||
cells.map((input, i) => ({
|
||||
input,
|
||||
left: (i % COLUMNS) * pitchX,
|
||||
top: Math.floor(i / COLUMNS) * pitchY,
|
||||
})),
|
||||
)
|
||||
.png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 })
|
||||
.toBuffer();
|
||||
await writeFile(SHEET, sheet);
|
||||
|
||||
const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit.
|
||||
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
|
||||
export const OWNER_FLAG_SHEET = Object.freeze({
|
||||
cellWidthPx: ${CELL.width},
|
||||
cellHeightPx: ${CELL.height},
|
||||
gutterPx: ${GUTTER},
|
||||
columns: ${COLUMNS},
|
||||
rows: ${rows},
|
||||
codes: Object.freeze(${JSON.stringify(codes)}),
|
||||
});
|
||||
`;
|
||||
await writeFile(
|
||||
LAYOUT,
|
||||
await prettier.format(layout, {
|
||||
...(await prettier.resolveConfig(LAYOUT)),
|
||||
filepath: LAYOUT,
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
`Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`,
|
||||
);
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Maintainer-only UCS Satellite Database pack build (openpyxl).
|
||||
|
||||
Reads the UCS Excel release and writes the compact pack the satellites layer
|
||||
loads: one row per satellite, keyed by NORAD number, with string-table indexes
|
||||
for the repeated text columns. Starlink rows are left out: the layer files
|
||||
every STARLINK object under its own class and operator without them.
|
||||
|
||||
Usage: python scripts/build-ucs-satellites.py "<path>/UCS-Satellite-Database 5-1-2023.xlsx"
|
||||
'''
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import openpyxl
|
||||
|
||||
LOG = logging.getLogger('build-ucs-satellites')
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PACK = ROOT / 'src/data/local_data/ucs_satellites'
|
||||
VERSION = 'UCS-Satellite-Database 5-1-2023'
|
||||
DATA_CURRENT_TO = '2023-05-01'
|
||||
URL = 'https://www.ucsusa.org/satellite_database'
|
||||
|
||||
# Output column -> spreadsheet header.
|
||||
COLUMNS = {
|
||||
'operator': 'Operator/Owner',
|
||||
'operatorCountry': 'Country of Operator/Owner',
|
||||
'purpose': 'Purpose',
|
||||
}
|
||||
STARLINK = re.compile(r'\bstarlink\b', re.I)
|
||||
|
||||
|
||||
def clean(value: object) -> str:
|
||||
'''One cell as tidy text; UCS's placeholders for nothing become ''.'''
|
||||
text = re.sub(r'\s+', ' ', str(value or '')).strip()
|
||||
return '' if text.lower() in ('', 'none', 'n/a') else text
|
||||
|
||||
|
||||
def build(xlsx: Path) -> dict:
|
||||
'''Read the release's first sheet into the pack the layer loads.'''
|
||||
sheet = openpyxl.load_workbook(xlsx, read_only=True, data_only=True).worksheets[0]
|
||||
rows = sheet.iter_rows(values_only=True)
|
||||
header = {str(h).strip(): i for i, h in enumerate(next(rows)) if h}
|
||||
names = [header['Current Official Name of Satellite'], header['Name of Satellite, Alternate Names']]
|
||||
norad_col = header['NORAD Number']
|
||||
tables = {key: [''] for key in COLUMNS}
|
||||
index = {key: {'': 0} for key in COLUMNS}
|
||||
records = {}
|
||||
source_rows = skipped_starlink = invalid_norad = 0
|
||||
for row in rows:
|
||||
if not any(row):
|
||||
continue
|
||||
source_rows += 1
|
||||
if any(STARLINK.search(clean(row[i])) for i in names):
|
||||
skipped_starlink += 1
|
||||
continue
|
||||
try:
|
||||
norad = int(float(row[norad_col]))
|
||||
except (TypeError, ValueError):
|
||||
invalid_norad += 1
|
||||
continue
|
||||
if norad <= 0:
|
||||
invalid_norad += 1
|
||||
continue
|
||||
record = [norad]
|
||||
for key, column in COLUMNS.items():
|
||||
text = clean(row[header[column]])
|
||||
if text not in index[key]:
|
||||
index[key][text] = len(tables[key])
|
||||
tables[key].append(text)
|
||||
record.append(index[key][text])
|
||||
# A duplicate NORAD number keeps the first row, as UCS lists it.
|
||||
records.setdefault(norad, record)
|
||||
ordered = [records[n] for n in sorted(records)]
|
||||
return {
|
||||
'meta': {
|
||||
'source': 'Union of Concerned Scientists Satellite Database',
|
||||
'version': VERSION,
|
||||
'url': URL,
|
||||
'dataCurrentTo': DATA_CURRENT_TO,
|
||||
'license': 'Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)',
|
||||
'curation': {
|
||||
'sourceRows': source_rows,
|
||||
'starlinkRowsDropped': skipped_starlink,
|
||||
'invalidNoradDropped': invalid_norad,
|
||||
'duplicateNoradDropped': source_rows - skipped_starlink - invalid_norad - len(ordered),
|
||||
'columns': COLUMNS,
|
||||
},
|
||||
'recordCount': len(ordered),
|
||||
},
|
||||
'fields': ['noradId', *COLUMNS],
|
||||
'strings': tables,
|
||||
'records': ordered,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
'''Build the pack from the release given on the command line.'''
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
parser = argparse.ArgumentParser(description='Build the UCS satellite pack from the UCS Excel release.')
|
||||
parser.add_argument('xlsx', type=Path, help='UCS-Satellite-Database <version>.xlsx')
|
||||
pack = build(parser.parse_args().xlsx)
|
||||
PACK.mkdir(parents=True, exist_ok=True)
|
||||
out = PACK / 'ucs_satellites.json'
|
||||
# LF on every platform: the repository stores text with LF endings.
|
||||
out.write_text(
|
||||
json.dumps(pack, ensure_ascii=False, separators=(',', ':')) + '\n',
|
||||
encoding='utf-8',
|
||||
newline='\n',
|
||||
)
|
||||
LOG.info('%s: %d records, %d bytes', out.relative_to(ROOT), pack['meta']['recordCount'], out.stat().st_size)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,185 +0,0 @@
|
||||
// The production build replaces every `cesium` import with the global that
|
||||
// /cesium/Cesium.js defines, and that script loads only when a Cesium globe
|
||||
// first starts (src/app/loadCesium.js). A module that reads a Cesium binding
|
||||
// while it loads would therefore throw on a page that starts on Navara. This
|
||||
// check lists such reads: a reference to a binding imported from `cesium`, or
|
||||
// to the `Cesium` global, that runs when its module is evaluated (top level,
|
||||
// class heritage, static members, immediately invoked functions) rather than
|
||||
// inside a function that runs later.
|
||||
//
|
||||
// node scripts/check-cesium-load-time.mjs [--json]
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { parse } from '@babel/parser';
|
||||
import { vueScripts } from './vue-scripts.mjs';
|
||||
|
||||
const root = new URL('..', import.meta.url);
|
||||
|
||||
/** Files under src/ that the app ships (tests and test support excluded). */
|
||||
export function sourceFiles() {
|
||||
return execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', 'src'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.split('\n')
|
||||
.filter(
|
||||
(path) =>
|
||||
/\.(?:js|mjs|vue)$/.test(path) &&
|
||||
!/\.test\.m?js$/.test(path) &&
|
||||
!path.startsWith('src/testSupport/') &&
|
||||
!path.includes('/testing/') &&
|
||||
existsSync(new URL(path, root)),
|
||||
);
|
||||
}
|
||||
|
||||
const FUNCTION_TYPES = new Set([
|
||||
'FunctionDeclaration',
|
||||
'FunctionExpression',
|
||||
'ArrowFunctionExpression',
|
||||
'ObjectMethod',
|
||||
'ClassMethod',
|
||||
'ClassPrivateMethod',
|
||||
]);
|
||||
|
||||
/** Names a module binds to `cesium` imports. */
|
||||
function cesiumBindings(program) {
|
||||
const names = new Set();
|
||||
for (const node of program.body) {
|
||||
if (node.type !== 'ImportDeclaration' || node.source.value !== 'cesium')
|
||||
continue;
|
||||
for (const specifier of node.specifiers) names.add(specifier.local.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function isReference(node, parent, key) {
|
||||
if (node.type !== 'Identifier') return false;
|
||||
if (!parent) return true;
|
||||
if (
|
||||
(parent.type === 'MemberExpression' ||
|
||||
parent.type === 'OptionalMemberExpression') &&
|
||||
key === 'property' &&
|
||||
!parent.computed
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
(parent.type === 'ObjectProperty' ||
|
||||
parent.type === 'ClassProperty' ||
|
||||
parent.type === 'ObjectMethod' ||
|
||||
parent.type === 'ClassMethod') &&
|
||||
key === 'key' &&
|
||||
!parent.computed
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
parent.type === 'ImportSpecifier' ||
|
||||
parent.type === 'ImportNamespaceSpecifier' ||
|
||||
parent.type === 'ImportDefaultSpecifier' ||
|
||||
(parent.type === 'ExportSpecifier' && key !== 'local')
|
||||
)
|
||||
return false;
|
||||
if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement')
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load-time references in one module. `deferred` is true below a function
|
||||
* body that runs later; an immediately invoked function and a class's static
|
||||
* members and heritage run at load and stay not deferred.
|
||||
*/
|
||||
export function loadTimeReferences(code, path = '<module>') {
|
||||
if (path.endsWith('.vue'))
|
||||
return vueScripts(code, path).flatMap((script) =>
|
||||
loadTimeReferences(script).map((reference) => ({ ...reference, path })),
|
||||
);
|
||||
const program = parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['classProperties', 'classStaticBlock', 'importAttributes'],
|
||||
}).program;
|
||||
const names = cesiumBindings(program);
|
||||
const found = [];
|
||||
// A re-export from `cesium` becomes a read of the global's property when
|
||||
// the module loads.
|
||||
for (const node of program.body)
|
||||
if (
|
||||
(node.type === 'ExportNamedDeclaration' ||
|
||||
node.type === 'ExportAllDeclaration') &&
|
||||
node.source?.value === 'cesium'
|
||||
)
|
||||
found.push({
|
||||
path,
|
||||
line: node.loc.start.line,
|
||||
name: 'export from cesium',
|
||||
});
|
||||
const hasGlobal = /\bCesium\b/.test(code);
|
||||
if (!names.size && !hasGlobal) return found;
|
||||
// A `Cesium` global reference counts when the module does not bind it.
|
||||
const watched = (name) =>
|
||||
names.has(name) || (name === 'Cesium' && !names.has('Cesium'));
|
||||
const visit = (node, parent, key, deferred) => {
|
||||
if (!node || typeof node.type !== 'string') return;
|
||||
if (node.type === 'ImportDeclaration') return;
|
||||
if (!deferred && isReference(node, parent, key) && watched(node.name))
|
||||
found.push({ path, line: node.loc.start.line, name: node.name });
|
||||
let childDeferred = deferred;
|
||||
if (FUNCTION_TYPES.has(node.type)) {
|
||||
const invoked =
|
||||
parent &&
|
||||
(parent.type === 'CallExpression' || parent.type === 'NewExpression') &&
|
||||
key === 'callee';
|
||||
childDeferred = deferred || !invoked;
|
||||
}
|
||||
if (
|
||||
(node.type === 'ClassProperty' || node.type === 'ClassPrivateProperty') &&
|
||||
!node.static
|
||||
)
|
||||
childDeferred = true;
|
||||
for (const [childKey, value] of Object.entries(node)) {
|
||||
if (
|
||||
childKey === 'loc' ||
|
||||
childKey === 'leadingComments' ||
|
||||
childKey === 'trailingComments' ||
|
||||
childKey === 'innerComments'
|
||||
)
|
||||
continue;
|
||||
// Computed keys and parameter defaults of a deferred function: its key
|
||||
// is evaluated with the enclosing scope, its body later.
|
||||
const keyDeferred =
|
||||
FUNCTION_TYPES.has(node.type) && childKey === 'key'
|
||||
? deferred
|
||||
: childDeferred;
|
||||
if (Array.isArray(value))
|
||||
for (const child of value) visit(child, node, childKey, keyDeferred);
|
||||
else if (value && typeof value === 'object')
|
||||
visit(value, node, childKey, keyDeferred);
|
||||
}
|
||||
};
|
||||
visit(program, null, null, false);
|
||||
return found;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const found = [];
|
||||
for (const path of sourceFiles()) {
|
||||
const code = readFileSync(new URL(path, root), 'utf8');
|
||||
try {
|
||||
found.push(...loadTimeReferences(code, path));
|
||||
} catch (error) {
|
||||
console.error(`${path}: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
if (process.argv.includes('--json'))
|
||||
console.log(JSON.stringify(found, null, 2));
|
||||
else
|
||||
for (const { path, line, name } of found)
|
||||
console.log(`${path}:${line} reads ${name} while the module loads`);
|
||||
if (found.length) {
|
||||
console.error(
|
||||
`${found.length} load-time Cesium read(s); a page that starts on Navara has no Cesium global yet.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else console.log('No module reads Cesium while it loads.');
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { loadTimeReferences, sourceFiles } from './check-cesium-load-time.mjs';
|
||||
|
||||
const reads = (code) =>
|
||||
loadTimeReferences(code).map(({ line, name }) => `${line}:${name}`);
|
||||
|
||||
test('reads that run while a module loads are found; reads inside later code are not', () => {
|
||||
assert.deepEqual(
|
||||
reads(`import * as Cesium from 'cesium';
|
||||
const color = Cesium.Color.RED;
|
||||
export function later() { return Cesium.Color.BLUE; }
|
||||
const lazy = () => Cesium.Math.PI;
|
||||
class Shape { static axis = Cesium.Axis.Y; field = Cesium.Axis.Z; draw() { return Cesium; } }
|
||||
(() => Cesium.VERSION)();
|
||||
export const fallback = (sdk = Cesium) => sdk;`),
|
||||
['2:Cesium', '5:Cesium', '6:Cesium'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
reads(`import { Cartesian3 as C3, Color } from 'cesium';
|
||||
export const ORIGIN = C3.ZERO;
|
||||
export function white() { return Color.WHITE; }`),
|
||||
['2:C3'],
|
||||
);
|
||||
assert.deepEqual(reads(`export { Axis } from 'cesium';`), [
|
||||
'1:export from cesium',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
reads(`window.__CESIUM__ = Cesium;`),
|
||||
['1:Cesium'],
|
||||
'the global counts where the module does not import it',
|
||||
);
|
||||
assert.deepEqual(reads(`const geo = { Cesium: 1 }; geo.Cesium;`), []);
|
||||
});
|
||||
|
||||
test('no shipped module reads Cesium while it loads', () => {
|
||||
const found = sourceFiles().flatMap((path) =>
|
||||
loadTimeReferences(readFileSync(path, 'utf8'), path),
|
||||
);
|
||||
assert.deepEqual(found, []);
|
||||
});
|
||||
|
||||
test('re-exporting imported Cesium bindings reads them while loading', () => {
|
||||
assert.deepEqual(
|
||||
reads("import { Color } from 'cesium'; export { Color as Tint };"),
|
||||
['1:Color'],
|
||||
);
|
||||
assert.deepEqual(reads("import * as C from 'cesium'; export { C };"), [
|
||||
'1:C',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Vue scripts participate in the load-time check with original line numbers', () => {
|
||||
assert.deepEqual(
|
||||
loadTimeReferences(
|
||||
`<template><span>Cesium</span></template>
|
||||
<script>
|
||||
import { Color } from 'cesium';
|
||||
export const tint = Color.RED;
|
||||
</script>`,
|
||||
'src/View.vue',
|
||||
).map(({ line, name }) => `${line}:${name}`),
|
||||
['4:Color'],
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { analyzeModule } from './module-analysis.mjs';
|
||||
const builtins = new Set(
|
||||
builtinModules.flatMap((name) => [name, `node:${name}`]),
|
||||
);
|
||||
const code = /\.(?:[mc]?js|vue)$/;
|
||||
const code = /\.[mc]?js$/;
|
||||
const entry = (file) =>
|
||||
file === 'src/main.js' || file.startsWith('src/standalone/');
|
||||
const tests = (file) =>
|
||||
@@ -19,17 +19,6 @@ const tests = (file) =>
|
||||
const source = (file) =>
|
||||
file.startsWith('src/sources/') ||
|
||||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
|
||||
// Engine-neutral geometry ported from Cesium; every runtime file is a portable root.
|
||||
const geo = (file) => file.startsWith('src/geo/');
|
||||
// The app clock (src/time): plain numbers, no engine, no DOM.
|
||||
const time = (file) => file.startsWith('src/time/');
|
||||
// The engine-neutral globe contract and its test support; engines live below it.
|
||||
const globeContract = (file) =>
|
||||
/^src\/globe\/[^/]+$/.test(file) || file.startsWith('src/globe/testing/');
|
||||
const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
|
||||
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
|
||||
// Navara renders through three.js and its postprocessing peer.
|
||||
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
||||
const renderer = (file) =>
|
||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
@@ -81,25 +70,6 @@ export function checkImportDirections(root) {
|
||||
(file) =>
|
||||
code.test(file) && !tests(file) && existsSync(path.join(root, file)),
|
||||
);
|
||||
const boundaryFile = path.join(root, 'scripts/package-boundaries.json');
|
||||
const { engineImportExceptions = [] } = existsSync(boundaryFile)
|
||||
? JSON.parse(readFileSync(boundaryFile, 'utf8'))
|
||||
: {};
|
||||
const exceptionKeys = new Set();
|
||||
for (const item of engineImportExceptions) {
|
||||
const key = `${item.file}:${item.package}`;
|
||||
if (
|
||||
typeof item.file !== 'string' ||
|
||||
!files.includes(item.file) ||
|
||||
!(cesiumPackage.test(item.package) || navaraPackage.test(item.package)) ||
|
||||
typeof item.reason !== 'string' ||
|
||||
!item.reason.trim() ||
|
||||
exceptionKeys.has(key)
|
||||
)
|
||||
throw new Error(`Invalid engine import exception: ${key}`);
|
||||
exceptionKeys.add(key);
|
||||
}
|
||||
const usedExceptions = new Set();
|
||||
const errors = new Set();
|
||||
const modules = new Map();
|
||||
const relative = (file) =>
|
||||
@@ -117,10 +87,7 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
let analysis;
|
||||
try {
|
||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
|
||||
dynamic: true,
|
||||
filename: file,
|
||||
});
|
||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
|
||||
} catch (error) {
|
||||
report(file, error.message);
|
||||
return null;
|
||||
@@ -142,19 +109,6 @@ export function checkImportDirections(root) {
|
||||
target = path.resolve(root, exported);
|
||||
} else {
|
||||
record.external.push(specifier);
|
||||
const owner = cesiumPackage.test(specifier)
|
||||
? 'cesium'
|
||||
: navaraPackage.test(specifier)
|
||||
? 'navara'
|
||||
: null;
|
||||
if (owner && !file.startsWith(`src/globe/${owner}/`)) {
|
||||
const exception = engineImportExceptions.find(
|
||||
(item) => item.file === file && item.package === specifier,
|
||||
);
|
||||
if (exception) usedExceptions.add(exception);
|
||||
else
|
||||
report(file, `${specifier} imports belong in src/globe/${owner}/`);
|
||||
}
|
||||
if (
|
||||
file.startsWith('src/') &&
|
||||
(builtins.has(specifier) || specifier.startsWith('node:'))
|
||||
@@ -177,18 +131,6 @@ export function checkImportDirections(root) {
|
||||
continue;
|
||||
}
|
||||
record.edges.push(to);
|
||||
// Navara stays out of the Cesium-mode bundle: outside its own directory
|
||||
// it is reached only through import() (src/app/scene.js), which the
|
||||
// bundler splits into a chunk loaded on ?engine=navara.
|
||||
if (
|
||||
to.startsWith('src/globe/navara/') &&
|
||||
!file.startsWith('src/globe/navara/') &&
|
||||
analysis.staticImports.includes(specifier)
|
||||
)
|
||||
report(
|
||||
file,
|
||||
`The Navara engine loads only through a dynamic import(): ${to}`,
|
||||
);
|
||||
if (file.startsWith('src/') && to.startsWith('server/'))
|
||||
report(file, `Browser imports server: ${to}`);
|
||||
if (
|
||||
@@ -235,8 +177,6 @@ export function checkImportDirections(root) {
|
||||
for (const file of files.filter((file) => file.startsWith('src/')))
|
||||
browser(file);
|
||||
const roots = new Map(files.filter(source).map((file) => [file, 'source']));
|
||||
for (const file of files.filter(geo)) roots.set(file, 'geo');
|
||||
for (const file of files.filter(time)) roots.set(file, 'time');
|
||||
for (const [key, value] of Object.entries(pkg.exports || {})) {
|
||||
if (portableExport(key) && typeof value === 'string')
|
||||
roots.set(value.replace(/^\.\//, ''), key);
|
||||
@@ -262,12 +202,7 @@ export function checkImportDirections(root) {
|
||||
if (
|
||||
builtins.has(specifier) ||
|
||||
specifier.startsWith('node:') ||
|
||||
/^(?:cesium|vite)(?:\/|$)/.test(specifier) ||
|
||||
// Geo must not reach Cesium through its workspace packages either.
|
||||
(label === 'geo' && /^@cesium\//.test(specifier)) ||
|
||||
// Neither engine reaches the app clock.
|
||||
(label === 'time' &&
|
||||
(cesiumPackage.test(specifier) || navaraPackage.test(specifier)))
|
||||
/^(?:cesium|vite)(?:\/|$)/.test(specifier)
|
||||
)
|
||||
report(
|
||||
file,
|
||||
@@ -282,51 +217,6 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
visit(start);
|
||||
}
|
||||
// The globe contract reaches no engine, directly or through helpers; each
|
||||
// engine implementation imports only its own engine's packages.
|
||||
const globeSeen = new Set();
|
||||
function globe(file) {
|
||||
if (globeSeen.has(file) || !code.test(file)) return;
|
||||
globeSeen.add(file);
|
||||
const info = load(file);
|
||||
if (!info) return;
|
||||
for (const specifier of info.external) {
|
||||
if (cesiumPackage.test(specifier) || navaraPackage.test(specifier))
|
||||
report(file, `globe contract reaches engine dependency: ${specifier}`);
|
||||
}
|
||||
for (const to of info.edges) {
|
||||
if (globeEngine(to))
|
||||
report(file, `globe contract imports engine implementation: ${to}`);
|
||||
else globe(to);
|
||||
}
|
||||
}
|
||||
for (const file of files.filter(globeContract)) globe(file);
|
||||
// Each engine graph stays on its own engine, through helpers too.
|
||||
for (const owner of ['cesium', 'navara']) {
|
||||
const navara = owner === 'navara';
|
||||
const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
|
||||
const foreignPackage = navara ? cesiumPackage : navaraPackage;
|
||||
const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
|
||||
const seen = new Set();
|
||||
const visit = (file) => {
|
||||
if (seen.has(file) || !code.test(file)) return;
|
||||
seen.add(file);
|
||||
const info = load(file);
|
||||
if (!info) return;
|
||||
for (const specifier of info.external)
|
||||
if (foreignPackage.test(specifier))
|
||||
report(file, `${label} reaches ${specifier}`);
|
||||
for (const to of info.edges) {
|
||||
if (to.startsWith(foreignEngine))
|
||||
report(file, `${label} reaches ${to}`);
|
||||
else visit(to);
|
||||
}
|
||||
};
|
||||
for (const start of files.filter((file) =>
|
||||
file.startsWith(`src/globe/${owner}/`),
|
||||
))
|
||||
visit(start);
|
||||
}
|
||||
// Common voice controls may use their DOM view, but never a protocol implementation.
|
||||
const voiceSeen = new Set();
|
||||
function voice(file) {
|
||||
@@ -341,17 +231,8 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
if (existsSync(path.join(root, 'src/voice/sessionCommands.js')))
|
||||
voice('src/voice/sessionCommands.js');
|
||||
for (const item of engineImportExceptions) {
|
||||
if (!usedExceptions.has(item))
|
||||
report(item.file, `Unused engine import exception: ${item.package}`);
|
||||
}
|
||||
if (errors.size) throw new Error([...errors].join('\n'));
|
||||
return {
|
||||
modules: files.length,
|
||||
portableEntries: roots.size,
|
||||
engineImportExceptionCount: usedExceptions.size,
|
||||
engineImportExceptions: [...usedExceptions],
|
||||
};
|
||||
return { modules: files.length, portableEntries: roots.size };
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The size budget of the Cesium-mode main chunk (run after `npm run build`):
|
||||
* gzip-9 of the entry chunk the built page loads (from the Vite manifest, or
|
||||
* the module script dist/index.html loads) may exceed the baseline in
|
||||
* scripts/main-chunk-baseline.json by at most its allowance. What the initial
|
||||
* chunks may hold is the build's own check (build/initialChunkGuard.js).
|
||||
*
|
||||
* Prints the size. `--json` prints the whole result; `--dist=DIR` reads
|
||||
* another build.
|
||||
*
|
||||
* npm run build && npm run check:main-chunk
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { entryFromHtml, entryFromManifest } from '../src/tooling/mainChunk.js';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
/** The verdict for a main-chunk size against the baseline. */
|
||||
export function checkMainChunk(
|
||||
gzipBytes,
|
||||
{ gzipBytes: baseline, allowanceBytes },
|
||||
) {
|
||||
const limit = baseline + allowanceBytes;
|
||||
const growth = gzipBytes - baseline;
|
||||
return {
|
||||
ok: gzipBytes <= limit,
|
||||
message: `main chunk ${gzipBytes} bytes gzip-9 (${growth >= 0 ? '+' : ''}${growth} over the ${baseline} baseline; limit ${limit})`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* gzip-9 size of the main chunk, with its name: the entry the built page
|
||||
* loads (`entry`, else found from the Vite manifest or dist/index.html).
|
||||
*/
|
||||
export function measureMainChunk(distDir, { entry = null } = {}) {
|
||||
const file = entry ?? findEntry(distDir).entry;
|
||||
const bytes = fs.readFileSync(path.join(distDir, file));
|
||||
return {
|
||||
name: path.basename(file),
|
||||
gzipBytes: gzipSync(bytes, { level: 9 }).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The entry chunk (relative to dist) and how it was found. */
|
||||
function findEntry(dist) {
|
||||
const manifestPath = path.join(dist, '.vite', 'manifest.json');
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const entry = entryFromManifest(
|
||||
JSON.parse(fs.readFileSync(manifestPath, 'utf8')),
|
||||
);
|
||||
if (entry) return { entry, via: 'manifest' };
|
||||
}
|
||||
const htmlPath = path.join(dist, 'index.html');
|
||||
if (!fs.existsSync(htmlPath))
|
||||
throw new Error(`No build at ${dist}: run npm run build first`);
|
||||
const entry = entryFromHtml(fs.readFileSync(htmlPath, 'utf8'));
|
||||
if (!entry) throw new Error(`${htmlPath} loads no module script`);
|
||||
return { entry, via: 'index.html' };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const option = (name) =>
|
||||
process.argv.find((arg) => arg.startsWith(`--${name}=`))?.split('=')[1];
|
||||
const dist = path.resolve(ROOT, option('dist') ?? 'dist');
|
||||
const { entry, via } = findEntry(dist);
|
||||
const baselinePath = path.join(ROOT, 'scripts/main-chunk-baseline.json');
|
||||
let budget = null;
|
||||
if (fs.existsSync(baselinePath)) {
|
||||
const measured = measureMainChunk(dist, { entry });
|
||||
budget = {
|
||||
...measured,
|
||||
...checkMainChunk(
|
||||
measured.gzipBytes,
|
||||
JSON.parse(fs.readFileSync(baselinePath, 'utf8')),
|
||||
),
|
||||
};
|
||||
}
|
||||
const ok = budget?.ok ?? true;
|
||||
if (process.argv.includes('--json')) {
|
||||
console.log(JSON.stringify({ entry, via, budget, ok }, null, 2));
|
||||
} else {
|
||||
console.log(`[check:main-chunk] entry ${entry} (via ${via})`);
|
||||
console.log(
|
||||
budget
|
||||
? `[check:main-chunk] ${budget.name}: ${budget.message}`
|
||||
: '[check:main-chunk] no scripts/main-chunk-baseline.json: size budget not checked',
|
||||
);
|
||||
}
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
main();
|
||||
@@ -11,9 +11,7 @@ export async function checkPackageBoundaries(root) {
|
||||
const readJson = async (name) =>
|
||||
JSON.parse(await readFile(path.join(root, name), 'utf8'));
|
||||
const pkg = await readJson('package.json');
|
||||
const { engineImportExceptions, ...groups } = await readJson(
|
||||
'scripts/package-boundaries.json',
|
||||
);
|
||||
const groups = await readJson('scripts/package-boundaries.json');
|
||||
const declaredExports = Object.keys(pkg.exports || {}).sort();
|
||||
const classifiedExports = Object.values(groups)
|
||||
.flatMap((group) => group.exports)
|
||||
@@ -39,11 +37,7 @@ export async function checkPackageBoundaries(root) {
|
||||
) {
|
||||
throw new Error(`Invalid package boundary: ${name}`);
|
||||
}
|
||||
// A module entry ending in "/" owns every file under that directory.
|
||||
const allowed = new Set();
|
||||
const allowedDirectories = [];
|
||||
const owns = (id) =>
|
||||
allowed.has(id) || allowedDirectories.some((dir) => id.startsWith(dir));
|
||||
for (const module of group.modules) {
|
||||
if (
|
||||
typeof module !== 'string' ||
|
||||
@@ -62,11 +56,7 @@ export async function checkPackageBoundaries(root) {
|
||||
) {
|
||||
throw new Error(`Boundary module escapes repository: ${name}`);
|
||||
}
|
||||
if (module.endsWith('/')) {
|
||||
allowedDirectories.push(`${normalizePath(resolved)}/`);
|
||||
} else {
|
||||
allowed.add(normalizePath(resolved));
|
||||
}
|
||||
allowed.add(normalizePath(resolved));
|
||||
}
|
||||
for (const external of group.external) {
|
||||
if (
|
||||
@@ -92,7 +82,7 @@ export async function checkPackageBoundaries(root) {
|
||||
if (
|
||||
typeof target !== 'string' ||
|
||||
!target.startsWith('./') ||
|
||||
!owns(normalizePath(path.resolve(root, target)))
|
||||
!allowed.has(normalizePath(path.resolve(root, target)))
|
||||
) {
|
||||
throw new Error(`Export must point to an owned module: ${key}`);
|
||||
}
|
||||
@@ -119,7 +109,7 @@ export async function checkPackageBoundaries(root) {
|
||||
// template blocks belong to the component file itself.
|
||||
if (info.id.startsWith('\0')) return;
|
||||
const id = info.id.split('?')[0];
|
||||
if (!owns(id)) {
|
||||
if (!allowed.has(id)) {
|
||||
throw new Error(
|
||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
|
||||
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
||||
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
|
||||
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}"
|
||||
|
||||
# Capture which provider credentials genuinely came from the parent shell
|
||||
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
||||
@@ -250,7 +250,7 @@ if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then
|
||||
fi
|
||||
|
||||
if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \
|
||||
! grep -q "createApplicationCctv(" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
|
||||
! grep -q "^[[:space:]]*createApplicationCctv({" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
|
||||
echo "error: CCTV layer not wired in src/standalone/catalog.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"metrics": {
|
||||
"cesiumLayerRendererLines": 0,
|
||||
"navaraLayerRendererLines": 0,
|
||||
"cesiumImportFiles": 3,
|
||||
"viewerLines": 750,
|
||||
"navaraKindChecks": 1,
|
||||
"engineSniffCalls": 0,
|
||||
"engineExtensionReads": 3,
|
||||
"groundEffectPasses": 0,
|
||||
"cesiumSdkLoads": 3,
|
||||
"screenSpaceHandlerFiles": 2,
|
||||
"engineNameLines": 27
|
||||
},
|
||||
"measuredAt": "One selection path and test level"
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ENGINE_COUPLING_METRICS,
|
||||
couplingGrowth,
|
||||
measureEngineCoupling,
|
||||
readBaseline,
|
||||
} from './report-engine-coupling.mjs';
|
||||
|
||||
test('engine coupling does not grow past the baseline', () => {
|
||||
const growth = couplingGrowth(
|
||||
measureEngineCoupling(),
|
||||
readBaseline().metrics,
|
||||
);
|
||||
assert.deepEqual(
|
||||
growth,
|
||||
[],
|
||||
'Code outside the globe engines grew more engine-specific. Draw through ' +
|
||||
'the globe contract instead; only a deliberate exception may raise ' +
|
||||
'scripts/engine-coupling-baseline.json (--write-baseline).',
|
||||
);
|
||||
});
|
||||
|
||||
test('the baseline names every metric', () => {
|
||||
const { metrics } = readBaseline();
|
||||
assert.deepEqual(
|
||||
Object.keys(metrics).sort(),
|
||||
ENGINE_COUPLING_METRICS.map(({ id }) => id).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('a metric that grows is reported, one that falls is not', () => {
|
||||
const baseline = Object.fromEntries(
|
||||
ENGINE_COUPLING_METRICS.map(({ id }) => [id, 10]),
|
||||
);
|
||||
const measured = { ...baseline, viewerLines: 11, navaraKindChecks: 3 };
|
||||
assert.deepEqual(
|
||||
couplingGrowth(measured, baseline).map(({ id }) => id),
|
||||
['viewerLines'],
|
||||
);
|
||||
});
|
||||
Binary file not shown.
@@ -1,132 +0,0 @@
|
||||
import { PbfWriter } from 'pbf';
|
||||
|
||||
// Recorded journeys cover both a dense neighbourhood and the wider Austin view.
|
||||
export const cameras = [
|
||||
...Array.from({ length: 225 }, (_, i) => ({
|
||||
latitude: 30.262 + (Math.floor(i / 15) - 7) * 0.003,
|
||||
longitude: -97.7431 + ((i % 15) - 7) * 0.003,
|
||||
})),
|
||||
...Array.from({ length: 100 }, (_, i) => ({
|
||||
latitude: 30.308 + (Math.floor(i / 10) - 4.5) * 0.001,
|
||||
longitude: -97.77 + ((i % 10) - 4.5) * 0.001,
|
||||
})),
|
||||
].map((position, i) => ({
|
||||
...position,
|
||||
osmId: 9000000 + i,
|
||||
operator: i % 3 ? 'Deterministic fixture' : 'Cámara pública',
|
||||
brand: 'Fixture camera',
|
||||
cameraType: 'fixed',
|
||||
direction: i % 5 ? String((i * 37) % 360) : '',
|
||||
}));
|
||||
|
||||
export function cameraTile(z, x, y, records = cameras) {
|
||||
const extent = 4096;
|
||||
const scale = 2 ** z;
|
||||
const rows = records.flatMap((record) => {
|
||||
const latitude = (record.latitude * Math.PI) / 180;
|
||||
const tx = ((record.longitude + 180) / 360) * scale;
|
||||
const ty = ((1 - Math.asinh(Math.tan(latitude)) / Math.PI) / 2) * scale;
|
||||
if (Math.floor(tx) !== x || Math.floor(ty) !== y) return [];
|
||||
return [
|
||||
{
|
||||
record,
|
||||
x: Math.round((tx - x) * extent),
|
||||
y: Math.round((ty - y) * extent),
|
||||
},
|
||||
];
|
||||
});
|
||||
const keys = [
|
||||
'osmType',
|
||||
'osmId',
|
||||
'operator',
|
||||
'brand',
|
||||
'cameraType',
|
||||
'direction',
|
||||
];
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(
|
||||
3,
|
||||
(_, layer) => {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, 'cameras');
|
||||
layer.writeVarintField(5, extent);
|
||||
for (const key of keys) layer.writeStringField(3, key);
|
||||
rows.forEach(({ record, x, y }, index) => {
|
||||
for (const key of keys)
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(_, value) =>
|
||||
value.writeStringField(
|
||||
1,
|
||||
key === 'osmType' ? 'node' : String(record[key]),
|
||||
),
|
||||
null,
|
||||
);
|
||||
layer.writeMessage(
|
||||
2,
|
||||
(_, feature) => {
|
||||
feature.writeVarintField(1, record.osmId);
|
||||
feature.writePackedVarint(
|
||||
2,
|
||||
keys.flatMap((_, i) => [i, index * keys.length + i]),
|
||||
);
|
||||
feature.writeVarintField(3, 1);
|
||||
feature.writePackedVarint(4, [9, x * 2, y * 2]);
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
},
|
||||
null,
|
||||
);
|
||||
return Buffer.from(writer.finish());
|
||||
}
|
||||
|
||||
/** Real TileJSON/MVT transport; no replacement of the layer or source parser. */
|
||||
export async function installAlprFixture(page) {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (request.isInterceptResolutionHandled()) return;
|
||||
const url = new URL(request.url());
|
||||
// The journey also asks for nearby-place labels while moving. Pin that
|
||||
// auxiliary service; these runs do not test Google Places availability.
|
||||
if (
|
||||
['localhost', '127.0.0.1'].includes(url.hostname) &&
|
||||
url.pathname === '/api/google/nearby-places'
|
||||
) {
|
||||
void request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ places: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.hostname !== 'tiles.dontgetflocked.com') {
|
||||
void request.continue();
|
||||
return;
|
||||
}
|
||||
const headers = { 'access-control-allow-origin': '*' };
|
||||
if (url.pathname.endsWith('.json')) {
|
||||
const country = url.pathname.includes('-ca-') ? 'ca' : 'us';
|
||||
void request.respond({
|
||||
status: 200,
|
||||
headers,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
tiles: [
|
||||
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
|
||||
],
|
||||
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const match = url.pathname.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
|
||||
void request.respond({
|
||||
status: match ? 200 : 404,
|
||||
headers,
|
||||
contentType: 'application/x-protobuf',
|
||||
body: match ? cameraTile(...match.slice(1).map(Number)) : '',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Transport fixtures are parsed by the real CCTV catalog and media owners.
|
||||
export const cameras = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `fixture-cctv-${String(i).padStart(3, '0')}`,
|
||||
name: i === 12 ? 'Cámara pública — Congress' : `Fixture camera ${i}`,
|
||||
cityId: 'austin',
|
||||
city: 'Austin',
|
||||
lat: 30.2672 + (Math.floor(i / 5) - 2) * 0.0018,
|
||||
lon: -97.7431 + ((i % 5) - 2) * 0.0018,
|
||||
headingDeg: (i * 37) % 360,
|
||||
fovDeg: i % 2 ? 44 : 56,
|
||||
rangeM: i % 2 ? 145 : 210,
|
||||
mountHeightM: i % 2 ? 8 : 10,
|
||||
pitchDeg: i % 2 ? -18 : -24,
|
||||
groundElevationM: 150,
|
||||
headingConfidence: i % 3 ? 'medium' : 'low',
|
||||
sourceKind: 'configured',
|
||||
feedType: 'image',
|
||||
provider: 'Deterministic fixture',
|
||||
url: 'https://cctv-fixture.invalid/frame.png',
|
||||
}));
|
||||
|
||||
export async function cameraFrame() {
|
||||
const width = 320,
|
||||
height = 180;
|
||||
const pixels = Buffer.alloc(width * height * 3);
|
||||
const colors = [
|
||||
[240, 45, 70],
|
||||
[35, 220, 90],
|
||||
[40, 90, 235],
|
||||
[245, 210, 30],
|
||||
];
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const color =
|
||||
colors[(x >= width / 2 ? 1 : 0) + (y >= height / 2 ? 2 : 0)];
|
||||
const grid = x % 40 < 2 || y % 30 < 2;
|
||||
const at = (y * width + x) * 3;
|
||||
for (let c = 0; c < 3; c++) pixels[at + c] = grid ? 15 : color[c];
|
||||
}
|
||||
}
|
||||
return sharp(pixels, { raw: { width, height, channels: 3 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export async function installCctvFixture(page, { video } = {}) {
|
||||
const frame = await cameraFrame();
|
||||
const catalog = cameras.map((camera) => ({ ...camera }));
|
||||
if (video)
|
||||
catalog.push({
|
||||
...cameras[0],
|
||||
id: 'fixture-cctv-video',
|
||||
name: 'Fixture moving video',
|
||||
lat: 30.2718,
|
||||
lon: -97.744,
|
||||
feedType: 'video',
|
||||
url: 'https://cctv-fixture.invalid/camera.mp4',
|
||||
});
|
||||
const ids = new Set(catalog.map((camera) => camera.id));
|
||||
// Global Puppeteer interception can stall Cesium's module workers in
|
||||
// Chromium 152. Intercept only fixture API routes on the page's CDP session.
|
||||
const session = await page.createCDPSession();
|
||||
let disposed = false;
|
||||
const routes = [
|
||||
'/api/cctv/*',
|
||||
'/api/flights*',
|
||||
'/api/military*',
|
||||
'/api/vessels*',
|
||||
'/api/google/nearby-places*',
|
||||
];
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
|
||||
routes.map((route) => ({ urlPattern: `http://${host}:*${route}` })),
|
||||
),
|
||||
});
|
||||
const respond = (
|
||||
requestId,
|
||||
{ status, contentType, headers = {}, body = '' },
|
||||
) =>
|
||||
session.send('Fetch.fulfillRequest', {
|
||||
requestId,
|
||||
responseCode: status,
|
||||
responseHeaders: Object.entries({
|
||||
...(contentType ? { 'content-type': contentType } : {}),
|
||||
...headers,
|
||||
}).map(([name, value]) => ({ name, value: String(value) })),
|
||||
body: Buffer.from(body).toString('base64'),
|
||||
});
|
||||
const handle = (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||
return request.continue();
|
||||
}
|
||||
const json = (body) =>
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const auxiliary = {
|
||||
'/api/flights': { states: [] },
|
||||
'/api/military': { ac: [] },
|
||||
'/api/vessels': { connected: false, vessels: [] },
|
||||
};
|
||||
if (auxiliary[url.pathname]) {
|
||||
return json(auxiliary[url.pathname]);
|
||||
}
|
||||
if (url.pathname === '/api/cctv/sources') {
|
||||
return json({ sources: catalog });
|
||||
}
|
||||
if (url.pathname === '/api/cctv/health') {
|
||||
return json({
|
||||
cameras: catalog.map(({ id, feedType }) => ({
|
||||
id,
|
||||
status: 'online',
|
||||
sourceKind: feedType,
|
||||
label: 'Deterministic fixture',
|
||||
updatedAt: Date.now(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const media = url.pathname.match(/^\/api\/cctv\/(frame|media)\/([^/]+)$/);
|
||||
if (media) {
|
||||
const id = decodeURIComponent(media[2]);
|
||||
if (
|
||||
!ids.has(id) ||
|
||||
(media[1] === 'media' && (id !== 'fixture-cctv-video' || !video))
|
||||
) {
|
||||
return request.respond({ status: 404, body: 'Unknown fixture camera' });
|
||||
}
|
||||
if (media[1] === 'frame') {
|
||||
return request.respond({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: frame,
|
||||
});
|
||||
}
|
||||
const range = request.headers().range?.match(/^bytes=(\d+)-(\d*)$/);
|
||||
const start = range ? Number(range[1]) : 0;
|
||||
const end =
|
||||
range && range[2]
|
||||
? Math.min(Number(range[2]), video.length - 1)
|
||||
: video.length - 1;
|
||||
if (start > end || start >= video.length) {
|
||||
return request.respond({
|
||||
status: 416,
|
||||
headers: { 'content-range': `bytes */${video.length}` },
|
||||
});
|
||||
}
|
||||
return request.respond({
|
||||
status: range ? 206 : 200,
|
||||
contentType: 'video/mp4',
|
||||
headers: {
|
||||
'accept-ranges': 'bytes',
|
||||
...(range
|
||||
? { 'content-range': `bytes ${start}-${end}/${video.length}` }
|
||||
: {}),
|
||||
},
|
||||
body: video.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/google/nearby-places') {
|
||||
return json({ places: [] });
|
||||
}
|
||||
return request.continue();
|
||||
};
|
||||
session.on('Fetch.requestPaused', (event) => {
|
||||
const request = {
|
||||
url: () => event.request.url,
|
||||
headers: () =>
|
||||
Object.fromEntries(
|
||||
Object.entries(event.request.headers).map(([name, value]) => [
|
||||
name.toLowerCase(),
|
||||
value,
|
||||
]),
|
||||
),
|
||||
respond: (response) => respond(event.requestId, response),
|
||||
continue: () =>
|
||||
session.send('Fetch.continueRequest', { requestId: event.requestId }),
|
||||
};
|
||||
Promise.resolve()
|
||||
.then(() => handle(request))
|
||||
.catch((error) => {
|
||||
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
|
||||
});
|
||||
});
|
||||
return {
|
||||
cameras: catalog,
|
||||
frame,
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
await session.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
const ORIGIN = { lat: 42.3554, lon: -71.0605 };
|
||||
const grid = (i, cols, spacing) => ({
|
||||
lat: ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing,
|
||||
lon: ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
|
||||
});
|
||||
const bikes = Array.from({ length: 200 }, (_, i) => ({
|
||||
station_id: `fixture-bike-${i}`,
|
||||
name: `Fixture station ${i}`,
|
||||
capacity: 20,
|
||||
...grid(i, 15, 0.0015),
|
||||
}));
|
||||
export const combinedPayloads = { bikes };
|
||||
|
||||
/** Bikeshare and startup feed fixtures shared by both engines. */
|
||||
export async function installCombinedFixture(page) {
|
||||
const requests = [];
|
||||
const session = await page.createCDPSession();
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: ['127.0.0.1', 'localhost'].map((host) => ({
|
||||
urlPattern: `http://${host}:*/api/*`,
|
||||
})),
|
||||
});
|
||||
let disposed = false;
|
||||
session.on('Fetch.requestPaused', async (event) => {
|
||||
try {
|
||||
const u = new URL(event.request.url);
|
||||
let body;
|
||||
const p = u.pathname;
|
||||
if (p.startsWith('/api/gbfs/'))
|
||||
body = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: decodeURIComponent(u.href).includes('station_information')
|
||||
? bikes
|
||||
: bikes.map(({ station_id }) => ({
|
||||
station_id,
|
||||
num_bikes_available: 7,
|
||||
num_docks_available: 13,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
else if (p === '/api/flights') body = { states: [] };
|
||||
else if (p === '/api/military') body = { ac: [] };
|
||||
else if (p === '/api/vessels') body = { connected: false, vessels: [] };
|
||||
else if (p === '/api/google/nearby-places') body = { places: [] };
|
||||
else if (p === '/api/setup/status') body = { ready: true };
|
||||
else if (p === '/api/terrain/heights')
|
||||
body = {
|
||||
results: (u.searchParams.get('points') || '')
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
.map((point) => {
|
||||
const [lon, lat] = point.split(',').map(Number);
|
||||
return { lon, lat, elevation: 20, geoid: 0, ellipsoid: 20 };
|
||||
}),
|
||||
};
|
||||
if (body === undefined) {
|
||||
await session.send('Fetch.continueRequest', {
|
||||
requestId: event.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
requests.push(p);
|
||||
await session.send('Fetch.fulfillRequest', {
|
||||
requestId: event.requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [
|
||||
{ name: 'Content-Type', value: 'application/json' },
|
||||
{ name: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ name: 'Access-Control-Expose-Headers', value: 'Data-Present' },
|
||||
{ name: 'Data-Present', value: 'true' },
|
||||
],
|
||||
body: Buffer.from(JSON.stringify(body)).toString('base64'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!disposed &&
|
||||
!page.isClosed() &&
|
||||
!String(error.message).includes('Invalid InterceptionId')
|
||||
)
|
||||
page.emit('pageerror', error);
|
||||
}
|
||||
});
|
||||
return {
|
||||
requests,
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
await session.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Real /api/firms envelope; fixed source receipt and acquisition clocks.
|
||||
export const FIRMS_EPOCH_MS = Date.UTC(2026, 9, 16, 12);
|
||||
export const FIRMS_VALID_COUNT = 371;
|
||||
|
||||
export function firmsFixture() {
|
||||
const fires = [];
|
||||
const regions = [
|
||||
[37.77, -122.42],
|
||||
[30.27, -97.74],
|
||||
[61.2, -149.9],
|
||||
];
|
||||
for (const [region, [latitude, longitude]] of regions.entries()) {
|
||||
for (let row = -5; row <= 5; row++) {
|
||||
for (let column = -5; column <= 5; column++) {
|
||||
const index = fires.length;
|
||||
fires.push({
|
||||
lat: latitude + row * 0.12,
|
||||
lon: longitude + column * 0.12,
|
||||
frp:
|
||||
region === 0 && row === 0 && column === 0 ? 999 : 5 + (index % 120),
|
||||
confidence: ['low', 'nominal', 'high', 85][index % 4],
|
||||
brightness: 280 + (index % 90),
|
||||
daynight: index % 2 ? 'N' : 'D',
|
||||
acqDate: '2026-10-16',
|
||||
acqTime: String(800 + (index % 60)),
|
||||
instrument: index % 4 === 3 ? 'MODIS' : 'VIIRS',
|
||||
satellite: index % 4 === 3 ? 'Terra' : 'NOAA-20',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [lat, lon] of [
|
||||
[10, 179.7],
|
||||
[10, -179.7],
|
||||
[89.5, 45],
|
||||
[-89.5, -135],
|
||||
[0.2, 179.8],
|
||||
[-0.2, -179.8],
|
||||
[70, 179.5],
|
||||
[-70, -179.5],
|
||||
]) {
|
||||
fires.push({
|
||||
lat,
|
||||
lon,
|
||||
frp: 30,
|
||||
confidence: 'high',
|
||||
brightness: 320,
|
||||
daynight: 'D',
|
||||
acqDate: '2026-10-16',
|
||||
acqTime: '930',
|
||||
instrument: 'VIIRS',
|
||||
satellite: 'NOAA-20',
|
||||
});
|
||||
}
|
||||
fires.push({ lat: 'invalid', lon: 0 }, { lat: 0, lon: 'invalid' });
|
||||
return { fires, fetchedAt: FIRMS_EPOCH_MS - 120000, stale: false };
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// Shared 750-station directory for legacy and both-engine radio QA.
|
||||
const tags = [
|
||||
'news',
|
||||
'talk',
|
||||
'weather,emergency',
|
||||
'public safety,scanner',
|
||||
'aviation,atc',
|
||||
'marine,maritime',
|
||||
'traffic,transit',
|
||||
'music,jazz',
|
||||
'music,rock',
|
||||
'community',
|
||||
];
|
||||
export const stations = Array.from({ length: 750 }, (_, index) => {
|
||||
const row = Math.floor(index / 30);
|
||||
const column = index % 30;
|
||||
return {
|
||||
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
|
||||
name:
|
||||
index === 360
|
||||
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
|
||||
: index === 390
|
||||
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
|
||||
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
|
||||
lat: -72 + row * 6,
|
||||
lon: -174 + column * 12,
|
||||
streamUrl: `https://audio.example.test/${index}.mp3`,
|
||||
homepage: `https://station.example.test/${index}`,
|
||||
tags: tags[index % tags.length].split(','),
|
||||
languages: index === 360 ? ['Spanish'] : ['English'],
|
||||
state: `Region ${row + 1}`,
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: index % 2 ? 'AAC' : 'MP3',
|
||||
bitrate: 128,
|
||||
};
|
||||
});
|
||||
|
||||
export async function installRadioFixture(page) {
|
||||
await page.evaluateOnNewDocument((stations) => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__qaRadio = {
|
||||
stations,
|
||||
generation: 1,
|
||||
plays: [],
|
||||
delay: 0,
|
||||
response: 'normal',
|
||||
pending: null,
|
||||
};
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
window.qaPlayer = this;
|
||||
window.__qaRadio.plays.push(this.src);
|
||||
if (window.__qaRadio.delayPlay) {
|
||||
window.__qaRadio.delayPlay = false;
|
||||
return new Promise((resolve) => {
|
||||
window.__qaRadio.releasePlay = () => {
|
||||
this.dispatchEvent(new Event('playing'));
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
queueMicrotask(() => this.dispatchEvent(new Event('playing')));
|
||||
return Promise.resolve();
|
||||
};
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const address = String(
|
||||
typeof input === 'string' || input instanceof URL ? input : input.url,
|
||||
);
|
||||
if (address.includes('/api/radio/stations')) {
|
||||
const qa = window.__qaRadio;
|
||||
qa.reads = (qa.reads || 0) + 1;
|
||||
const response = {
|
||||
stations: qa.response === 'empty' ? [] : qa.stations,
|
||||
updatedAt: new Date().toISOString(),
|
||||
stale: false,
|
||||
degraded: false,
|
||||
acceptedGeneration: qa.generation,
|
||||
catalogInstance: 'fixture-radio-fixture',
|
||||
};
|
||||
const mode = qa.response;
|
||||
const delay = qa.delay;
|
||||
qa.delay = 0;
|
||||
if (delay)
|
||||
await new Promise((resolve) => {
|
||||
qa.pending = resolve;
|
||||
});
|
||||
init?.signal?.throwIfAborted();
|
||||
return new Response(
|
||||
mode === 'failure' ? '{}' : JSON.stringify(response),
|
||||
{
|
||||
status: mode === 'failure' ? 503 : 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (address.includes('/api/radio/click/'))
|
||||
return new Response(null, { status: 204 });
|
||||
if (address.includes('/api/flights'))
|
||||
return new Response('{"states":[]}');
|
||||
if (address.includes('/api/military')) return new Response('{"ac":[]}');
|
||||
if (address.includes('/api/vessels'))
|
||||
return new Response('{"connected":false,"vessels":[]}');
|
||||
return fetch(input, init);
|
||||
};
|
||||
}, stations);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
export const TRANSIT_EPOCH = 1_793_232_000_000;
|
||||
const routes = ['1', 'Green-B', 'Red', 'CR-Fitchburg', 'Boat-F1', ''];
|
||||
export function transitSnapshot(step = 8) {
|
||||
const fetchedAt = TRANSIT_EPOCH - 135000 + step * 15000;
|
||||
return {
|
||||
fetchedAt,
|
||||
vehicles: [
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `fixture-${i}`,
|
||||
routeId: routes[i % 6],
|
||||
tripId: `trip-${i}`,
|
||||
lat:
|
||||
42.355 +
|
||||
(Math.floor(i / 4) - 1) * 0.0017 +
|
||||
(i < 6 ? step * 0.00012 : 0),
|
||||
lon: -71.065 + ((i % 4) - 1.5) * 0.0017,
|
||||
timestamp: fetchedAt / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
})),
|
||||
{
|
||||
id: 'stale',
|
||||
routeId: '1',
|
||||
tripId: 'old',
|
||||
lat: 42.355,
|
||||
lon: -71.065,
|
||||
timestamp: (TRANSIT_EPOCH - 900000) / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
export async function installTransitFixture(page) {
|
||||
let step = 0,
|
||||
disposed = false;
|
||||
const requests = [];
|
||||
const session = await page.createCDPSession();
|
||||
await page.evaluateOnNewDocument((epoch) => {
|
||||
Date.now = () => epoch + performance.now();
|
||||
}, TRANSIT_EPOCH);
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
|
||||
[
|
||||
'/api/transit/*',
|
||||
'/api/flights*',
|
||||
'/api/military*',
|
||||
'/api/vessels*',
|
||||
].map((route) => ({ urlPattern: `http://${host}:*${route}` })),
|
||||
),
|
||||
});
|
||||
session.on('Fetch.requestPaused', async (event) => {
|
||||
try {
|
||||
const url = new URL(event.request.url);
|
||||
requests.push(url.pathname);
|
||||
let body;
|
||||
if (url.pathname.startsWith('/api/transit/vehicles/'))
|
||||
body = url.pathname.endsWith('/mbta')
|
||||
? transitSnapshot(step)
|
||||
: { fetchedAt: TRANSIT_EPOCH, vehicles: [] };
|
||||
else if (url.pathname.startsWith('/api/transit/trail/')) {
|
||||
const vehicleId = decodeURIComponent(url.pathname.split('/').at(-1));
|
||||
const record = transitSnapshot().vehicles.find(
|
||||
(row) => row.id === vehicleId,
|
||||
);
|
||||
body = {
|
||||
version: 1,
|
||||
feedId: 'mbta',
|
||||
vehicleId,
|
||||
truncated: false,
|
||||
epochs: [
|
||||
{ id: 1, trip: record.tripId, route: record.routeId, mode: 'bus' },
|
||||
],
|
||||
fixes: Array.from({ length: 9 }, (_, i) => {
|
||||
const snapshot = transitSnapshot(i),
|
||||
row = snapshot.vehicles.find((v) => v.id === vehicleId);
|
||||
return [snapshot.fetchedAt, row.lat, row.lon, 0, 1];
|
||||
}),
|
||||
};
|
||||
} else body = { states: [], ac: [], vessels: [], feeds: [] };
|
||||
await session.send('Fetch.fulfillRequest', {
|
||||
requestId: event.requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
body: Buffer.from(JSON.stringify(body)).toString('base64'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
|
||||
}
|
||||
});
|
||||
return {
|
||||
requests,
|
||||
setStep(value) {
|
||||
step = value;
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
await session.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
// Wildlife Sightings without the network: the app's own iNaturalist and
|
||||
// Acartia proxies (server/providers) answer the preview's /api routes in
|
||||
// this process, over upstream feeds faked here. Four sightings over the
|
||||
// Salish Sea: three iNaturalist observations (one with an obscured
|
||||
// location, drawn as its privacy cell) and one Acartia orca report.
|
||||
import { installInatProxy } from '../../server/providers/inaturalist/proxy.fixture.mjs';
|
||||
import { syntheticGrid } from '../../server/providers/inaturalist/grid.fixture.mjs';
|
||||
import { acartiaProxy } from '../../server/providers/acartia.js';
|
||||
|
||||
/** The view the scenario frames (straight down). */
|
||||
export const WILDLIFE_VIEW = Object.freeze({
|
||||
lat: 48.4,
|
||||
lon: -123.1,
|
||||
alt: 200000,
|
||||
});
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
// iNaturalist iconic taxon ids, as the UTFGrid carries them.
|
||||
const ICONIC_IDS = { Aves: 3, Mammalia: 40151, Plantae: 47126 };
|
||||
|
||||
/**
|
||||
* The sightings as the browser draws them (`lat`/`lon` after the proxy's
|
||||
* projection: an obscured record sits at the centre of its 0.2° cell, an
|
||||
* Acartia report is rounded to 0.01°), with what the upstream feeds send.
|
||||
*/
|
||||
export const WILDLIFE_SIGHTINGS = Object.freeze([
|
||||
{
|
||||
id: 'inat:910001',
|
||||
name: 'Bald Eagle',
|
||||
scientific: 'Haliaeetus leucocephalus',
|
||||
iconic: 'Aves',
|
||||
raw: [48.4, -123.1],
|
||||
lat: 48.4,
|
||||
lon: -123.1,
|
||||
},
|
||||
{
|
||||
id: 'inat:910002',
|
||||
name: "Douglas's Squirrel",
|
||||
scientific: 'Tamiasciurus douglasii',
|
||||
iconic: 'Mammalia',
|
||||
raw: [48.46, -122.92],
|
||||
lat: 48.46,
|
||||
lon: -122.92,
|
||||
},
|
||||
{
|
||||
id: 'inat:910003',
|
||||
name: 'Western Redcedar',
|
||||
scientific: 'Thuja plicata',
|
||||
iconic: 'Plantae',
|
||||
obscured: true,
|
||||
raw: [48.31, -123.29],
|
||||
lat: 48.3,
|
||||
lon: -123.3,
|
||||
cell: { south: 48.2, west: -123.4, north: 48.4, east: -123.2 },
|
||||
},
|
||||
{
|
||||
id: 'acartia:qa-orca-1',
|
||||
name: 'Orca',
|
||||
origin: 'acartia',
|
||||
raw: [48.5234, -123.2167],
|
||||
lat: 48.52,
|
||||
lon: -123.22,
|
||||
},
|
||||
]);
|
||||
|
||||
const inat = WILDLIFE_SIGHTINGS.filter((s) => s.id.startsWith('inat:'));
|
||||
const acartia = WILDLIFE_SIGHTINGS.filter((s) => s.origin === 'acartia');
|
||||
|
||||
/** An API v2 observation, with the fields the proxy asks for. */
|
||||
function observation(sighting, nowMs) {
|
||||
const id = Number(sighting.id.slice(5));
|
||||
const [lat, lon] = sighting.raw;
|
||||
const time = new Date(nowMs - 3 * HOUR_MS);
|
||||
return {
|
||||
id,
|
||||
uri: `https://www.inaturalist.org/observations/${id}`,
|
||||
location: `${lat},${lon}`,
|
||||
geojson: { type: 'Point', coordinates: [lon, lat] },
|
||||
geoprivacy: sighting.obscured ? 'obscured' : null,
|
||||
taxon_geoprivacy: null,
|
||||
obscured: sighting.obscured === true,
|
||||
quality_grade: 'research',
|
||||
observed_on: time.toISOString().slice(0, 10),
|
||||
time_observed_at: time.toISOString(),
|
||||
faves_count: 1,
|
||||
num_identification_agreements: 2,
|
||||
// The feed asks for observations with photos; these carry no open
|
||||
// licence, so no thumbnail is fetched.
|
||||
photos: [
|
||||
{
|
||||
url: `https://inaturalist-open-data.s3.amazonaws.com/photos/${id}/square.jpeg`,
|
||||
attribution: '(c) QA observer, all rights reserved',
|
||||
license_code: null,
|
||||
},
|
||||
],
|
||||
taxon: {
|
||||
name: sighting.scientific,
|
||||
preferred_common_name: sighting.name,
|
||||
iconic_taxon_name: sighting.iconic,
|
||||
threatened: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Web Mercator tile coordinates of a point at zoom z (fractional). */
|
||||
function tileOf(lat, lon, z) {
|
||||
const count = 2 ** z;
|
||||
const sin = Math.sin((lat * Math.PI) / 180);
|
||||
return {
|
||||
x: ((lon + 180) / 360) * count,
|
||||
y: (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * count,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The faked upstream feeds: iNaturalist observations (filtered by the
|
||||
* request's box), its UTFGrid density tiles, and the Acartia snapshot.
|
||||
* `calls` counts what each upstream was asked.
|
||||
*/
|
||||
export function wildlifeUpstream({ now = () => Date.now() } = {}) {
|
||||
const calls = { observations: 0, grid: 0, acartia: 0 };
|
||||
async function fetchImpl(href) {
|
||||
const url = new URL(href);
|
||||
if (url.host === 'acartia.io') {
|
||||
calls.acartia++;
|
||||
const created = new Date(now() - 2 * HOUR_MS)
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace('T', ' ');
|
||||
return Response.json(
|
||||
acartia.map((s) => ({
|
||||
entry_id: s.id.slice(8),
|
||||
latitude: s.raw[0],
|
||||
longitude: s.raw[1],
|
||||
created,
|
||||
type: s.name,
|
||||
no_sighted: '3',
|
||||
data_source_name: 'QA Whale Network',
|
||||
data_source_entity: 'Community reports',
|
||||
trusted: true,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (url.host !== 'api.inaturalist.org')
|
||||
throw Error('unexpected upstream ' + url.host);
|
||||
const grid = /^\/v1\/grid\/(\d+)\/(\d+)\/(\d+)\.grid\.json$/.exec(
|
||||
url.pathname,
|
||||
);
|
||||
if (grid) {
|
||||
calls.grid++;
|
||||
const [z, x, y] = grid.slice(1).map(Number);
|
||||
const cells = [];
|
||||
for (const s of inat) {
|
||||
const at = tileOf(s.raw[0], s.raw[1], z);
|
||||
if (Math.floor(at.x) !== x || Math.floor(at.y) !== y) continue;
|
||||
cells.push({
|
||||
row: Math.min(63, Math.floor((at.y - y) * 64)),
|
||||
column: Math.min(63, Math.floor((at.x - x) * 64)),
|
||||
count: 4,
|
||||
iconic: ICONIC_IDS[s.iconic],
|
||||
});
|
||||
}
|
||||
return Response.json(syntheticGrid({ z, x, y, cells }));
|
||||
}
|
||||
if (url.pathname === '/v2/observations') {
|
||||
calls.observations++;
|
||||
const box = ['swlat', 'swlng', 'nelat', 'nelng'].map((key) =>
|
||||
url.searchParams.has(key) ? Number(url.searchParams.get(key)) : null,
|
||||
);
|
||||
const inside = ({ raw: [lat, lon] }) =>
|
||||
box[0] === null ||
|
||||
(lat >= box[0] && lat <= box[2] && lon >= box[1] && lon <= box[3]);
|
||||
const results = inat.filter(inside).map((s) => observation(s, now()));
|
||||
return Response.json({
|
||||
total_results: results.length,
|
||||
page: 1,
|
||||
per_page: 200,
|
||||
results,
|
||||
});
|
||||
}
|
||||
throw Error('unexpected upstream path ' + url.pathname);
|
||||
}
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
/** Mount an /api/acartia proxy over synthetic HTTP objects. */
|
||||
function installAcartiaProxy(options) {
|
||||
let handler;
|
||||
acartiaProxy({ warn() {}, ...options }).configurePreviewServer({
|
||||
middlewares: {
|
||||
use(mount, callback) {
|
||||
if (mount !== '/api/acartia') throw Error('acartia mount ' + mount);
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (url, { headers = {} } = {}) => {
|
||||
const res = {
|
||||
headers: {},
|
||||
writeHead(status, values) {
|
||||
this.status = status;
|
||||
Object.assign(this.headers, values);
|
||||
},
|
||||
setHeader(key, value) {
|
||||
this.headers[key] = value;
|
||||
},
|
||||
end(value) {
|
||||
this.status ??= this.statusCode;
|
||||
this.body = JSON.parse(value);
|
||||
},
|
||||
};
|
||||
await handler(
|
||||
{ url, method: 'GET', headers, socket: { remoteAddress: '127.0.0.1' } },
|
||||
res,
|
||||
);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict-harness routes for the preview's wildlife API: each request is
|
||||
* answered by the app's own proxy in this process (it never reaches the
|
||||
* preview, so the preview never calls the real providers). `asked` counts
|
||||
* the browser's requests per route.
|
||||
*/
|
||||
export function wildlifeRoutes(origin, options = {}) {
|
||||
const upstream = wildlifeUpstream(options);
|
||||
const proxies = {
|
||||
'/api/inaturalist': installInatProxy('/observations', {
|
||||
fetchImpl: upstream.fetchImpl,
|
||||
now: options.now ?? (() => Date.now()),
|
||||
}),
|
||||
'/api/acartia': installAcartiaProxy({
|
||||
fetchImpl: upstream.fetchImpl,
|
||||
now: options.now ?? (() => Date.now()),
|
||||
}),
|
||||
};
|
||||
const asked = { observations: 0, grid: 0, acartia: 0 };
|
||||
const route = (mount) => ({
|
||||
pattern: `${origin}${mount}/*`,
|
||||
async respond(request) {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname.slice(mount.length);
|
||||
asked[mount === '/api/acartia' ? 'acartia' : path.slice(1)]++;
|
||||
const headers = { host: url.host };
|
||||
for (const [name, value] of Object.entries(request.headers ?? {}))
|
||||
headers[name.toLowerCase()] = value;
|
||||
const res = await proxies[mount](path + url.search, { headers });
|
||||
return {
|
||||
status: res.status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(res.body),
|
||||
};
|
||||
},
|
||||
});
|
||||
return {
|
||||
routes: [route('/api/inaturalist'), route('/api/acartia')],
|
||||
asked,
|
||||
upstream: upstream.calls,
|
||||
};
|
||||
}
|
||||
+43
-369
@@ -1,7 +1,4 @@
|
||||
[
|
||||
"src/data/sceneServices.js",
|
||||
"src/data/sceneServices.test.mjs",
|
||||
"src/data/trackingGesture.js",
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
@@ -59,11 +56,11 @@
|
||||
"src/app/application.test.mjs",
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/globe/cesium/viewer.js",
|
||||
"src/globe/cesium/zoomOutGuard.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/zoomOutGuard.test.mjs",
|
||||
"src/app/controls.test.mjs",
|
||||
"scripts/qa-zoom-out-guard.mjs",
|
||||
"src/globe/cesium/atmosphereCompat.test.mjs",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.js",
|
||||
"src/standalone/data.js",
|
||||
@@ -115,6 +112,8 @@
|
||||
"src/data/terrainHeightsProxy.test.mjs",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tomtomTiles.test.mjs",
|
||||
"src/data/tileBudget.js",
|
||||
"src/data/tileBudget.test.mjs",
|
||||
"src/data/firmsCsv.js",
|
||||
"src/data/firmsCsv.test.mjs",
|
||||
"src/tooling/environmentProviders.test.mjs",
|
||||
@@ -331,6 +330,12 @@
|
||||
"src/ui/styles/responsive.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/cockpitOrbital.css",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/layers/satellites/sensors.test.mjs",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/ui/styles/command-dock-compact.css",
|
||||
"src/app/stateChannel.js",
|
||||
"src/app/stateChannel.test.mjs",
|
||||
@@ -372,10 +377,8 @@
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/aircraft/index.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/aircraft/tracking.js",
|
||||
"src/layers/military/ownership.test.mjs",
|
||||
"src/layers/aircraft/classification.test.mjs",
|
||||
"src/layers/aircraft/tracking.test.mjs",
|
||||
"src/data/aisLiveVessels.js",
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
@@ -481,14 +484,17 @@
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/launches/failedEntry.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
"src/layers/installations/source.js",
|
||||
"src/layers/installations/state.js",
|
||||
"src/layers/installations/policy.js",
|
||||
"src/layers/installations/rendering.js",
|
||||
"src/layers/installations/ingestion.js",
|
||||
"src/layers/installations/index.js",
|
||||
"src/layers/installations/model.js",
|
||||
"src/layers/installations/selection.js",
|
||||
"src/layers/awareness/focus.js",
|
||||
"src/layers/awareness/lifecycle.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
@@ -524,17 +530,20 @@
|
||||
"src/layers/traffic/model.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/viewport.js",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/bikeshare/rendering.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/data/traffic.js",
|
||||
"src/data/bikeshare.js",
|
||||
"src/layers/traffic/source.test.mjs",
|
||||
@@ -582,6 +591,7 @@
|
||||
"src/data/cctvHk.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvA22.test.mjs",
|
||||
"src/data/cctvIceland.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvHouston.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
@@ -595,9 +605,9 @@
|
||||
"src/layers/alpr/index.js",
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/overlay.test.mjs",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/layers/alpr/source.test.mjs",
|
||||
@@ -628,8 +638,13 @@
|
||||
"src/layers/submarineCables/bundledSource.js",
|
||||
"src/layers/submarineCables/geometry.js",
|
||||
"src/layers/submarineCables/index.js",
|
||||
"src/layers/submarineCables/ingestion.js",
|
||||
"src/layers/submarineCables/interaction.js",
|
||||
"src/layers/submarineCables/lifecycle.js",
|
||||
"src/layers/submarineCables/overlay.js",
|
||||
"src/layers/submarineCables/policy.js",
|
||||
"src/layers/submarineCables/rendering.js",
|
||||
"src/layers/submarineCables/state.js",
|
||||
"src/layers/submarineCables/surface.js",
|
||||
"src/data/telegeographySubmarineCables.js",
|
||||
"src/data/telegeographySubmarineCables.test.mjs",
|
||||
@@ -645,9 +660,9 @@
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/globe/cesium/mapImagery.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/globe/cesium/terrain.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
"src/maps/terrainRetry.test.mjs",
|
||||
"src/mapStackController.js",
|
||||
@@ -811,6 +826,8 @@
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/contactTrailRenderer.test.mjs",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/data/contactPlayback.test.mjs",
|
||||
"src/layers/transit/movement.js",
|
||||
@@ -837,6 +854,7 @@
|
||||
"src/layers/weather/index.js",
|
||||
"src/layers/weather/clock.js",
|
||||
"src/layers/weather/clock.test.mjs",
|
||||
"src/layers/weather/rendering.js",
|
||||
"src/layers/weather/shellRendering.js",
|
||||
"src/layers/weather/shellRendering.test.mjs",
|
||||
"src/layers/weather/infraredAlpha.js",
|
||||
@@ -845,6 +863,7 @@
|
||||
"src/layers/weather/infraredImage.test.mjs",
|
||||
"src/layers/weather/source.js",
|
||||
"src/layers/weather/weather.test.mjs",
|
||||
"src/layers/weather/rasterTiles.test.mjs",
|
||||
"src/layers/wind/rendering.test.mjs",
|
||||
"src/layers/weather/source.test.mjs",
|
||||
"src/data/weatherProxy.test.mjs",
|
||||
@@ -870,6 +889,8 @@
|
||||
"src/ui/railCards.test.mjs",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/railTimeline.test.mjs",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryOrder.test.mjs",
|
||||
"server/providers/cyclones.js",
|
||||
"server/providers/firePerimeters.js",
|
||||
"server/providers/droneAirspace.js",
|
||||
@@ -902,6 +923,7 @@
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js",
|
||||
"src/layers/cyclones/rendering.test.mjs",
|
||||
"src/layers/cyclones/labels.test.mjs",
|
||||
"src/layers/cyclones/index.test.mjs",
|
||||
"src/layers/cyclones/source.test.mjs",
|
||||
@@ -910,6 +932,7 @@
|
||||
"scripts/qa-weather-journey.mjs",
|
||||
"scripts/qa-weather-swap.mjs",
|
||||
"scripts/qa-weather-teardown.mjs",
|
||||
"src/layers/weather/imageryHost.test.mjs",
|
||||
"src/layers/weather/display.test.mjs",
|
||||
"src/sdr/adsbDecoder.test.mjs",
|
||||
"src/sdr/controller.test.mjs",
|
||||
@@ -934,6 +957,8 @@
|
||||
"src/data/modelEligibility.test.mjs",
|
||||
"src/layers/recentImagery/model.test.mjs",
|
||||
"src/layers/recentImagery/catalog.test.mjs",
|
||||
"src/maps/imageryHost.test.mjs",
|
||||
"src/layers/recentImagery/rendering.test.mjs",
|
||||
"src/layers/recentImagery/thumbnails.test.mjs",
|
||||
"src/layers/recentImagery/index.test.mjs",
|
||||
"src/ui/imageryBoxTool.test.mjs",
|
||||
@@ -1052,7 +1077,6 @@
|
||||
"src/layers/traffic/roadModes.test.mjs",
|
||||
"scripts/qa-traffic-motion.mjs",
|
||||
"src/layers/traffic/motion.test.mjs",
|
||||
"src/layers/traffic/handoff.test.mjs",
|
||||
"scripts/qa-journey-recorder.mjs",
|
||||
"scripts/qa-alpr-journey.mjs",
|
||||
"scripts/qa-context-arrivals.mjs",
|
||||
@@ -1101,9 +1125,10 @@
|
||||
"scripts/build-bright-stars.mjs",
|
||||
"scripts/build-milky-way.mjs",
|
||||
"src/sky/stars.test.mjs",
|
||||
"src/globe/cesium/starrySky.test.mjs",
|
||||
"src/sky/starrySky.test.mjs",
|
||||
"src/tooling/skyAssets.test.mjs",
|
||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||
"src/tooling/responseCompression.test.mjs",
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
@@ -1201,198 +1226,6 @@
|
||||
"src/data/headingDegrees.test.mjs",
|
||||
"src/data/distanceFormat.test.mjs",
|
||||
"src/sources/adsbFields.test.mjs",
|
||||
"src/layers/flights/render/display.js",
|
||||
"src/layers/aircraft/render/display.test.mjs",
|
||||
"src/layers/aircraft/render/display.js",
|
||||
"src/layers/flights/renderer.test.mjs",
|
||||
"src/layers/military/renderer.test.mjs",
|
||||
"src/layers/military/policy.test.mjs",
|
||||
"src/testSupport/aircraftRecordingRenderer.mjs",
|
||||
"src/testSupport/cesiumMoversEngine.mjs",
|
||||
"src/testSupport/cockpitEngine.mjs",
|
||||
"src/layers/aircraft/render/contract.js",
|
||||
"src/layers/shared/color.test.mjs",
|
||||
"src/layers/shared/selectionKeeper.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/layers/shared/viewRectangle.js",
|
||||
"src/layers/military/render/display.js",
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/app/navaraLayers.test.mjs",
|
||||
"src/layers/vessels/icons.js",
|
||||
"src/layers/vessels/render/contract.js",
|
||||
"src/layers/vessels/renderer.test.mjs",
|
||||
"src/layers/vessels/testFixtures.mjs",
|
||||
"src/layers/shared/render/engineView.js",
|
||||
"src/layers/shared/render/moverBank.js",
|
||||
"src/layers/shared/render/imageAtlas.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/bikeshare/presentation.test.mjs",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/cctv/projectionScheduling.test.mjs",
|
||||
"src/layers/cctv/lifecycle.test.mjs",
|
||||
"src/layers/cctv/projectionMedia.test.mjs",
|
||||
"src/layers/cctv/ingestion.test.mjs",
|
||||
"scripts/fixtures/layers/alpr.mjs",
|
||||
"scripts/fixtures/layers/cctv.mjs",
|
||||
"src/layers/radio/screenClusters.js",
|
||||
"src/layers/radio/screenClusters.test.mjs",
|
||||
"src/globe/cesium/mapScene.js",
|
||||
"src/globe/cesium/mapScene.test.mjs",
|
||||
"src/globe/contract.test.mjs",
|
||||
"src/globe/testing/fakeEngine.test.mjs",
|
||||
"src/globe/cesium/events.test.mjs",
|
||||
"src/globe/cesium/camera.test.mjs",
|
||||
"src/globe/cesium/projector.test.mjs",
|
||||
"src/globe/cesium/picker.test.mjs",
|
||||
"src/globe/cesium/frames.test.mjs",
|
||||
"src/globe/cesium/render.test.mjs",
|
||||
"src/globe/cesium/surface.test.mjs",
|
||||
"src/globe/cesium/input.test.mjs",
|
||||
"src/globe/cesium/credits.test.mjs",
|
||||
"src/globe/cesium/maps.test.mjs",
|
||||
"src/globe/cesium/createCesiumEngine.test.mjs",
|
||||
"src/globe/cesium/movers.test.mjs",
|
||||
"src/globe/cesium/lines.test.mjs",
|
||||
"src/globe/cesium/testing/fakeViewer.mjs",
|
||||
"src/globe/cesium/testing/cameraViewer.mjs",
|
||||
"src/app/globeEngine.js",
|
||||
"src/app/globeEngine.test.mjs",
|
||||
"scripts/fixtures/layers/transit.mjs",
|
||||
"src/layers/localAdsb/render/display.js",
|
||||
"src/layers/localAdsb/renderer.test.mjs",
|
||||
"build/navaraAssets.js",
|
||||
"scripts/qa-engine-switch.mjs",
|
||||
"scripts/qa/tileCache.mjs",
|
||||
"src/annotations/resolverEngine.test.mjs",
|
||||
"src/voice/gevActionsEngine.test.mjs",
|
||||
"src/app/engineChoice.js",
|
||||
"src/app/engineChoice.test.mjs",
|
||||
"src/app/navaraMode.js",
|
||||
"src/app/navaraScene.test.mjs",
|
||||
"src/app/viewerlessTools.test.mjs",
|
||||
"src/cameraAngles.js",
|
||||
"src/cameraAngles.test.mjs",
|
||||
"src/data/cameraArrival.test.mjs",
|
||||
"src/data/cameraSensitivity.test.mjs",
|
||||
"src/globe/cesium/camera.js",
|
||||
"src/globe/cesium/convert.js",
|
||||
"src/globe/cesium/createCesiumEngine.js",
|
||||
"src/globe/cesium/credits.js",
|
||||
"src/globe/cesium/events.js",
|
||||
"src/globe/cesium/frames.js",
|
||||
"src/globe/cesium/input.js",
|
||||
"src/globe/cesium/maps.js",
|
||||
"src/globe/cesium/picker.js",
|
||||
"src/globe/cesium/projector.js",
|
||||
"src/globe/cesium/render.js",
|
||||
"src/globe/cesium/surface.js",
|
||||
"src/globe/cesium/movers.js",
|
||||
"src/globe/cesium/lines.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cameraMath.js",
|
||||
"src/globe/cameraMath.test.mjs",
|
||||
"src/globe/navara/cameraModel.js",
|
||||
"src/globe/navara/cameraModel.test.mjs",
|
||||
"src/globe/navara/createNavaraEngine.js",
|
||||
"src/globe/navara/createNavaraEngine.test.mjs",
|
||||
"src/globe/navara/credits.js",
|
||||
"src/globe/navara/credits.test.mjs",
|
||||
"src/globe/navara/groundFloor.js",
|
||||
"src/globe/navara/groundFloor.test.mjs",
|
||||
"src/globe/navara/index.js",
|
||||
"src/globe/navara/input.js",
|
||||
"src/globe/navara/input.test.mjs",
|
||||
"src/globe/navara/internals.js",
|
||||
"src/globe/navara/internals.test.mjs",
|
||||
"src/globe/navara/maps.js",
|
||||
"src/globe/navara/maps.test.mjs",
|
||||
"src/globe/navara/sharpen.js",
|
||||
"src/globe/navara/surface.js",
|
||||
"src/globe/navara/surface.test.mjs",
|
||||
"src/globe/navara/surfaceRay.test.mjs",
|
||||
"src/globe/navara/viewOptions.js",
|
||||
"src/globe/navara/viewOptions.test.mjs",
|
||||
"src/globe/testing/contractSuite.mjs",
|
||||
"src/globe/testing/fakeEngine.js",
|
||||
"src/mapStackController.test.mjs",
|
||||
"src/orbit.test.mjs",
|
||||
"src/services/groundSnap.test.mjs",
|
||||
"src/sharelinkPose.test.mjs",
|
||||
"src/tooling/navaraAssets.test.mjs",
|
||||
"src/tooling/tileCache.test.mjs",
|
||||
"scripts/check-main-chunk.mjs",
|
||||
"src/tooling/mainChunkComposition.test.mjs",
|
||||
"src/tooling/initialChunkGuard.test.mjs",
|
||||
"src/globe/movers.test.mjs",
|
||||
"src/globe/navara/movers/pack.test.mjs",
|
||||
"src/globe/navara/movers/pickIds.test.mjs",
|
||||
"src/globe/navara/movers/moverMesh.test.mjs",
|
||||
"src/globe/navara/movers/modelMover.test.mjs",
|
||||
"src/bench/syntheticFlights.test.mjs",
|
||||
"src/globe/lines.test.mjs",
|
||||
"src/globe/navara/lines/lineMesh.test.mjs",
|
||||
"src/globe/pickDelivery.test.mjs",
|
||||
"src/globe/navara/picking.test.mjs",
|
||||
"src/data/selectionDispatcher.test.mjs",
|
||||
"src/globe/navara/cascadedLightCopy.test.mjs",
|
||||
"src/tooling/navaraDependencies.test.mjs",
|
||||
"build/navaraDependencies.js",
|
||||
"build/initialChunkGuard.js",
|
||||
"src/globe/follow.js",
|
||||
"src/globe/follow.test.mjs",
|
||||
"src/globe/cesium/follow.js",
|
||||
"src/globe/cesium/follow.test.mjs",
|
||||
"src/globe/surfaceDetail.js",
|
||||
"src/globe/surfaceDetail.test.mjs",
|
||||
"src/globe/navara/followGestures.js",
|
||||
"src/globe/navara/followGestures.test.mjs",
|
||||
"src/globe/cpuPicks.test.mjs",
|
||||
"src/app/layers/directory.js",
|
||||
"src/app/layers/renderers.js",
|
||||
"src/app/layers/renderers.test.mjs",
|
||||
"src/data/lifecycleEngine.test.mjs",
|
||||
"src/ui/shareRestorationEngine.test.mjs",
|
||||
"src/app/qaProbe.js",
|
||||
"src/app/qaProbe.test.mjs",
|
||||
"src/app/layers/qaSources.js",
|
||||
"src/app/layers/qaSources.test.mjs",
|
||||
"src/bench/w1Feeds.js",
|
||||
"src/bench/satelliteFixture.js",
|
||||
"src/bench/satelliteFixture.test.mjs",
|
||||
"src/globe/cesium/celestial.js",
|
||||
"src/celestialRing/view.js",
|
||||
"src/geo/ellipsoidGeodesic.test.mjs",
|
||||
"src/bench/launchFixture.js",
|
||||
"src/layers/launches/replayClock.test.mjs",
|
||||
"src/geo/boundingSphere.test.mjs",
|
||||
"src/layers/launches/launchPadFrame.test.mjs",
|
||||
"src/layers/satellites/interaction.test.mjs",
|
||||
"src/layers/launches/lifecycleSnapshot.test.mjs",
|
||||
"src/globe/cesium/sceneProjection.js",
|
||||
"src/layers/flights/trackingRefresh.test.mjs",
|
||||
"scripts/qa-heading-navara.mjs",
|
||||
"src/layers/aircraft/qaDrawn.test.mjs",
|
||||
"src/globe/cesium/screenEvents.js",
|
||||
"src/globe/navara/movers/frameCamera.js",
|
||||
"src/globe/navara/overlayPass.js",
|
||||
"src/globe/navara/overlayPass.test.mjs",
|
||||
"src/globe/navara/movers/modelTint.js",
|
||||
"src/globe/navara/movers/frameCamera.test.mjs",
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/layers/localAdsb/qaDrawn.test.mjs",
|
||||
"src/tooling/browserRuntimeEvidence.test.mjs",
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/firms/rectangle.test.mjs",
|
||||
"src/layers/firms/viewport.test.mjs",
|
||||
"scripts/fixtures/layers/firms.mjs",
|
||||
"src/layers/firms/lifecycle.test.mjs",
|
||||
"scripts/fixtures/layers/radio.mjs",
|
||||
"scripts/qa-map-policy.mjs",
|
||||
"scripts/qa-map-policy.test.mjs",
|
||||
"scripts/qa-imagery-contract.mjs",
|
||||
"src/globe/navara/postSamplingPass.test.mjs",
|
||||
"src/globe/navara/emptySceneRender.test.mjs",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsIntensity.test.mjs",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
@@ -1534,168 +1367,9 @@
|
||||
"src/layers/wildlifeSightings/density.test.mjs",
|
||||
"src/layers/wildlifeSightings/selection.test.mjs",
|
||||
"server/providers/inaturalist/proxy.fixture.mjs",
|
||||
"scripts/report-engine-coupling.mjs",
|
||||
"scripts/engineCoupling.test.mjs",
|
||||
"scripts/qa-contract-suite.mjs",
|
||||
"src/globe/cesium/doubleClick.js",
|
||||
"scripts/engine-coupling-baseline.json",
|
||||
"src/layers/wildlifeSightings/display.js",
|
||||
"src/layers/alpr/render/display.js",
|
||||
"src/layers/alpr/render/display.test.mjs",
|
||||
"src/layers/bikeshare/render/display.js",
|
||||
"src/layers/bikeshare/render/display.test.mjs",
|
||||
"src/layers/bikeshare/render/style.js",
|
||||
"src/layers/launches/render/display.js",
|
||||
"src/layers/launches/render/display.test.mjs",
|
||||
"src/layers/osmPlaces/render/display.js",
|
||||
"src/layers/osmPlaces/render/display.test.mjs",
|
||||
"src/layers/satellites/render/display.js",
|
||||
"src/layers/satellites/render/display.test.mjs",
|
||||
"src/layers/vessels/render/display.js",
|
||||
"src/layers/vessels/render/display.test.mjs",
|
||||
"scripts/qa-wildlife-switch.mjs",
|
||||
"scripts/fixtures/wildlife.mjs",
|
||||
"src/layers/cctv/render/display.js",
|
||||
"src/layers/cctv/render/display.test.mjs",
|
||||
"src/layers/cctv/render/displayGizmo.test.mjs",
|
||||
"src/layers/cctv/render/meshes.js",
|
||||
"src/layers/cctv/render/meshes.test.mjs",
|
||||
"src/layers/cctv/render/style.js",
|
||||
"src/layers/radio/render/display.js",
|
||||
"src/layers/radio/render/display.test.mjs",
|
||||
"src/layers/radio/render/style.js",
|
||||
"src/layers/transit/render/display.js",
|
||||
"src/layers/transit/render/display.test.mjs",
|
||||
"src/layers/transit/render/style.js",
|
||||
"src/layers/transit/render/testAtlas.mjs",
|
||||
"src/testSupport/cctvEngines.mjs",
|
||||
"src/testSupport/radioTestEngine.mjs",
|
||||
"src/globe/ground.js",
|
||||
"src/globe/ground.test.mjs",
|
||||
"src/globe/cesium/ground.js",
|
||||
"src/globe/cesium/ground.test.mjs",
|
||||
"src/globe/navara/ground/index.js",
|
||||
"src/globe/navara/ground/index.test.mjs",
|
||||
"src/globe/navara/ground/pack.js",
|
||||
"src/globe/navara/ground/pack.test.mjs",
|
||||
"src/globe/navara/ground/pass.js",
|
||||
"docs/GLOBE-PRIMITIVES.md",
|
||||
"src/layers/airQuality/render/display.js",
|
||||
"src/layers/airQuality/render/display.test.mjs",
|
||||
"src/layers/cyclones/render/display.js",
|
||||
"src/layers/cyclones/render/display.test.mjs",
|
||||
"src/layers/droneAirspace/render/display.js",
|
||||
"src/layers/droneAirspace/render/display.test.mjs",
|
||||
"src/layers/earthquakes/render/display.js",
|
||||
"src/layers/earthquakes/render/display.test.mjs",
|
||||
"src/layers/firms/render/display.js",
|
||||
"src/layers/firms/render/display.test.mjs",
|
||||
"src/layers/firms/render/sprites.js",
|
||||
"src/layers/ghostMaps/render/display.js",
|
||||
"src/layers/ghostMaps/render/display.test.mjs",
|
||||
"src/layers/gnss/render/display.js",
|
||||
"src/layers/gnss/render/display.test.mjs",
|
||||
"src/layers/perimeters/render/display.js",
|
||||
"src/layers/perimeters/render/display.test.mjs",
|
||||
"src/layers/submarineCables/render/display.js",
|
||||
"src/layers/submarineCables/render/display.test.mjs",
|
||||
"src/testSupport/groundDisplayEngines.mjs",
|
||||
"src/globe/cesium/groundPulse.js",
|
||||
"src/layers/awareness/render/display.js",
|
||||
"src/layers/awareness/render/display.test.mjs",
|
||||
"src/layers/infrastructure/render/display.js",
|
||||
"src/layers/infrastructure/render/display.test.mjs",
|
||||
"src/layers/installations/render/display.js",
|
||||
"src/layers/installations/render/display.test.mjs",
|
||||
"src/layers/installations/render/displayHarness.mjs",
|
||||
"src/layers/installations/render/namedMarkers.js",
|
||||
"src/layers/installations/render/style.js",
|
||||
"src/layers/installations/selection.test.mjs",
|
||||
"src/layers/installations/selectionFill.test.mjs",
|
||||
"src/layers/installations/lifecycle.test.mjs",
|
||||
"src/layers/installations/navigation.test.mjs",
|
||||
"src/layers/infrastructure/index.js",
|
||||
"src/layers/infrastructure/session.test.mjs",
|
||||
"src/data/localGeojson.test.mjs",
|
||||
"src/layers/directions/render/display.js",
|
||||
"src/layers/directions/render/display.test.mjs",
|
||||
"src/layers/directions/render/style.js",
|
||||
"src/layers/streetLevel/render/display.js",
|
||||
"src/layers/streetLevel/render/display.test.mjs",
|
||||
"src/layers/traffic/render/display.js",
|
||||
"src/layers/traffic/render/display.test.mjs",
|
||||
"src/layers/directions/edgeCases.test.mjs",
|
||||
"src/layers/streetLevel/edgeCases.test.mjs",
|
||||
"src/globe/cesium/imagery.js",
|
||||
"src/globe/cesium/imagery.test.mjs",
|
||||
"src/globe/cesium/imageryShell.test.mjs",
|
||||
"src/globe/imagery.js",
|
||||
"src/globe/imagery.test.mjs",
|
||||
"src/globe/navara/imagery/index.js",
|
||||
"src/globe/navara/imagery/index.test.mjs",
|
||||
"src/globe/navara/imagery/pass.js",
|
||||
"src/layers/recentImagery/render/display.js",
|
||||
"src/layers/recentImagery/render/display.test.mjs",
|
||||
"src/layers/weather/render/display.test.mjs",
|
||||
"src/layers/wind/render/display.test.mjs",
|
||||
"src/layers/bhoteKoshi/render/display.test.mjs",
|
||||
"src/globe/navara/navaraGroundOrder.test.mjs",
|
||||
"src/globe/navara/ground/corridors.js",
|
||||
"src/globe/cesium/windFlow.js",
|
||||
"src/testSupport/imageryDisplayEngines.mjs",
|
||||
"src/data/geoImportDisplay.js",
|
||||
"src/data/geoImportDisplay.test.mjs",
|
||||
"src/data/geoImportKml.js",
|
||||
"src/data/geoImportKml.test.mjs",
|
||||
"src/data/geoImportRecords.js",
|
||||
"src/globe/cesium/atmosphereCompat.js",
|
||||
"src/globe/cesium/postStages.js",
|
||||
"src/globe/cesium/postStages.test.mjs",
|
||||
"src/globe/cesium/sonar.js",
|
||||
"src/globe/cesium/sonar.test.mjs",
|
||||
"src/globe/cesium/sonarCommands.js",
|
||||
"src/globe/cesium/sonarCommands.test.mjs",
|
||||
"src/globe/cesium/starrySky.js",
|
||||
"src/globe/cesium/zoomOutGuard.js",
|
||||
"scripts/qa-geo-import-probe.mjs",
|
||||
"src/globe/cesium/meshes.js",
|
||||
"src/globe/meshes.js",
|
||||
"src/globe/navara/meshes.js",
|
||||
"src/globe/navara/meshes.test.mjs",
|
||||
"src/scenes/dataPacks/display.js",
|
||||
"src/scenes/dataPacks/shapes.js",
|
||||
"src/scenes/dataPacks/shapes.test.mjs",
|
||||
"src/globe/cesium/meshes.test.mjs",
|
||||
"src/globe/meshes.test.mjs",
|
||||
"src/scenes/dataPacks/display.test.mjs",
|
||||
"src/globe/exclusivePointer.js",
|
||||
"src/globe/cesium/followOffset.js",
|
||||
"src/annotations/drawSurface.js",
|
||||
"src/annotations/drawSurface.test.mjs",
|
||||
"src/annotations/drawToolBehaviour.test.mjs",
|
||||
"src/globe/navara/ground/pulse.js",
|
||||
"src/globe/navara/ground/surface.js",
|
||||
"src/layers/bhoteKoshi/render/story.js",
|
||||
"src/globe/groundPulse.js",
|
||||
"src/globe/groundPulse.test.mjs",
|
||||
"src/globe/navara/ground/pulse.test.mjs",
|
||||
"src/layers/bhoteKoshi/render/story.test.mjs",
|
||||
"src/globe/navara/windFlowMesh.js",
|
||||
"src/data/layerSceneBinding.js",
|
||||
"src/ui/shellScene.js",
|
||||
"src/app/layerSession.js",
|
||||
"src/standalone/sceneSwitcher.js",
|
||||
"src/app/layerSession.test.mjs",
|
||||
"src/standalone/sceneSwitcher.test.mjs",
|
||||
"src/app/layers/displayRehearsal.test.mjs",
|
||||
"src/globe/cesium/annotations/worldAnnotationRenderer.js",
|
||||
"src/globe/cesium/annotations/screenAnnotationRenderer.js",
|
||||
"src/globe/navara/windFlow.js",
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/trackedReadoutScenes.test.mjs",
|
||||
"src/data/trackedAircraftScenes.test.mjs",
|
||||
"src/ui/releaseTrackers.js",
|
||||
"src/testSupport/domClasses.mjs",
|
||||
"src/layers/shared/imageryHost.js",
|
||||
"src/layers/shared/imageryHost.test.mjs"
|
||||
"scripts/build-owner-flags.mjs",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/data/placePacksLoading.test.mjs",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/placePacksLoading.test.mjs"
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"gzipBytes": 2066560,
|
||||
"allowanceBytes": 20666,
|
||||
"measuredAt": "Keyless production build with dynamically loaded layer displays",
|
||||
"allowance": "1% of the baseline; growth past it fails npm run check:main-chunk until the baseline is raised on purpose"
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { parsers } from 'prettier/plugins/babel.mjs';
|
||||
import { vueScripts } from './vue-scripts.mjs';
|
||||
|
||||
const browserGlobals = new Set([
|
||||
'window',
|
||||
@@ -16,14 +15,9 @@ const browserGlobals = new Set([
|
||||
'getComputedStyle',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Inspect literal module dependencies and browser-platform references without
|
||||
* executing code. With `dynamic`, also list the static (not import())
|
||||
* specifiers apart.
|
||||
*/
|
||||
export function analyzeModule(source, { dynamic = false, filename = '' } = {}) {
|
||||
/** Inspect literal module dependencies and browser-platform references without executing code. */
|
||||
export function analyzeModule(source) {
|
||||
const imports = [];
|
||||
const staticImports = [];
|
||||
const browser = new Set();
|
||||
function walk(node, parent, field) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
@@ -41,8 +35,6 @@ export function analyzeModule(source, { dynamic = false, filename = '' } = {}) {
|
||||
'Computed module imports are not allowed in runtime code',
|
||||
);
|
||||
imports.push(node.source.value);
|
||||
if (node.type !== 'ImportExpression')
|
||||
staticImports.push(node.source.value);
|
||||
}
|
||||
if (node.type === 'CallExpression' && node.callee?.name === 'require')
|
||||
throw new Error('Runtime modules must use ES imports');
|
||||
@@ -78,15 +70,8 @@ export function analyzeModule(source, { dynamic = false, filename = '' } = {}) {
|
||||
else if (value && typeof value === 'object') walk(value, node, key);
|
||||
}
|
||||
}
|
||||
for (const script of filename.endsWith('.vue')
|
||||
? vueScripts(source, filename)
|
||||
: [source])
|
||||
walk(parsers.babel.parse(script));
|
||||
return {
|
||||
imports,
|
||||
browser: [...browser],
|
||||
...(dynamic && { staticImports }),
|
||||
};
|
||||
walk(parsers.babel.parse(source));
|
||||
return { imports, browser: [...browser] };
|
||||
}
|
||||
|
||||
/** Return static imports, literal dynamic imports and re-exports. */
|
||||
|
||||
+320
-705
File diff suppressed because it is too large.
Load diff
@@ -1,617 +0,0 @@
|
||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js';
|
||||
import {
|
||||
assertPreviewPortAvailable,
|
||||
createStrict,
|
||||
ENGINES,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
// The strict acceptance: every browser scenario against one keyless local
|
||||
// preview, under the strict network policy (scripts/qa-strict-harness.mjs),
|
||||
// writing <output>/acceptance.json, one log and one assertion manifest per
|
||||
// scenario.
|
||||
//
|
||||
// node scripts/qa-all-layer-switches.mjs http://127.0.0.1:4208 <output>
|
||||
// [--serve[=<commit>]] build <commit> (default HEAD) keyless in a clean
|
||||
// worktree and serve it with every .env key blank
|
||||
// [--scratch=DIR] where --serve puts its checkout and build
|
||||
// [--only=a,b] run only these labels
|
||||
// [--quick] the soak with 2 runs of 10 switches and 100
|
||||
// switches for the long run
|
||||
//
|
||||
// The run fails when a scenario fails, when any catalog layer (read from
|
||||
// the live page and cross-checked with src/app/layers/directory.js) has no
|
||||
// assertion on either engine, or when any network policy violation shows.
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const output = process.argv[3]?.startsWith('--')
|
||||
? mkdtempSync(join(tmpdir(), 'gev-acceptance-'))
|
||||
: resolve(process.argv[3] ?? mkdtempSync(join(tmpdir(), 'gev-acceptance-')));
|
||||
const flag = (name) =>
|
||||
process.argv
|
||||
.find((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`))
|
||||
?.split('=')[1] ?? (process.argv.includes(`--${name}`) ? true : null);
|
||||
const only = flag('only') ? String(flag('only')).split(',') : null;
|
||||
const quick = Boolean(flag('quick'));
|
||||
mkdirSync(join(output, 'evidence'), { recursive: true });
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
const dirty = () =>
|
||||
git('status', '--porcelain')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => line.slice(3));
|
||||
|
||||
// Fail once, with the fix, instead of once per scenario.
|
||||
let chrome;
|
||||
try {
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
chrome = await browser.version();
|
||||
await browser.close();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Puppeteer cannot start Chrome: ' +
|
||||
String(error?.message)
|
||||
.split('\n')[0]
|
||||
.replace(/ This can occur.*/, '') +
|
||||
'\nPoint PUPPETEER_EXECUTABLE_PATH at an installed Chrome, for example\n' +
|
||||
' PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n' +
|
||||
'or download the pinned build with `npx puppeteer browsers install chrome`.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Every name the checkout's .env files define, plus key-like names in this
|
||||
// environment: all blank for the build and the preview.
|
||||
const KEY_LIKE = /(_KEY|_TOKEN|_SECRET|_CLIENT_ID|PASSWORD)$/;
|
||||
/** The [name, value] pairs of the checkout's .env and .env.local, in order. */
|
||||
const dotenv = () =>
|
||||
['.env', '.env.local']
|
||||
.filter((file) => existsSync(join(root, file)))
|
||||
.flatMap((file) => readFileSync(join(root, file), 'utf8').split('\n'))
|
||||
.map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)/))
|
||||
.filter(Boolean)
|
||||
.map(([, name, raw]) => [name, raw.trim().replace(/^["']|["']$/g, '')]);
|
||||
const dotenvNames = () => [...new Set(dotenv().map(([name]) => name))].sort();
|
||||
const envNames = () =>
|
||||
[
|
||||
...new Set([
|
||||
...dotenvNames(),
|
||||
...Object.keys(process.env).filter((name) => KEY_LIKE.test(name)),
|
||||
]),
|
||||
].sort();
|
||||
// Credentials only (model names and URLs are settings the code may also
|
||||
// carry as defaults).
|
||||
const envValues = () =>
|
||||
dotenv()
|
||||
.filter(([name, value]) => KEY_LIKE.test(name) && value.length >= 8)
|
||||
.map(([, value]) => value);
|
||||
const scrubbed = () => ({
|
||||
...process.env,
|
||||
...Object.fromEntries(envNames().map((name) => [name, ''])),
|
||||
});
|
||||
|
||||
// The report names no local directory: the output, the served checkout and
|
||||
// this repository become placeholders (logs carry stack traces).
|
||||
const machinePaths = (text) => {
|
||||
let out = String(text);
|
||||
for (const [path, name] of [
|
||||
[served?.scratch, '<scratch>'],
|
||||
[output, '<output>'],
|
||||
[root.replace(/\/$/, ''), '<repo>'],
|
||||
])
|
||||
if (path) out = out.split(path).join(name);
|
||||
return out;
|
||||
};
|
||||
let served = null;
|
||||
let preview = null;
|
||||
const cleanup = () => {
|
||||
if (preview && !preview.killed) preview.kill('SIGTERM');
|
||||
if (served?.worktree)
|
||||
try {
|
||||
git('worktree', 'remove', '--force', served.worktree);
|
||||
served.worktree = null;
|
||||
} catch (error) {
|
||||
console.error('Could not remove', served.worktree, error.message);
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => process.exit(130));
|
||||
|
||||
if (flag('serve')) {
|
||||
const commit = git(
|
||||
'rev-parse',
|
||||
flag('serve') === true ? 'HEAD' : String(flag('serve')),
|
||||
).trim();
|
||||
// --scratch=DIR keeps the checkout and build out of the system temp.
|
||||
const scratchRoot = flag('scratch')
|
||||
? resolve(String(flag('scratch')))
|
||||
: tmpdir();
|
||||
mkdirSync(scratchRoot, { recursive: true });
|
||||
await assertPreviewPortAvailable(base.href);
|
||||
const scratch = mkdtempSync(join(scratchRoot, 'gev-acceptance-build-'));
|
||||
const worktree = join(scratch, 'checkout');
|
||||
const dist = join(scratch, 'dist');
|
||||
git('worktree', 'add', '--detach', worktree, commit);
|
||||
symlinkSync(join(root, 'node_modules'), join(worktree, 'node_modules'));
|
||||
served = {
|
||||
scratch,
|
||||
commit,
|
||||
worktree,
|
||||
dist,
|
||||
// Names from .env files only; other key-like variables of this shell
|
||||
// are blanked too but only counted.
|
||||
blankedEnv: dotenvNames(),
|
||||
otherKeyLikeVariablesBlanked: envNames().filter(
|
||||
(name) => !dotenvNames().includes(name),
|
||||
).length,
|
||||
envFilesInCheckout: ['.env', '.env.local'].filter((f) =>
|
||||
existsSync(join(worktree, f)),
|
||||
),
|
||||
};
|
||||
console.log('Building', commit, 'keyless into', dist);
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
[join(root, 'node_modules/vite/bin/vite.js'), 'build', '--outDir', dist],
|
||||
{ cwd: worktree, env: scrubbed(), encoding: 'utf8', maxBuffer: 1 << 26 },
|
||||
);
|
||||
if (build.status !== 0) {
|
||||
console.error(build.stdout + build.stderr);
|
||||
process.exit(2);
|
||||
}
|
||||
// No .env credential value may reach the built files.
|
||||
const values = envValues();
|
||||
let leaks = 0;
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const file = join(dir, name);
|
||||
if (statSync(file).isDirectory()) walk(file);
|
||||
else if (/\.(js|html|json|css|webmanifest)$/.test(name)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const value of values) if (text.includes(value)) leaks++;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dist);
|
||||
served.distCredentialValuesFound = leaks;
|
||||
served.mainChunk = JSON.parse(
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[join(root, 'scripts/check-main-chunk.mjs'), `--dist=${dist}`, '--json'],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
).stdout || 'null',
|
||||
);
|
||||
preview = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
join(root, 'node_modules/vite/bin/vite.js'),
|
||||
'preview',
|
||||
'--outDir',
|
||||
dist,
|
||||
'--host',
|
||||
base.hostname,
|
||||
'--port',
|
||||
base.port,
|
||||
'--strictPort',
|
||||
],
|
||||
{ cwd: worktree, env: scrubbed(), stdio: 'ignore' },
|
||||
);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
if ((await fetch(base.origin + '/')).ok) break;
|
||||
} catch {
|
||||
// Not up yet.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(base.origin + '/', {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) throw Error('HTTP ' + response.status);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'No preview answers at ' + base.origin + ' (' + error.message + ').',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// The preview reports no paid key: its own keyless status routes.
|
||||
const probe = async (path) => {
|
||||
try {
|
||||
const r = await fetch(base.origin + path, {
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' },
|
||||
});
|
||||
return { status: r.status, body: await r.json().catch(() => null) };
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
};
|
||||
const keys = {
|
||||
openaiHudSummary: await probe('/api/openai/hud-summary/status'),
|
||||
firms: await probe('/api/firms/status'),
|
||||
tomtom: await probe('/api/tomtom/status'),
|
||||
mapillary: await probe('/api/mapillary/status'),
|
||||
};
|
||||
const paidKeysConfigured = [
|
||||
keys.openaiHudSummary.body?.configured !== false && 'OpenAI',
|
||||
keys.firms.body?.hasKey !== false && 'FIRMS',
|
||||
keys.tomtom.body?.hasKey !== false && 'TomTom',
|
||||
keys.mapillary.body?.configured !== false && 'Mapillary',
|
||||
].filter(Boolean);
|
||||
|
||||
const dirtyFiles = dirty();
|
||||
const soakRuns = quick
|
||||
? ['--runs=2', '--swaps=10']
|
||||
: ['--runs=6', '--swaps=50'];
|
||||
const longRun = quick ? '--swaps=100' : '--swaps=500';
|
||||
// label, script, layers, extra arguments, timeout in seconds.
|
||||
const runs = [
|
||||
['engine-switch', 'engine-switch', ['earthquakes']],
|
||||
['director-engine-switch', 'director-engine-switch', []],
|
||||
['layer-handoff', 'layer-handoff', ['flights']],
|
||||
['layer-handoff--military', 'layer-handoff', ['military'], ['--military']],
|
||||
[
|
||||
'ground-layer-switch',
|
||||
'ground-layer-switch',
|
||||
['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'],
|
||||
],
|
||||
[
|
||||
'contact-switch',
|
||||
'contact-switch',
|
||||
['military-awareness', 'military-installations'],
|
||||
],
|
||||
['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']],
|
||||
['ghostmaps-switch', 'ghostmaps-switch', ['s2-ghostmaps']],
|
||||
['wildlife-switch', 'wildlife-switch', ['wildlife-sightings']],
|
||||
[
|
||||
'open-layers-switch',
|
||||
'open-layers-switch',
|
||||
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'],
|
||||
],
|
||||
[
|
||||
'weather-engine-switch',
|
||||
'weather-engine-switch',
|
||||
[
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
],
|
||||
],
|
||||
['wind-engine-switch', 'wind-engine-switch', ['wind']],
|
||||
[
|
||||
'infrastructure-engine-switch',
|
||||
'infrastructure-engine-switch',
|
||||
['local-dams', 'local-datacenters'],
|
||||
],
|
||||
[
|
||||
'submarine-cables-engine-switch',
|
||||
'submarine-cables-engine-switch',
|
||||
['telegeography-submarine-cables'],
|
||||
],
|
||||
['directions-engine-switch', 'directions-engine-switch', ['directions']],
|
||||
['nepal-locator-switch', 'nepal-locator-switch', ['bhote-koshi-locator']],
|
||||
['nepal-event-switch', 'nepal-event-switch', ['bhote-koshi-2026']],
|
||||
['traffic-engine-switch', 'traffic-engine-switch', ['traffic']],
|
||||
[
|
||||
'traffic-engine-switch--simulation',
|
||||
'traffic-engine-switch',
|
||||
['traffic'],
|
||||
['--simulation'],
|
||||
],
|
||||
[
|
||||
'street-level-engine-switch',
|
||||
'street-level-engine-switch',
|
||||
['street-level'],
|
||||
],
|
||||
[
|
||||
'street-level-engine-switch--panoramax',
|
||||
'street-level-engine-switch',
|
||||
['street-level'],
|
||||
['--panoramax'],
|
||||
],
|
||||
['radio-engine-switch', 'radio-engine-switch', ['radio']],
|
||||
['imagery-contract', 'imagery-contract', ['recent-imagery']],
|
||||
['fire-camera-switch', 'fire-camera-switch', ['alpr-cameras']],
|
||||
[
|
||||
'fire-camera-switch--fires',
|
||||
'fire-camera-switch',
|
||||
['local-firms'],
|
||||
['--fires'],
|
||||
],
|
||||
['urban-engine-switch', 'urban-engine-switch', ['bikeshare']],
|
||||
[
|
||||
'urban-engine-switch--transit',
|
||||
'urban-engine-switch',
|
||||
['transit'],
|
||||
['--transit'],
|
||||
],
|
||||
['cctv-engine-switch', 'cctv-engine-switch', ['cctv']],
|
||||
['space-engine-switch', 'space-engine-switch', ['satellites']],
|
||||
[
|
||||
'space-engine-switch--missions',
|
||||
'space-engine-switch',
|
||||
['rocket-launches'],
|
||||
['--missions'],
|
||||
],
|
||||
// One application per page, and voice across a switch: these block paid
|
||||
// routes themselves but do not run under the strict harness, so they add
|
||||
// no assertion manifest and no network ledger.
|
||||
['app-once', 'app-once', [], [], 600, true],
|
||||
['voice-engine-switch', 'voice-engine-switch', [], [], 300, true],
|
||||
['strict-rapid-switch', 'strict-rapid-switch', []],
|
||||
['strict-hidden-switch', 'strict-hidden-switch', []],
|
||||
['strict-context-loss', 'strict-context-loss', []],
|
||||
['strict-parked-release', 'strict-parked-release', []],
|
||||
['strict-navara-hd', 'strict-navara-hd', []],
|
||||
['strict-failure-matrix', 'strict-failure-matrix', [], [], 600],
|
||||
['strict-cold-chunks', 'strict-cold-chunks', []],
|
||||
['strict-prod-smoke', 'strict-prod-smoke', []],
|
||||
// The app's visual styles, bloom, contact sonar, cockpit clouds, and the
|
||||
// annotations, Draw, Import and the scope mask with the celestial ring on
|
||||
// Navara (those are tools and display overlays, not catalog layers:
|
||||
// TOOL_IDS).
|
||||
['navara-visual-styles', 'navara-visual-styles', []],
|
||||
['navara-bloom', 'navara-bloom', []],
|
||||
['navara-sonar', 'navara-sonar', []],
|
||||
['navara-cockpit-clouds', 'navara-cockpit-clouds', []],
|
||||
['navara-annotations', 'navara-annotations', ['annotations']],
|
||||
['navara-draw', 'navara-draw', ['draw']],
|
||||
['navara-geo-import', 'navara-geo-import', ['geo-import']],
|
||||
['navara-scope', 'navara-scope', ['scope-mask', 'celestial-ring']],
|
||||
['strict-soak', 'strict-soak', [], soakRuns, 1200],
|
||||
[
|
||||
'strict-soak--long',
|
||||
'strict-soak',
|
||||
[],
|
||||
['--runs=1', longRun, '--assert'],
|
||||
1800,
|
||||
],
|
||||
];
|
||||
// Ids a scenario covers that are tools, not catalog layers: their
|
||||
// assertions are recorded, but the catalog comparison leaves them out.
|
||||
const TOOL_IDS = new Set([
|
||||
'annotations',
|
||||
'draw',
|
||||
'geo-import',
|
||||
'scope-mask',
|
||||
'celestial-ring',
|
||||
]);
|
||||
const scenarioIds = [
|
||||
...new Set(
|
||||
runs.flatMap(([, , ids]) => ids).filter((id) => !TOOL_IDS.has(id)),
|
||||
),
|
||||
].sort();
|
||||
const directoryIds = APPLICATION_LAYER_DIRECTORY.map((l) => l.id).sort();
|
||||
|
||||
// The catalog as the live page has it, on both engines.
|
||||
const liveCatalog = {};
|
||||
{
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: [],
|
||||
name: 'catalog',
|
||||
});
|
||||
const browser = await strict.launch();
|
||||
try {
|
||||
for (const engine of ENGINES) {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(
|
||||
`${base.origin}/?engine=${engine}&welcome=0#v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await waitForEngine(page, engine);
|
||||
liveCatalog[engine] = await page.evaluate(() =>
|
||||
[...window.__godsEyeView.dataManager.layers.keys()].sort(),
|
||||
);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
liveCatalog.violations = strict.ledger.violations;
|
||||
}
|
||||
|
||||
const scenarios = [];
|
||||
const report = {
|
||||
commit: git('rev-parse', 'HEAD').trim(),
|
||||
dirty: dirtyFiles.length > 0,
|
||||
dirtyFiles,
|
||||
dirtyFilesAtEnd: null,
|
||||
chrome,
|
||||
puppeteerExecutable: process.env.PUPPETEER_EXECUTABLE_PATH || null,
|
||||
node: process.version,
|
||||
platform: process.platform + ' ' + process.arch,
|
||||
baseUrl: base.origin,
|
||||
preview: served
|
||||
? {
|
||||
servedByRunner: true,
|
||||
appCommit: served.commit,
|
||||
appCheckoutClean: true,
|
||||
scenariosFrom: 'the same clean checkout',
|
||||
envFilesInCheckout: served.envFilesInCheckout,
|
||||
blankedEnv: served.blankedEnv,
|
||||
otherKeyLikeVariablesBlanked: served.otherKeyLikeVariablesBlanked,
|
||||
distCredentialValuesFound: served.distCredentialValuesFound,
|
||||
}
|
||||
: { servedByRunner: false },
|
||||
keyProbes: keys,
|
||||
paidKeysConfigured,
|
||||
mainChunk: served?.mainChunk ?? null,
|
||||
networkPolicy: {
|
||||
blockedFirst:
|
||||
'Google (*.googleapis.com, *.google.com, maps.google.*, *.gstatic.com), Cesium ion (*.cesium.com), preview /api/openai/*, /api/realtime/*, /api/google/*',
|
||||
allowed:
|
||||
'the preview origin, each scenario’s declared fixture URLs, keyless probes answered by the harness, and the free tile hosts answered from the disk cache',
|
||||
keylessProbes: (await import('./qa-map-policy.mjs')).KEYLESS_PROBES.map(
|
||||
(p) => p.pattern,
|
||||
),
|
||||
tileHosts: (await import('./qa-map-policy.mjs')).STRICT_TILE_HOSTS,
|
||||
},
|
||||
catalog: {
|
||||
directory: directoryIds,
|
||||
live: liveCatalog,
|
||||
scenarios: scenarioIds,
|
||||
},
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
networkViolations: 0,
|
||||
coverage: null,
|
||||
uncovered: null,
|
||||
scenarios,
|
||||
};
|
||||
const save = () =>
|
||||
writeFileSync(
|
||||
join(output, 'acceptance.json'),
|
||||
machinePaths(JSON.stringify(report, null, 2)) + '\n',
|
||||
);
|
||||
save();
|
||||
|
||||
for (const [label, name, layers, extra = [], seconds = 300, external] of runs) {
|
||||
if (only && !only.includes(label)) continue;
|
||||
const script = 'scripts/qa-' + name + '.mjs';
|
||||
const evidence = join(output, 'evidence', label + '.json');
|
||||
rmSync(evidence, { force: true });
|
||||
const args = [base.origin, ...extra];
|
||||
if (external) args.splice(0, 1, base.origin + '/');
|
||||
else args.push(`--layers=${layers.join(',')}`, `--evidence=${evidence}`);
|
||||
const start = Date.now();
|
||||
// With --serve the scenarios run from the same clean checkout the app was
|
||||
// built from, so other work in this tree cannot change them.
|
||||
const scripts = served?.worktree ?? root;
|
||||
const run = spawnSync(process.execPath, [join(scripts, script), ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: seconds * 1000,
|
||||
maxBuffer: 1 << 26,
|
||||
env: scrubbed(),
|
||||
});
|
||||
const status =
|
||||
run.status === 0
|
||||
? 'pass'
|
||||
: run.error?.code === 'ETIMEDOUT'
|
||||
? 'timeout'
|
||||
: 'fail';
|
||||
const log = machinePaths(
|
||||
(run.stdout || '') + (run.stderr || '') + (run.error?.message || ''),
|
||||
);
|
||||
writeFileSync(join(output, label + '.log'), log);
|
||||
const manifest = existsSync(evidence)
|
||||
? JSON.parse(machinePaths(readFileSync(evidence, 'utf8')))
|
||||
: null;
|
||||
if (manifest)
|
||||
writeFileSync(evidence, JSON.stringify(manifest, null, 2) + '\n');
|
||||
scenarios.push({
|
||||
label,
|
||||
script,
|
||||
args: args.map((arg) =>
|
||||
arg.includes(output) ? arg.replace(output, '<output>') : arg,
|
||||
),
|
||||
layers,
|
||||
external: Boolean(external),
|
||||
status,
|
||||
exitCode: run.status,
|
||||
seconds: Math.round((Date.now() - start) / 100) / 10,
|
||||
log: label + '.log',
|
||||
logSha256: createHash('sha256').update(log).digest('hex'),
|
||||
evidence: manifest ? 'evidence/' + label + '.json' : null,
|
||||
assertions: manifest?.assertions.length ?? 0,
|
||||
coverage: manifest?.coverage ?? null,
|
||||
picks: manifest?.picks ?? [],
|
||||
network: manifest
|
||||
? {
|
||||
requests: manifest.network.requests,
|
||||
violations: manifest.network.violations,
|
||||
external: manifest.network.external,
|
||||
tileCache: manifest.network.tileCache,
|
||||
probes: manifest.network.probes,
|
||||
routes: manifest.network.routes,
|
||||
previewApi: manifest.network.previewApi,
|
||||
fixtures: manifest.network.fixtures,
|
||||
}
|
||||
: null,
|
||||
contracts: manifest?.contracts ?? null,
|
||||
error: manifest?.error ?? null,
|
||||
});
|
||||
if (manifest?.soak) scenarios.at(-1).soak = manifest.soak;
|
||||
report.passed = scenarios.filter((row) => row.status === 'pass').length;
|
||||
report.failed = scenarios.length - report.passed;
|
||||
report.networkViolations =
|
||||
scenarios.reduce(
|
||||
(sum, row) => sum + (row.network?.violations.length ?? 0),
|
||||
0,
|
||||
) + liveCatalog.violations.length;
|
||||
save();
|
||||
console.log(status.toUpperCase(), label, scenarios.at(-1).seconds);
|
||||
}
|
||||
|
||||
// Coverage that cannot be faked: every catalog id needs an assertion on each
|
||||
// engine from some scenario's manifest.
|
||||
const catalogIds = [
|
||||
...new Set([...directoryIds, ...(liveCatalog.navara ?? [])]),
|
||||
].sort();
|
||||
report.coverage = Object.fromEntries(
|
||||
catalogIds.map((id) => [
|
||||
id,
|
||||
Object.fromEntries(
|
||||
ENGINES.map((engine) => [
|
||||
engine,
|
||||
scenarios.reduce(
|
||||
(sum, row) => sum + (row.coverage?.[id]?.[engine] ?? 0),
|
||||
0,
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
report.uncovered = Object.entries(report.coverage).flatMap(([id, counts]) =>
|
||||
ENGINES.filter((engine) => !counts[engine]).map(
|
||||
(engine) => `${id}@${engine}`,
|
||||
),
|
||||
);
|
||||
report.catalogMatches =
|
||||
JSON.stringify(liveCatalog.navara) === JSON.stringify(directoryIds) &&
|
||||
JSON.stringify(liveCatalog.cesium) === JSON.stringify(directoryIds) &&
|
||||
JSON.stringify(scenarioIds) === JSON.stringify(directoryIds);
|
||||
report.finishedAt = new Date().toISOString();
|
||||
report.dirtyFilesAtEnd = dirty();
|
||||
save();
|
||||
console.log(
|
||||
`${catalogIds.length} catalog layers, ${report.uncovered.length} engine slots without an assertion, ` +
|
||||
`${report.networkViolations} network violations, ${report.passed}/${scenarios.length} scenarios, ` +
|
||||
`paid keys configured: ${paidKeysConfigured.join(', ') || 'none'}. ${join(output, 'acceptance.json')}`,
|
||||
);
|
||||
if (
|
||||
report.failed ||
|
||||
(!only && report.uncovered.length) ||
|
||||
report.networkViolations ||
|
||||
paidKeysConfigured.length ||
|
||||
!report.catalogMatches
|
||||
)
|
||||
process.exitCode = 1;
|
||||
// The preview child would keep this process alive: stop it and drop the
|
||||
// checkout now.
|
||||
cleanup();
|
||||
+20
-93
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
|
||||
*
|
||||
* Run: node scripts/qa-alpr-journey.mjs <dev-server-url> [--fixture]
|
||||
* Run: node scripts/qa-alpr-journey.mjs http://localhost:4173 [--headful]
|
||||
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
|
||||
*
|
||||
* Drives the same eased camera path several times: a neighbourhood by Camp
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* Per stop it records: time from the camera stopping to cameras shown (or an
|
||||
* explicit "zoom in" state), the camera count and row status, rAF interval and
|
||||
* engine render CPU time (mean / p50 / p95) while moving and while holding, and
|
||||
* Cesium render CPU time (mean / p50 / p95) while moving and while holding, and
|
||||
* marker stability while the camera is still (camera-set changes after the stop
|
||||
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show
|
||||
* cameras within 2 s on the warm pass; the whole-city stop shows cameras or a
|
||||
@@ -39,10 +39,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
import sharp from 'sharp';
|
||||
import { installAlprFixture } from './fixtures/layers/alpr.mjs';
|
||||
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
|
||||
import {
|
||||
createScreencast,
|
||||
frameStats,
|
||||
@@ -63,20 +60,12 @@ const option = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||
};
|
||||
const appUrl = argv.find((arg) => /^https?:/.test(arg));
|
||||
if (!appUrl) throw new Error('An explicit dev-server URL is required');
|
||||
if (
|
||||
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||
new URL(appUrl).port,
|
||||
)
|
||||
)
|
||||
throw new Error('ALPR QA may not use a reserved port');
|
||||
const appUrl =
|
||||
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
|
||||
const outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
|
||||
const passes = option('--passes', 'off,on,on').split(',');
|
||||
const assertOn = !argv.includes('--no-assert');
|
||||
const dropRenders = argv.includes('--self-test-drop-renders');
|
||||
if (dropRenders && new URL(appUrl).searchParams.get('engine') === 'navara')
|
||||
throw new Error('The legacy render-drop self-test requires Cesium');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const cyanCount = async (file) => {
|
||||
@@ -181,8 +170,6 @@ const result = {
|
||||
startedAt: new Date().toISOString(),
|
||||
passes: [],
|
||||
consoleErrors: [],
|
||||
expectedPreviewErrors: [],
|
||||
failedRequests: [],
|
||||
failures: [],
|
||||
};
|
||||
const fail = (message) => {
|
||||
@@ -192,44 +179,14 @@ const fail = (message) => {
|
||||
let page;
|
||||
try {
|
||||
page = await browser.newPage();
|
||||
await blockPaidQaMaps(page);
|
||||
if (argv.includes('--fixture')) await installAlprFixture(page);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
page.on('console', (message) => {
|
||||
if (
|
||||
isExpectedPreviewSetupError(message, {
|
||||
preview: argv.includes('--preview'),
|
||||
baseUrl: appUrl,
|
||||
})
|
||||
) {
|
||||
result.expectedPreviewErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
if (message.type() === 'error') {
|
||||
let resource = '(location unavailable)';
|
||||
try {
|
||||
const url = new URL(message.location()?.url);
|
||||
resource = url.origin + url.pathname;
|
||||
} catch {
|
||||
/* no resource location */
|
||||
}
|
||||
result.consoleErrors.push(redact(`${message.text()} — ${resource}`));
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) =>
|
||||
result.consoleErrors.push(redact(error.message)),
|
||||
);
|
||||
page.on('requestfailed', (request) => {
|
||||
if (result.failedRequests.length >= 200) return;
|
||||
const url = new URL(request.url());
|
||||
result.failedRequests.push({
|
||||
resource: redact(url.origin + url.pathname),
|
||||
error: request.failure()?.errorText,
|
||||
});
|
||||
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => result.consoleErrors.push(error.message));
|
||||
const url = new URL(appUrl);
|
||||
url.searchParams.set('welcome', '0');
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
@@ -253,17 +210,23 @@ try {
|
||||
const entry = window.__godsEyeView.dataManager.layers.get('alpr-cameras');
|
||||
const stats = entry?.module?.getStats?.() || {};
|
||||
// Layers that publish a render revision are measured by it; otherwise
|
||||
// hash the layer's mover sets on the globe contract (ids and counts).
|
||||
// hash the rendered entity ids and their visibility.
|
||||
let hash = 0;
|
||||
let shown = 0;
|
||||
if (Number.isFinite(stats.renderRevision)) {
|
||||
hash = stats.renderRevision;
|
||||
shown = stats.shown ?? stats.count ?? 0;
|
||||
} else if (entry?.module && entry.enabled) {
|
||||
for (const set of window.__godsEyeView.engine.movers.list()) {
|
||||
if (!String(set.id).startsWith('alpr-')) continue;
|
||||
shown += set.count;
|
||||
const id = `${set.id}:${set.count}`;
|
||||
} else {
|
||||
const entities =
|
||||
entry?.module && entry.enabled
|
||||
? window.__godsEyeView.viewer.dataSources.getByName(
|
||||
'alpr-cameras',
|
||||
)[0]?.entities.values || []
|
||||
: [];
|
||||
for (const entity of entities) {
|
||||
if (entity.show === false) continue;
|
||||
shown += 1;
|
||||
const id = String(entity.id);
|
||||
for (let i = 0; i < id.length; i++)
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
}
|
||||
@@ -289,8 +252,6 @@ try {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
if (window.__godsEyeView.engine.kind === 'navara')
|
||||
return window.__godsEyeView.engine.surface.tilesLoaded();
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
if (scene.globe.show) return scene.globe.tilesLoaded;
|
||||
for (let i = 0; i < scene.primitives.length; i++) {
|
||||
@@ -347,31 +308,8 @@ try {
|
||||
// changed state without asking for a render.
|
||||
let staleFrame = null;
|
||||
if (alpr) {
|
||||
const shownImage = await page.screenshot({ type: 'png' });
|
||||
const shown = await cyanCount(shownImage);
|
||||
const shown = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
if (engine.kind === 'navara') {
|
||||
await new Promise((resolve, reject) => {
|
||||
let count = 4;
|
||||
const timeout = setTimeout(() => {
|
||||
remove();
|
||||
reject(new Error('ALPR forced-frame deadline'));
|
||||
}, 10000);
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--count === 0) {
|
||||
remove();
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
} else
|
||||
requestAnimationFrame(() =>
|
||||
engine.render.request('alpr-qa-frame'),
|
||||
);
|
||||
});
|
||||
engine.render.request('alpr-qa-frame');
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
const mode = scene.requestRenderMode;
|
||||
scene.requestRenderMode = false;
|
||||
@@ -379,23 +317,12 @@ try {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
scene.requestRenderMode = mode;
|
||||
});
|
||||
const forcedImage = await page.screenshot({ type: 'png' });
|
||||
const forced = await cyanCount(forcedImage);
|
||||
const forced = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
staleFrame = {
|
||||
shown,
|
||||
forced,
|
||||
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
|
||||
};
|
||||
if (staleFrame.stale) {
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, `${passName}-${stop.name}-shown.png`),
|
||||
shownImage,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, `${passName}-${stop.name}-forced.png`),
|
||||
forcedImage,
|
||||
);
|
||||
}
|
||||
}
|
||||
const moving = frameStats(await readFrames(page, moveStart, holdStart));
|
||||
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
import { localPreview, waitForEngine } from './qa-strict-harness.mjs';
|
||||
// The application is created once per page: switching engines detaches and
|
||||
// attaches only the engine-bound parts. This gate runs three Cesium→Navara→
|
||||
// Cesium round trips and checks that every session object keeps its identity,
|
||||
// an open panel, typed text and the focused field survive, Escape still does
|
||||
// what it did before the switches, window/document listeners do not grow,
|
||||
// and a failed candidate rolls back onto the same session objects.
|
||||
//
|
||||
// node scripts/qa-app-once.mjs http://127.0.0.1:<port>/ [--report]
|
||||
// `--report` prints the measurements without asserting them.
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
idDifference,
|
||||
launch,
|
||||
listenerCounts,
|
||||
listenerDifference,
|
||||
openSessionPage,
|
||||
readSession,
|
||||
requestEngine,
|
||||
sessionAddress,
|
||||
switchEngine,
|
||||
} from './qa/appSessionHarness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const reportOnly = process.argv.includes('--report');
|
||||
const report = { switches: [], failures: [] };
|
||||
const check = (condition, message, detail) => {
|
||||
if (condition) return;
|
||||
report.failures.push({ message, detail });
|
||||
if (!reportOnly)
|
||||
assert.fail(`${message}${detail ? ` ${JSON.stringify(detail)}` : ''}`);
|
||||
};
|
||||
|
||||
const FIELD = '#location-search';
|
||||
const PANEL = 'data-panel';
|
||||
|
||||
// The page's visible UI state the switches must leave alone.
|
||||
const readUi = (page) =>
|
||||
page.evaluate(
|
||||
(field, panel) => ({
|
||||
value: document.querySelector(field)?.value ?? null,
|
||||
focused: document.activeElement?.id || null,
|
||||
panelOpen: !document
|
||||
.getElementById(panel)
|
||||
?.classList.contains('collapsed'),
|
||||
}),
|
||||
FIELD,
|
||||
PANEL,
|
||||
);
|
||||
|
||||
// Focus the field, press Escape and read what changed; then put the state
|
||||
// back. The same press before and after the switches must have the same
|
||||
// effect.
|
||||
async function escapeEffect(page) {
|
||||
await page.focus(FIELD);
|
||||
const before = await readUi(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const after = await readUi(page);
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
async function prepareUi(page) {
|
||||
await page.evaluate((panel) => {
|
||||
const style = window.__godsEyeView.styleManager;
|
||||
style.setPanelCollapsed(panel, false, { explicit: true });
|
||||
style.setPanelCollapsed('location-bar', false, { explicit: true });
|
||||
}, PANEL);
|
||||
await page.waitForFunction(
|
||||
(field) => document.querySelector(field)?.offsetParent !== null,
|
||||
{},
|
||||
FIELD,
|
||||
);
|
||||
await page.evaluate((field) => {
|
||||
const input = document.querySelector(field);
|
||||
input.focus();
|
||||
input.value = 'Reykjav';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, FIELD);
|
||||
}
|
||||
|
||||
// The session's tools: a mark on the annotation board, an imported file and
|
||||
// the Draw shape, set on Cesium before the switches.
|
||||
async function prepareTools(page) {
|
||||
return page.evaluate(async () => {
|
||||
const annotated = await window.__gevAnnotations.annotate([
|
||||
{ type: 'pin', latitude: 42.3554, longitude: -71.0605, label: 'QA mark' },
|
||||
]);
|
||||
const file = new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'QA area' },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-71.07, 42.35],
|
||||
[-71.05, 42.35],
|
||||
[-71.05, 42.36],
|
||||
[-71.07, 42.35],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
'qa-area.geojson',
|
||||
{ type: 'application/geo+json' },
|
||||
);
|
||||
const imported = await window.__gevGeoImport.importFiles([file]);
|
||||
window.__gevDrawTool.setShape('line');
|
||||
return { annotated: annotated.drawn, imported: imported[0]?.ok };
|
||||
});
|
||||
}
|
||||
|
||||
const readTools = (page) =>
|
||||
page.evaluate(() => ({
|
||||
marks: window.__gevAnnotations?.count() ?? null,
|
||||
marksDrawn: window.__gevAnnotations?.attached ?? null,
|
||||
files: window.__gevGeoImport?.list().length ?? null,
|
||||
filesDrawn: window.__gevGeoImport?.diagnostics().drawn ?? null,
|
||||
drawShape: window.__gevDrawTool?.shape ?? null,
|
||||
}));
|
||||
|
||||
// Listeners whose difference is the parked renderer's: the Navara engine
|
||||
// keeps its own input, resize and attribution listeners while it is parked
|
||||
// for a switch back (scripts that are not the session's).
|
||||
const PARKED_RENDERER = /@navara-engine\.js$/;
|
||||
|
||||
const browser = await launch();
|
||||
try {
|
||||
const { page, errors } = await openSessionPage(
|
||||
browser,
|
||||
sessionAddress(base, { engine: 'cesium' }),
|
||||
);
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
// Let the first boot's deferred listeners land before counting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const boot = await readSession(page);
|
||||
const bootListeners = await listenerCounts(page, { bySource: true });
|
||||
report.boot = boot;
|
||||
report.toolsPrepared = await prepareTools(page);
|
||||
check(
|
||||
report.toolsPrepared.annotated === 1 && report.toolsPrepared.imported,
|
||||
'the tools could not be prepared',
|
||||
report.toolsPrepared,
|
||||
);
|
||||
// Let the imported file finish drawing.
|
||||
await page.waitForFunction(
|
||||
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||
);
|
||||
const toolsBefore = await readTools(page);
|
||||
report.toolsBefore = toolsBefore;
|
||||
|
||||
await prepareUi(page);
|
||||
const escapeBefore = await escapeEffect(page);
|
||||
report.escapeBefore = escapeBefore;
|
||||
await prepareUi(page);
|
||||
const uiBefore = await readUi(page);
|
||||
report.uiBefore = uiBefore;
|
||||
|
||||
const sequence = ['navara', 'cesium', 'navara', 'cesium', 'navara', 'cesium'];
|
||||
// Dev guard: once an engine is parked, nothing may move its camera or
|
||||
// ask it to draw until it is attached again (its own resume aside).
|
||||
await page.evaluate(() => {
|
||||
window.__qaParkedCalls = [];
|
||||
window.__qaWatchParked = (engine) => {
|
||||
if (!engine || engine.__qaWatched) return;
|
||||
engine.__qaWatched = true;
|
||||
const watch = (owner, names, label) => {
|
||||
for (const name of names) {
|
||||
const original = owner[name];
|
||||
if (typeof original !== 'function') continue;
|
||||
owner[name] = function (...args) {
|
||||
const parked = window.__godsEyeView?.engine !== engine;
|
||||
const resume = name === 'request' && args[0] === 'engine-resume';
|
||||
if (parked && !resume && !window.__qaSwitching)
|
||||
window.__qaParkedCalls.push({
|
||||
call: `${engine.kind}.${label}.${name}`,
|
||||
reason: typeof args[0] === 'string' ? args[0] : null,
|
||||
stack: new Error().stack.split('\n').slice(2, 6).join(' | '),
|
||||
});
|
||||
return original.apply(this, args);
|
||||
};
|
||||
}
|
||||
};
|
||||
watch(
|
||||
engine.camera,
|
||||
['setView', 'flyTo', 'lookAt', 'flyToBoundingSphere', 'release'],
|
||||
'camera',
|
||||
);
|
||||
watch(engine.render, ['request', 'setContinuous'], 'render');
|
||||
};
|
||||
window.__qaWatchParked(window.__godsEyeView.engine);
|
||||
});
|
||||
for (const kind of sequence) {
|
||||
const started = Date.now();
|
||||
// The switch itself parks and resumes engines; only the time an engine
|
||||
// sits parked is watched.
|
||||
await page.evaluate(() => {
|
||||
window.__qaSwitching = true;
|
||||
});
|
||||
await switchEngine(page, kind);
|
||||
await page.evaluate(() => {
|
||||
window.__qaSwitching = false;
|
||||
window.__qaWatchParked(window.__godsEyeView.engine);
|
||||
});
|
||||
const state = await readSession(page);
|
||||
const ui = await readUi(page);
|
||||
if (kind === 'navara')
|
||||
await page.evaluate(() => {
|
||||
window.__qaParkedNavara = window.__godsEyeView.engine;
|
||||
});
|
||||
if (report.switches.length === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
report.firstRoundTrip = await listenerCounts(page, { bySource: true });
|
||||
}
|
||||
// Both engines draw the session's marks and files (Navara included).
|
||||
await page.waitForFunction(
|
||||
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const tools = await readTools(page);
|
||||
check(
|
||||
tools.marks === toolsBefore.marks &&
|
||||
tools.files === toolsBefore.files &&
|
||||
tools.drawShape === toolsBefore.drawShape &&
|
||||
tools.marksDrawn === true &&
|
||||
tools.filesDrawn === 1,
|
||||
`the session tools changed on the switch to ${kind}`,
|
||||
{ toolsBefore, tools },
|
||||
);
|
||||
report.switches.push({
|
||||
kind,
|
||||
ms: Date.now() - started,
|
||||
ids: idDifference(boot.ids, state.ids),
|
||||
counters: state.counters,
|
||||
ui,
|
||||
tools,
|
||||
});
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
|
||||
`session ids changed on the switch to ${kind}`,
|
||||
idDifference(boot.ids, state.ids),
|
||||
);
|
||||
check(
|
||||
ui.value === uiBefore.value,
|
||||
`typed text lost on the switch to ${kind}`,
|
||||
ui,
|
||||
);
|
||||
check(ui.panelOpen, `open panel closed on the switch to ${kind}`, ui);
|
||||
check(
|
||||
ui.focused === uiBefore.focused,
|
||||
`focus moved on the switch to ${kind}`,
|
||||
ui,
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const after = await readSession(page);
|
||||
const afterListeners = await listenerCounts(page, { bySource: true });
|
||||
report.after = after;
|
||||
// After three round trips the page holds exactly the listeners it held
|
||||
// after the first boot, apart from the parked Navara renderer's own (it
|
||||
// stays alive for the switch back), which the first round trip added and
|
||||
// the later ones did not grow.
|
||||
report.listeners = listenerDifference(bootListeners, afterListeners);
|
||||
report.listenersSinceFirstRoundTrip = listenerDifference(
|
||||
report.firstRoundTrip,
|
||||
afterListeners,
|
||||
);
|
||||
const sessionListeners = Object.keys(report.listeners).filter(
|
||||
(key) => !PARKED_RENDERER.test(key),
|
||||
);
|
||||
check(
|
||||
sessionListeners.length === 0,
|
||||
'session listeners on window/document changed after three round trips',
|
||||
report.listeners,
|
||||
);
|
||||
check(
|
||||
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
|
||||
'listeners on window/document grew after the first round trip',
|
||||
report.listenersSinceFirstRoundTrip,
|
||||
);
|
||||
check(
|
||||
after.counters?.attachments === 1 + sequence.length &&
|
||||
after.counters?.detaches === sequence.length,
|
||||
'each switch detaches once and attaches once',
|
||||
after.counters,
|
||||
);
|
||||
report.parkedEngineCalls = await page.evaluate(() => window.__qaParkedCalls);
|
||||
check(
|
||||
report.parkedEngineCalls.length === 0,
|
||||
'a parked engine had its camera moved or a frame requested',
|
||||
report.parkedEngineCalls,
|
||||
);
|
||||
check(
|
||||
after.counters?.applications === 1,
|
||||
'the application must be created once',
|
||||
after.counters,
|
||||
);
|
||||
|
||||
const escapeAfter = await escapeEffect(page);
|
||||
report.escapeAfter = escapeAfter;
|
||||
check(
|
||||
JSON.stringify(escapeAfter) === JSON.stringify(escapeBefore),
|
||||
'Escape behaves differently after the switches',
|
||||
{ escapeBefore, escapeAfter },
|
||||
);
|
||||
|
||||
// A failed candidate: the parked Navara renderer refuses to resume (as in
|
||||
// qa-layer-handoff.mjs). The working Cesium attaches again on the same
|
||||
// session objects.
|
||||
await prepareUi(page);
|
||||
const beforeRollback = await readSession(page);
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await page.evaluate(() => {
|
||||
window.__qaRollback = false;
|
||||
const parked = window.__qaParkedNavara;
|
||||
const request = parked.render.request;
|
||||
parked.render.request = function (...args) {
|
||||
parked.render.request = request;
|
||||
window.__qaRollback = true;
|
||||
throw new Error('QA parked renderer failure');
|
||||
};
|
||||
});
|
||||
const sabotaged = true;
|
||||
if (sabotaged) {
|
||||
await requestEngine(page, 'navara');
|
||||
await page.waitForFunction(() => window.__qaRollback === true, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
const rolledBack = await readSession(page);
|
||||
const ui = await readUi(page);
|
||||
report.rollback = {
|
||||
ids: idDifference(beforeRollback.ids, rolledBack.ids),
|
||||
counters: rolledBack.counters,
|
||||
ui,
|
||||
history: (await page.evaluate(() => history.length)) - historyLength,
|
||||
};
|
||||
check(
|
||||
Object.keys(report.rollback.ids).length === 0,
|
||||
'a rollback changed session ids',
|
||||
report.rollback,
|
||||
);
|
||||
check(
|
||||
report.rollback.history === 0,
|
||||
'a rollback added a history entry',
|
||||
report.rollback,
|
||||
);
|
||||
check(
|
||||
ui.value === uiBefore.value && ui.panelOpen,
|
||||
'a rollback lost the UI state',
|
||||
ui,
|
||||
);
|
||||
// The page still switches after the rollback.
|
||||
await switchEngine(page, 'navara');
|
||||
await switchEngine(page, 'cesium');
|
||||
const final = await readSession(page);
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, final.ids)).length === 0,
|
||||
'ids changed after the rollback',
|
||||
idDifference(boot.ids, final.ids),
|
||||
);
|
||||
|
||||
// A candidate failing after the shell attached to it: a layer's
|
||||
// drawing refuses to attach on Navara. The shell lets go of the failed
|
||||
// globe and Cesium attaches again; the next switch works.
|
||||
await page.evaluate(() => {
|
||||
window.__qaDataRefused = false;
|
||||
const module =
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes').module;
|
||||
const attach = module.attachRenderer;
|
||||
module.attachRenderer = function () {
|
||||
module.attachRenderer = attach;
|
||||
window.__qaDataRefused = true;
|
||||
return Promise.reject(new Error('QA data attach failure'));
|
||||
};
|
||||
});
|
||||
const beforeDataFailure = await page.evaluate(() => history.length);
|
||||
await requestEngine(page, 'navara');
|
||||
await page.waitForFunction(() => window.__qaDataRefused === true, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
const afterDataFailure = await readSession(page);
|
||||
report.dataFailure = {
|
||||
ids: idDifference(boot.ids, afterDataFailure.ids),
|
||||
counters: afterDataFailure.counters,
|
||||
history: (await page.evaluate(() => history.length)) - beforeDataFailure,
|
||||
};
|
||||
check(
|
||||
Object.keys(report.dataFailure.ids).length === 0 &&
|
||||
report.dataFailure.history === 0,
|
||||
'a candidate failing in the data part changed the session',
|
||||
report.dataFailure,
|
||||
);
|
||||
await switchEngine(page, 'navara');
|
||||
await switchEngine(page, 'cesium');
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, (await readSession(page)).ids))
|
||||
.length === 0,
|
||||
'ids changed after the data-part failure',
|
||||
);
|
||||
}
|
||||
report.errors = errors;
|
||||
check(errors.length === 0, 'page errors', errors);
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!reportOnly) console.log('PASS application created once; session kept');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -23,74 +23,6 @@ export function redact(text) {
|
||||
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
|
||||
}
|
||||
|
||||
/** The standalone Vite preview has no setup API; opt in explicitly. */
|
||||
export function isExpectedPreviewSetupError(
|
||||
message,
|
||||
{ preview = false, baseUrl } = {},
|
||||
) {
|
||||
if (
|
||||
!preview ||
|
||||
message.type() !== 'error' ||
|
||||
!/^Failed to load resource: .*\b404\b/.test(message.text())
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const url = new URL(message.location()?.url);
|
||||
return (
|
||||
url.origin === new URL(baseUrl).origin &&
|
||||
url.pathname === '/api/setup/status'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime evidence gate shares one strict runtime verdict across its main page and
|
||||
// every share-link page. Response diagnostics never retain query credentials.
|
||||
export function watchBrowserPage(
|
||||
page,
|
||||
report,
|
||||
{ name, preview = false, baseUrl },
|
||||
) {
|
||||
const errors = (report.browserErrors ??= []);
|
||||
report.expectedPreviewErrors ??= [];
|
||||
report.failedResponses ??= [];
|
||||
(report.runtimePages ??= []).push(name);
|
||||
const resourcePath = (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return redact(url.origin + url.pathname);
|
||||
} catch {
|
||||
return '(location unavailable)';
|
||||
}
|
||||
};
|
||||
page.on('console', (message) => {
|
||||
if (isExpectedPreviewSetupError(message, { preview, baseUrl })) {
|
||||
report.expectedPreviewErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
if (message.type() === 'error')
|
||||
errors.push(
|
||||
`[${name}] ${redact(message.text())} — ${resourcePath(message.location()?.url)}`,
|
||||
);
|
||||
});
|
||||
for (const event of ['pageerror', 'error'])
|
||||
page.on(event, (error) =>
|
||||
errors.push(`[${name}] ${redact(error.message)}`),
|
||||
);
|
||||
page.on('response', (response) => {
|
||||
if (
|
||||
response.status() >= 400 &&
|
||||
report.failedResponses.length < EVIDENCE_LIMIT
|
||||
)
|
||||
report.failedResponses.push({
|
||||
page: name,
|
||||
status: response.status(),
|
||||
resource: resourcePath(response.url()),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function push(list, entry) {
|
||||
list.push(entry);
|
||||
if (list.length > EVIDENCE_LIMIT)
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||
* a. per-frame `scene.render` cost over a ~10 s driven-orbit window
|
||||
* (mean / p50 / p95 / max — relative numbers, SwiftShader surface),
|
||||
* b. toggle-ON → first-labels-visible latency,
|
||||
* c. contract set (ground paths, mover tips, stems) / host-entry counts,
|
||||
* c. entity / native-label / host-entry counts,
|
||||
* d. parked-idle postRender fires over 5 s (render-governor honesty).
|
||||
* 2. GATE (unified build only): assert the migration contract —
|
||||
* no layer-owned Cesium data source (paths, pins and tips are globe
|
||||
* contract sets), host entries
|
||||
* zero native `LabelGraphics` on cable reference entities, host entries
|
||||
* present for the source, idle stays near-zero, and a clean OFF→ON cycle
|
||||
* (no orphan host entries after disable).
|
||||
*
|
||||
@@ -106,48 +105,35 @@ try {
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle
|
||||
}
|
||||
|
||||
// ── c. drawn-set / label / host counts ────────────────────────────────
|
||||
// The layer draws through the globe contract (render/display.js): cable
|
||||
// paths in one engine.ground corridor set, landing points and reference
|
||||
// tips in mover sets, stems in engine.lines, labels on the world-overlay
|
||||
// host. It owns no Cesium DataSource or Entity any more.
|
||||
const counts = await page.evaluate((layerId, legacy) => {
|
||||
// ── c. entity / label / host counts ───────────────────────────────────
|
||||
const counts = await page.evaluate((layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const engine = gev.engine;
|
||||
const out = {
|
||||
layerDataSources: 0, layerEntities: 0,
|
||||
pathSets: 0, pathItems: 0, markers: 0, tipSets: 0, tips: 0, stems: 0,
|
||||
hostEntries: 0, hostPainted: 0,
|
||||
dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0,
|
||||
referenceEntities: 0, hostEntries: 0, hostPainted: 0,
|
||||
};
|
||||
for (let i = 0; i < (v?.dataSources?.length ?? 0); i++) {
|
||||
for (let i = 0; i < v.dataSources.length; i++) {
|
||||
const ds = v.dataSources.get(i);
|
||||
if (!/TeleGeography/.test(ds.name || '')) continue;
|
||||
out.layerDataSources++;
|
||||
out.layerEntities += ds.entities.values.length;
|
||||
}
|
||||
if (legacy) return out;
|
||||
for (const set of engine.ground.list()) {
|
||||
if (set.id !== `${layerId}-paths`) continue;
|
||||
out.pathSets++;
|
||||
out.pathItems += set.count;
|
||||
}
|
||||
for (const set of engine.movers.list()) {
|
||||
if (set.id === `${layerId}-markers`) out.markers += set.count;
|
||||
else if (set.id.startsWith(`${layerId}-`) && set.id.endsWith('-tips')) {
|
||||
out.tipSets++;
|
||||
out.tips += set.count;
|
||||
const list = ds.entities.values;
|
||||
out.dataSources[ds.name] = list.length;
|
||||
for (const e of list) {
|
||||
if (e.label !== undefined) out.nativeLabels++;
|
||||
if (e.position && e.position.isConstant === false) out.dynamicPositionEntities++;
|
||||
if (e.polyline?.positions && e.polyline.positions.isConstant === false) {
|
||||
out.dynamicPositionEntities++;
|
||||
}
|
||||
}
|
||||
if (/References/.test(ds.name)) out.referenceEntities = list.length;
|
||||
}
|
||||
out.stems = (engine.lines.list().find((line) =>
|
||||
line.id === `${layerId}-stems` && line.shown)?.points ?? 0) / 2;
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
out.hostEntries = diag.entriesBySource?.[layerId] || 0;
|
||||
out.hostPainted = diag.paintedBySource?.[layerId] || 0;
|
||||
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
||||
out.referenceLabelCount = stats.referenceLabelCount ?? null;
|
||||
return out;
|
||||
}, LAYER_ID, legacy);
|
||||
}, LAYER_ID);
|
||||
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
|
||||
|
||||
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
|
||||
@@ -203,10 +189,8 @@ try {
|
||||
|
||||
if (!legacy && !control) {
|
||||
// ── unified-build contract gates ────────────────────────────────────
|
||||
// Paths, pins and tips belong to contract sets; labels belong to the host.
|
||||
check('no layer-owned Cesium data source or entities', counts.layerDataSources === 0 && counts.layerEntities === 0, counts);
|
||||
check('cable paths drawn as an engine.ground corridor set', counts.pathSets === 1 && counts.pathItems > 0, counts);
|
||||
check('reference tips drawn as mover sets', counts.tipSets > 0 && counts.tips > 0, counts);
|
||||
check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels);
|
||||
check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities);
|
||||
check('host has cable entries', counts.hostEntries > 0, counts.hostEntries);
|
||||
check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted);
|
||||
// The cables source never republishes an unchanged cohort (unit pin), and
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { installCctvFixture } from './fixtures/layers/cctv.mjs';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
const url = localPreview(process.argv[2]);
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&lat=30.2718&lon=-97.744&alt=900&pitch=-70&heading=0&map=sentinel2&l=';
|
||||
const { stdout: video } = await promisify(execFile)(
|
||||
'ffmpeg',
|
||||
[
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'testsrc2=size=320x180:rate=12',
|
||||
'-t',
|
||||
'60',
|
||||
'-an',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-threads',
|
||||
'1',
|
||||
'-movflags',
|
||||
'frag_keyframe+empty_moov',
|
||||
'-f',
|
||||
'mp4',
|
||||
'pipe:1',
|
||||
],
|
||||
{ encoding: 'buffer', maxBuffer: 16000000 },
|
||||
);
|
||||
const strict = createStrict({ origin: url.origin, layers: ['cctv'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message);
|
||||
});
|
||||
await installCctvFixture(p, { video });
|
||||
let catalogs = 0;
|
||||
p.on('request', (req) => {
|
||||
if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++;
|
||||
});
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await assertKeylessPreview(p);
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await styleManager.initialRestorePromise;
|
||||
await dataManager.setEnabled('cctv', true, { origin: 'user' });
|
||||
window.qaCctv = dataManager.layers.get('cctv').module;
|
||||
window.qaCctv.setParams({
|
||||
selectedCameraId: 'fixture-cctv-video',
|
||||
showProjection: true,
|
||||
coverageMode: 'viewshed',
|
||||
calibration: { patch: { heightM: 31, pitchDeg: 20 } },
|
||||
});
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.qaCctv.getActiveVideoElement()?.videoWidth === 320 &&
|
||||
window.qaCctv.getActiveVideoElement().currentTime > 0.1,
|
||||
{ timeout: 40000 },
|
||||
);
|
||||
const first = await p.evaluate(() => {
|
||||
window.qaVideo = window.qaCctv.getActiveVideoElement();
|
||||
return window.qaVideo.getVideoPlaybackQuality().totalVideoFrames;
|
||||
});
|
||||
let previous = first;
|
||||
const count = catalogs;
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
await p.waitForFunction(
|
||||
(n) =>
|
||||
window.qaCctv.getActiveVideoElement()?.getVideoPlaybackQuality()
|
||||
.totalVideoFrames > n,
|
||||
{},
|
||||
previous,
|
||||
);
|
||||
const v = await p.evaluate(() => ({
|
||||
same:
|
||||
window.qaCctv ===
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module,
|
||||
media: window.qaCctv.getActiveVideoElement() === window.qaVideo,
|
||||
frames: window.qaVideo.getVideoPlaybackQuality().totalVideoFrames,
|
||||
paused: window.qaVideo.paused,
|
||||
params: window.qaCctv.getParams(),
|
||||
plane: !!window.qaCctv
|
||||
._cctvRecordsForTest()
|
||||
.find((r) => r.camera.id === 'fixture-cctv-video').projection
|
||||
?.planeEntity,
|
||||
projection: (() => {
|
||||
const r = window.qaCctv
|
||||
._cctvRecordsForTest()
|
||||
.find((r) => r.camera.id === 'fixture-cctv-video');
|
||||
return {
|
||||
show: r.projection?.planeEntity?.show === true,
|
||||
visible: r.projection?.planeVisible === true,
|
||||
calibration: r.camera.calibration,
|
||||
};
|
||||
})(),
|
||||
}));
|
||||
console.log(
|
||||
kind,
|
||||
v.same,
|
||||
v.media,
|
||||
v.frames,
|
||||
v.plane,
|
||||
v.params.selectedCameraId,
|
||||
v.params.coverageMode,
|
||||
);
|
||||
assert.ok(v.same && v.media && v.plane && !v.paused);
|
||||
assert.equal(v.params.selectedCameraId, 'fixture-cctv-video');
|
||||
assert.equal(v.params.coverageMode, 'viewshed');
|
||||
assert.equal(catalogs, count);
|
||||
await strict.passed(
|
||||
p,
|
||||
'cctv',
|
||||
'same model and video element decoding new frames; textured projection plane; no catalog read',
|
||||
);
|
||||
// The calibration patch (31 m mount, 20° pitch) and the plane's
|
||||
// visibility are kept on the new engine.
|
||||
const { calibration } = v.projection;
|
||||
await strict.check(
|
||||
p,
|
||||
'cctv',
|
||||
'calibration patch kept (31 m, 20° pitch); monitor plane visible',
|
||||
v.projection.show &&
|
||||
v.projection.visible &&
|
||||
calibration?.heightM === 31 &&
|
||||
calibration?.pitchDeg === 20 &&
|
||||
v.params.calibration?.values?.heightM === 31 &&
|
||||
v.params.calibration?.values?.pitchDeg === 20,
|
||||
v.projection,
|
||||
);
|
||||
previous = v.frames;
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS retained CCTV catalog, decoder and textured projection');
|
||||
});
|
||||
+147
-386
@@ -40,10 +40,12 @@
|
||||
* issuing zero transient samples, without touching the save-gated store;
|
||||
* camera-controller inputs restore and parts hide on mode off.
|
||||
*
|
||||
* This legacy harness is the geometry, lifecycle and real-input oracle.
|
||||
* Migration runs use headless Metal and strict runtime error observation.
|
||||
* Screenshots remain visual-review evidence here; a separate pixel gate must
|
||||
* independently prove actual marker, image-plane and video pixels.
|
||||
* SwiftShader caveat (proven in Task 3): under headless software GL, plane
|
||||
* image materials do not visually FILL — screenshotting for a pixel-based
|
||||
* pass/fail is not viable here. All four assertion groups therefore check
|
||||
* entity/counter/localStorage/network STATE (GL-independent), never rendered
|
||||
* pixels. Screenshots are still captured for human visual review but do not
|
||||
* gate the exit code.
|
||||
*
|
||||
* No `cesium` package import for in-page math: the app does not expose a
|
||||
* `window.Cesium` global, so every in-page evaluate() either (a) reads plain
|
||||
@@ -69,17 +71,12 @@
|
||||
* so the coincide check is deterministic whichever camera activates, but it
|
||||
* is no longer needed to avoid a plane/wireframe disagreement.
|
||||
*
|
||||
* Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview]
|
||||
* Routine keyless builds also pass --terrain-globe: assert zero scene floor
|
||||
* samples instead of claiming mesh-floor coverage on a globe-only map.
|
||||
* Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173
|
||||
*
|
||||
* Exit 0 = all asserts passed. Non-zero = a hard failure.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { watchBrowserPage, redact } from './qa-browserEvidence.mjs';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
import { installCctvFixture } from './fixtures/layers/cctv.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -98,33 +95,12 @@ const getOpt = (name, dflt) => {
|
||||
};
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
|
||||
const explicitUrl = getOpt('--url', process.env.QA_BASE_URL);
|
||||
const output = getOpt('--out');
|
||||
if (!explicitUrl || !output)
|
||||
throw new Error('Explicit --url and --out are required');
|
||||
const mapPolicy = { allowPaidMaps: getFlag('--allow-paid-maps') };
|
||||
const terrainGlobe = getFlag('--terrain-globe');
|
||||
if (terrainGlobe && mapPolicy.allowPaidMaps)
|
||||
throw new Error('Terrain-globe QA must keep paid maps blocked');
|
||||
const CCTV_URL = new URL(explicitUrl);
|
||||
if (
|
||||
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||
CCTV_URL.port,
|
||||
)
|
||||
)
|
||||
throw new Error('CCTV QA may not use a reserved port');
|
||||
if (getFlag('--headful')) throw new Error('CCTV migration QA is headless only');
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const CCTV_URL = new URL(getOpt('--url', BASE_URL));
|
||||
CCTV_URL.searchParams.set('welcome', '0');
|
||||
const APP_URL = CCTV_URL.href;
|
||||
const SHOTS_DIR = path.resolve(output);
|
||||
const evidence = { checks: [], browserErrors: [], pass: false };
|
||||
const saveEvidence = () => {
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(SHOTS_DIR, 'result.json'),
|
||||
JSON.stringify(evidence, null, 2) + '\n',
|
||||
);
|
||||
};
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -151,9 +127,8 @@ function findChromeExecutable() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = evidence.checks;
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
detail = redact(detail);
|
||||
results.push({ name, ok, detail });
|
||||
const tag =
|
||||
ok === null
|
||||
@@ -286,111 +261,34 @@ function installRenderFramesHelper(page) {
|
||||
});
|
||||
}
|
||||
|
||||
// In-page helpers (stringified and re-defined inside each evaluate that needs
|
||||
// them, since functions can't cross the puppeteer boundary).
|
||||
//
|
||||
// The frustum wireframe (four corner rays and the far cap) and the gizmo draw
|
||||
// through the globe contract (src/layers/cctv/render/display.js): engine
|
||||
// lines named `cctv-<camera>-<role>` and `cctv-gizmo-<part>`, and the gizmo's
|
||||
// handles in the `cctv-gizmo-points` point set. engine.lines.list() names the
|
||||
// live lines (id, points, shown); their positions are what the layer's own
|
||||
// line handles hold (record.coverageEntities). The monitor plane is a
|
||||
// world mesh, with placement held by the display's plane handle.
|
||||
const CONTRACT_SRC = `
|
||||
function cctvRecord(camId) {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
return (
|
||||
mod._cctvRecordsForTest().find((r) => r.camera && r.camera.id === camId) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
function liveLines() {
|
||||
return new Map(
|
||||
window.__godsEyeView.engine.lines.list().map((line) => [line.id, line]),
|
||||
);
|
||||
}
|
||||
// The positions of camera camId's coverage line 'role', when the engine
|
||||
// draws that line with the handle's points; null otherwise.
|
||||
function coverageLinePositions(camId, role, lines = liveLines()) {
|
||||
const id = 'cctv-' + camId + '-' + role;
|
||||
const line = lines.get(id);
|
||||
const record = cctvRecord(camId);
|
||||
const handle = ((record && record.coverageEntities) || []).find(
|
||||
(h) => h && h.id === id,
|
||||
);
|
||||
const positions = handle && handle.polyline && handle.polyline.positions;
|
||||
if (!line || !positions || line.points !== positions.length) return null;
|
||||
return Array.from(positions, (p) => [p.x, p.y, p.z]);
|
||||
}
|
||||
// A Cesium mover set's instance by pickId (the contract's own primitives:
|
||||
// a collection tagged gevMoverSet in scene.primitives).
|
||||
function moverInstance(setId, pickId) {
|
||||
const walk = (collection) => {
|
||||
if (!collection || typeof collection.get !== 'function') return null;
|
||||
if (collection.gevMoverSet === setId) {
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const item = collection.get(i);
|
||||
if (item && item.id === pickId) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const found = walk(collection.get(i));
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(window.__godsEyeView.viewer.scene.primitives);
|
||||
}
|
||||
// 'shown' | 'hidden' | 'missing' for one gizmo part: its line (rings and
|
||||
// arrow shafts) and its point (arrow tips and handles), as many as it has.
|
||||
function gizmoPartState(part) {
|
||||
const id = 'cctv-gizmo-' + part;
|
||||
const line = liveLines().get(id);
|
||||
const point = moverInstance('cctv-gizmo-points', id);
|
||||
if (!line && !point) return 'missing';
|
||||
const shown = [line && line.shown, point && point.show].filter(
|
||||
(value) => value !== undefined && value !== null,
|
||||
);
|
||||
return shown.every(Boolean) ? 'shown' : 'hidden';
|
||||
}
|
||||
// The layer's viewshed volumes whose engine meshes are still live.
|
||||
function viewshedVolumes() {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const live = new Set(window.__godsEyeView.engine.meshes.list());
|
||||
const out = [];
|
||||
for (const record of mod._cctvRecordsForTest()) {
|
||||
const volume = record.viewshedPrimitive;
|
||||
const primitive = volume && !volume.destroyed ? volume.mesh : null;
|
||||
if (primitive && live.has(primitive.id))
|
||||
out.push({ camera: record.camera.id, volume, primitive });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
`;
|
||||
|
||||
// serializeGeom: the 5 frustum polylines + plane position/orientation/
|
||||
// dimensions for a camera as plain arrays of numbers. The plane's properties
|
||||
// are read at the viewer's own clock.currentTime (Property.getValue just needs
|
||||
// *a* JulianDate, and that is a valid live instance of one).
|
||||
// In-page helper (stringified and re-defined inside each evaluate that needs
|
||||
// it, since functions can't cross the puppeteer boundary): serializes the
|
||||
// 5 frustum polylines + plane position/orientation/dimensions for a camera
|
||||
// into plain arrays of numbers, using only the camera's own clock time (no
|
||||
// Cesium namespace needed — Property.getValue just needs *a* JulianDate,
|
||||
// and the viewer's own clock.currentTime is a valid live instance of one).
|
||||
const SERIALIZE_GEOM_SRC = `
|
||||
${CONTRACT_SRC}
|
||||
function serializeGeom(camId) {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const roles = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap'];
|
||||
const lines = liveLines();
|
||||
const poly = {};
|
||||
for (const role of roles)
|
||||
poly[role] = coverageLinePositions(camId, role, lines);
|
||||
const planeEnt = cctvRecord(camId)?.projection?.planeEntity;
|
||||
const live = window.__godsEyeView.engine.meshes.list().includes(planeEnt?.id);
|
||||
for (const role of roles) {
|
||||
const ent = viewer.entities.getById('cctv-' + camId + '-' + role);
|
||||
poly[role] = ent && ent.polyline
|
||||
? ent.polyline.positions.getValue(time).map((p) => [p.x, p.y, p.z])
|
||||
: null;
|
||||
}
|
||||
const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane');
|
||||
let plane = null;
|
||||
if (live) {
|
||||
const { positions, orientation: orient, shape } = planeEnt.mesh;
|
||||
const pos = positions.capCenter;
|
||||
if (planeEnt) {
|
||||
const pos = planeEnt.position.getValue(time);
|
||||
const orient = planeEnt.orientation.getValue(time);
|
||||
const dims = planeEnt.plane.dimensions.getValue(time);
|
||||
plane = {
|
||||
position: [pos.x, pos.y, pos.z],
|
||||
orientation: [orient.x, orient.y, orient.z, orient.w],
|
||||
dimensions: [shape.halfW * 2, shape.halfH * 2],
|
||||
dimensions: [dims.x, dims.y],
|
||||
};
|
||||
}
|
||||
return { poly, plane };
|
||||
@@ -527,24 +425,24 @@ function findSafeCalibrationPatch(basePose, groundAltM) {
|
||||
|
||||
async function main() {
|
||||
console.log(`\nCCTV v2 Subsystem Proof`);
|
||||
console.log(` App URL : ${redact(APP_URL)}`);
|
||||
console.log(' Mode : headless Metal\n');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
redact(`Dev server not reachable: ${e.message}`),
|
||||
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
|
||||
);
|
||||
throw new Error('CCTV prerequisite unavailable');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
||||
// Talk CDP over a pipe, so no debugging port stays open on localhost for
|
||||
// the length of a headful run.
|
||||
@@ -553,46 +451,32 @@ async function main() {
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-angle=metal',
|
||||
'--enable-gpu',
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = evidence.browserErrors;
|
||||
const consoleErrors = [];
|
||||
let exitCode = 0;
|
||||
let fixture = null;
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await blockPaidQaMaps(page, mapPolicy);
|
||||
const terrainRequests = new Set();
|
||||
let terrainActivityAt = 0;
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname !== '/api/terrain/heights') return;
|
||||
terrainRequests.add(request);
|
||||
terrainActivityAt = Date.now();
|
||||
});
|
||||
for (const event of ['requestfinished', 'requestfailed'])
|
||||
page.on(event, (request) => {
|
||||
if (terrainRequests.delete(request)) terrainActivityAt = Date.now();
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
watchBrowserPage(page, evidence, {
|
||||
name: 'cctv-legacy',
|
||||
baseUrl: APP_URL,
|
||||
preview: getFlag('--preview'),
|
||||
});
|
||||
fixture = getFlag('--fixture')
|
||||
? await installCctvFixture(page)
|
||||
: null;
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const t = msg.text();
|
||||
if (
|
||||
!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(
|
||||
t,
|
||||
)
|
||||
) {
|
||||
consoleErrors.push(t);
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
@@ -608,35 +492,6 @@ async function main() {
|
||||
);
|
||||
if (!appReady) throw new Error('app did not initialise within 60s');
|
||||
await installRenderFramesHelper(page);
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.styleManager.initialRestorePromise;
|
||||
app.styleManager.orbitController.stop();
|
||||
for (const [id, layer] of app.dataManager.layers)
|
||||
if (layer.enabled) await app.dataManager.setEnabled(id, false);
|
||||
app.styleManager._setDetectionMode('OFF');
|
||||
});
|
||||
evidence.surface = await page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
return {
|
||||
map: app.mapStackController.getActiveId(),
|
||||
globeVisible: app.viewer.scene.globe.show,
|
||||
tileset: Boolean(app.tileset),
|
||||
googleKeyPresent: Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||||
photorealAvailable:
|
||||
app.mapStackController.isStackAvailable('photoreal'),
|
||||
};
|
||||
});
|
||||
evidence.surfaceMode = terrainGlobe ? 'terrain-globe' : 'mesh-floor';
|
||||
if (terrainGlobe) {
|
||||
const keyless =
|
||||
evidence.surface.globeVisible === true &&
|
||||
!evidence.surface.tileset &&
|
||||
!evidence.surface.googleKeyPresent &&
|
||||
!evidence.surface.photorealAvailable;
|
||||
record('keyless terrain-globe startup', keyless);
|
||||
if (!keyless) throw new Error('Expected a keyless globe before CCTV QA');
|
||||
}
|
||||
// Let the initial fly-to Austin and first tiles settle.
|
||||
await sleep(4000);
|
||||
|
||||
@@ -674,7 +529,7 @@ async function main() {
|
||||
console.error(
|
||||
'\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m',
|
||||
);
|
||||
throw new Error('CCTV prerequisite unavailable');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const readCounters = () =>
|
||||
@@ -702,20 +557,9 @@ async function main() {
|
||||
// ~800ms per real ground sample measured under SwiftShader (each
|
||||
// scene.sampleHeight forces tile loads at the probe point) — real GPU is
|
||||
// far faster, so this is a headless-CI ceiling, not an app expectation.
|
||||
evidence.cameraIds = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState()
|
||||
.cameras.map(({ id }) => id)
|
||||
.sort(),
|
||||
);
|
||||
if (fixture)
|
||||
record(
|
||||
'real catalog parser retains every fixture camera ID',
|
||||
JSON.stringify(evidence.cameraIds) ===
|
||||
JSON.stringify(fixture.cameras.map(({ id }) => id).sort()),
|
||||
);
|
||||
const drainBudgetMs = Math.max(30000, camCount * 800 + 30000);
|
||||
const drainBudgetMs = HEADFUL
|
||||
? Math.max(30000, camCount * 800 + 30000)
|
||||
: Math.min(120000, Math.max(30000, camCount * 800 + 30000));
|
||||
console.log(
|
||||
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
||||
);
|
||||
@@ -730,10 +574,12 @@ async function main() {
|
||||
);
|
||||
record(
|
||||
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
||||
drained,
|
||||
drained ? true : HEADFUL ? false : null,
|
||||
drained
|
||||
? 'loading.active === false'
|
||||
: 'timed out waiting for the real-GPU geometry drain',
|
||||
: HEADFUL
|
||||
? 'timed out waiting for drain on the real-GPU sign-off path'
|
||||
: 'headless GL geometry sampling did not settle; current headful evidence is required',
|
||||
);
|
||||
|
||||
const cAfterDrain = await readCounters();
|
||||
@@ -771,7 +617,6 @@ async function main() {
|
||||
.activeCameraId,
|
||||
);
|
||||
|
||||
evidence.activeCameraId = activeId;
|
||||
const pickDeltaActivation =
|
||||
cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay;
|
||||
record(
|
||||
@@ -815,32 +660,9 @@ async function main() {
|
||||
// (existence, not just ceiling). The wait below tolerates the update-tick
|
||||
// latency of the completion pass (update interval is 10s).
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||
let sampleFloorOk = false;
|
||||
if (tilesSeenLoaded && !terrainGlobe) {
|
||||
sampleFloorOk = await page
|
||||
.waitForFunction(
|
||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||
{ timeout: 30000 },
|
||||
c0.sampleHeight,
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
const cFloor = await readCounters();
|
||||
record(
|
||||
terrainGlobe
|
||||
? 'terrain-globe resolves from priors with zero scene ground samples'
|
||||
: '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||
terrainGlobe
|
||||
? cFloor.sampleHeight - c0.sampleHeight === 0
|
||||
: tilesSeenLoaded && sampleFloorOk,
|
||||
tilesSeenLoaded
|
||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||
const globeMode = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
|
||||
);
|
||||
const globeMode = await page.evaluate(() => window.__godsEyeView.viewer.scene.globe?.show !== false);
|
||||
if (globeMode) {
|
||||
// Globe stacks intentionally perform no scene.sampleHeight query.
|
||||
// Require independently fetched DEM heights and the actual rendered
|
||||
@@ -869,18 +691,14 @@ async function main() {
|
||||
(await waitForPredicate(
|
||||
page,
|
||||
(expected, base) => {
|
||||
const { viewer, dataManager, engine } = window.__godsEyeView;
|
||||
const module = dataManager.layers.get('cctv').module;
|
||||
const camera = module.getUIState().activeCamera;
|
||||
// The corner ray is an engine line; its first point (the mount)
|
||||
// is what the layer's line handle gave it.
|
||||
const id = `cctv-${camera.id}-ray-tl`;
|
||||
const drawn = engine.lines.list().some((line) => line.id === id);
|
||||
const ray = module
|
||||
._cctvRecordsForTest()
|
||||
.find((record) => record.camera?.id === camera.id)
|
||||
?.coverageEntities?.find((handle) => handle?.id === id);
|
||||
const mount = drawn ? ray?.polyline?.positions?.[0] : undefined;
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
const camera = dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState().activeCamera;
|
||||
const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`);
|
||||
const mount = ray?.polyline?.positions.getValue(
|
||||
viewer.clock.currentTime,
|
||||
)?.[0];
|
||||
const height =
|
||||
mount &&
|
||||
viewer.scene.globe.ellipsoid.cartesianToCartographic(mount)
|
||||
@@ -1031,7 +849,7 @@ async function main() {
|
||||
// source. Assert the migrated surface via the host diagnostics.
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.();
|
||||
g.hasLabel = (diag?.entriesBySource?.['cctv-projection'] || 0) >= 1;
|
||||
const planeEnt = cctvRecord(camId)?.projection?.planeEntity;
|
||||
const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane');
|
||||
g.planeShow = planeEnt ? planeEnt.show : null;
|
||||
return g;
|
||||
})(${JSON.stringify(activeId)})`);
|
||||
@@ -1044,14 +862,14 @@ async function main() {
|
||||
'cap',
|
||||
].every((r) => Array.isArray(geomInfo.poly[r]));
|
||||
record(
|
||||
'all 5 frustum lines are drawn for the active camera (engine lines)',
|
||||
'all 5 frustum polyline entities exist for the active camera',
|
||||
allPolylinesPresent,
|
||||
Object.keys(geomInfo.poly)
|
||||
.map((k) => `${k}=${geomInfo.poly[k] ? 'ok' : 'MISSING'}`)
|
||||
.join(', '),
|
||||
);
|
||||
record(
|
||||
'plane mesh and label exist for the active camera',
|
||||
'plane + plane-label entities exist for the active camera',
|
||||
!!geomInfo.plane && geomInfo.hasLabel,
|
||||
`plane=${!!geomInfo.plane} label=${geomInfo.hasLabel} show=${geomInfo.planeShow}`,
|
||||
);
|
||||
@@ -1216,27 +1034,8 @@ async function main() {
|
||||
return serializeGeom(camId);
|
||||
})(${JSON.stringify(camId)})`);
|
||||
const geomBefore30s = await serializeGeom(activeId);
|
||||
await sleep(28000);
|
||||
evidence.staticIdle = await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js');
|
||||
let frames = 0;
|
||||
const remove = app.engine.frames.postRender.addEventListener(() => frames++);
|
||||
const started = performance.now();
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
remove();
|
||||
return { frames, durationMs: performance.now() - started,
|
||||
governor: getRenderGovernorDiagnostics(app.engine) };
|
||||
});
|
||||
await sleep(30000);
|
||||
const geomAfter30s = await serializeGeom(activeId);
|
||||
evidence.geometry = {
|
||||
initial: geomInfo,
|
||||
beforeIdle: geomBefore30s,
|
||||
afterIdle: geomAfter30s,
|
||||
};
|
||||
evidence.countersAfterIdle = await readCounters();
|
||||
record('static image projection settles to zero frames over 2s',
|
||||
evidence.staticIdle.frames === 0, JSON.stringify(evidence.staticIdle));
|
||||
const geomStable =
|
||||
JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s);
|
||||
record(
|
||||
@@ -1408,7 +1207,7 @@ async function main() {
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Group 4: frame-loop health (state oracle; media pixels have a separate gate)
|
||||
// Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat)
|
||||
// =========================================================================
|
||||
console.log(
|
||||
'Checking frame-loop health (placeholder -> frame swap, URL family)...',
|
||||
@@ -1419,11 +1218,15 @@ async function main() {
|
||||
// never null/undefined once the runtime exists, i.e. there's no gap where
|
||||
// the plane shows nothing.
|
||||
const materialInfo = await page.evaluate((camId) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const runtime = mod._cctvRecordsForTest().find((r) => r.camera.id === camId)?.projection;
|
||||
const planeEnt = runtime?.planeEntity;
|
||||
const mat = runtime?.planeMaterial;
|
||||
const resolved = mat?.image;
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane');
|
||||
const mat = planeEnt?.plane?.material;
|
||||
const image = mat?.image;
|
||||
const resolved =
|
||||
image && typeof image.getValue === 'function'
|
||||
? image.getValue(time)
|
||||
: image;
|
||||
return {
|
||||
hasMaterial: !!mat,
|
||||
hasImage: !!resolved,
|
||||
@@ -1485,9 +1288,8 @@ async function main() {
|
||||
const scene = viewer.scene;
|
||||
const canvas = scene.canvas;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const { hitTest } = (
|
||||
await import('/src/overlays/worldOverlay.js')
|
||||
).worldOverlayFor(window.__godsEyeView.engine);
|
||||
const { hitTestWorldOverlay } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const candidates = [
|
||||
[0.55, 0.72],
|
||||
[0.45, 0.72],
|
||||
@@ -1509,7 +1311,7 @@ async function main() {
|
||||
const x = Math.round(canvas.clientWidth * fx);
|
||||
const y = Math.round(canvas.clientHeight * fy);
|
||||
const picked = scene.pick({ x, y });
|
||||
const card = hitTest(x, y, { sourceId: 'cctv' });
|
||||
const card = hitTestWorldOverlay(x, y, { sourceId: 'cctv' });
|
||||
const top = document.elementFromPoint(rect.left + x, rect.top + y);
|
||||
if (
|
||||
resolveId(picked) === null &&
|
||||
@@ -1662,18 +1464,25 @@ async function main() {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const scene = viewer.scene;
|
||||
const canvasPoint = { x: emptyPoint.canvasX, y: emptyPoint.canvasY };
|
||||
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const canvasPoint = new Cesium.Cartesian2(
|
||||
emptyPoint.canvasX,
|
||||
emptyPoint.canvasY,
|
||||
);
|
||||
const ray = viewer.camera.getPickRay(canvasPoint);
|
||||
if (!ray) return null;
|
||||
let position = null;
|
||||
let projected = null;
|
||||
for (const distance of [1_000, 10_000, 100_000, 1_000_000]) {
|
||||
const candidate = ray.constructor.getPoint(
|
||||
const candidate = Cesium.Ray.getPoint(
|
||||
ray,
|
||||
distance,
|
||||
new ray.origin.constructor(),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
candidate,
|
||||
);
|
||||
const screen = scene.cartesianToCanvasCoordinates(candidate);
|
||||
if (
|
||||
screen &&
|
||||
Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2
|
||||
@@ -1689,7 +1498,7 @@ async function main() {
|
||||
position,
|
||||
point: {
|
||||
pixelSize: 64,
|
||||
color: new scene.backgroundColor.constructor(1, 0, 1, 1),
|
||||
color: Cesium.Color.MAGENTA,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
@@ -1728,7 +1537,7 @@ async function main() {
|
||||
!siblingTarget
|
||||
? null
|
||||
: siblingOwnsPick ||
|
||||
(siblingRendererStalled ? null : false),
|
||||
(siblingRendererStalled && !HEADFUL ? null : false),
|
||||
siblingTarget
|
||||
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||
: 'no previously verified empty canvas ray was available',
|
||||
@@ -1855,40 +1664,17 @@ async function main() {
|
||||
console.log(
|
||||
'Checking viewshed mode (coverage tri-state + color-coded volumes)...',
|
||||
);
|
||||
// Every layer volume owns one engine mesh.
|
||||
const countVolumes = () =>
|
||||
page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
return viewshedVolumes().length;
|
||||
})()`);
|
||||
page.evaluate(() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
let n = 0;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed) n += 1;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
const cBeforeViewshed = await readCounters();
|
||||
await page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
const meshes = window.__godsEyeView.engine.meshes;
|
||||
const events = [];
|
||||
window.__qaViewshedTrace = events;
|
||||
const create = meshes.createMesh.bind(meshes);
|
||||
meshes.createMesh = (options) => {
|
||||
const primitive = create(options);
|
||||
if (options.id.startsWith('cctv-volume-')) {
|
||||
const record = (method) => events.push({ method, primitive, at: performance.now(), stack: new Error().stack });
|
||||
record('add');
|
||||
const destroy = primitive.destroy.bind(primitive);
|
||||
primitive.destroy = () => { record('remove'); destroy(); };
|
||||
}
|
||||
return primitive;
|
||||
};
|
||||
window.__qaViewshedTraceRead = () => {
|
||||
const owners = new Map(
|
||||
viewshedVolumes().map((entry) => [entry.primitive, entry.camera]),
|
||||
);
|
||||
return events.map(({ primitive, ...event }) => ({
|
||||
...event,
|
||||
camera: owners.get(primitive) ?? null,
|
||||
}));
|
||||
};
|
||||
})()`);
|
||||
const modeAfterSet = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
mod.setParams({ coverageMode: 'viewshed' });
|
||||
@@ -1939,35 +1725,27 @@ async function main() {
|
||||
mod.setParams({ coverageMode: 'viewshed' });
|
||||
});
|
||||
await sleep(400);
|
||||
// NEXT starts an asynchronous footprint-height lookup. Its completion is
|
||||
// allowed to update geometry; the unchanged 8s idle window starts after it.
|
||||
const terrainDeadline = Date.now() + 35000;
|
||||
while (
|
||||
(terrainRequests.size || Date.now() - terrainActivityAt < 250) &&
|
||||
Date.now() < terrainDeadline
|
||||
)
|
||||
await sleep(50);
|
||||
const terrainSettled =
|
||||
terrainRequests.size === 0 && Date.now() - terrainActivityAt >= 250;
|
||||
record('footprint requests settle before viewshed idle', terrainSettled);
|
||||
if (!terrainSettled) throw new Error('Footprint requests did not settle');
|
||||
await page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
window.__qaViewshedRefs = viewshedVolumes().map((entry) => entry.primitive);
|
||||
})()`);
|
||||
await page.evaluate(() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
window.__qaViewshedRefs = [];
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed)
|
||||
window.__qaViewshedRefs.push(prims.get(i));
|
||||
}
|
||||
});
|
||||
await sleep(8000);
|
||||
const viewshedIdle = await page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
const now = viewshedVolumes().map((entry) => entry.primitive);
|
||||
const viewshedIdle = await page.evaluate(() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
const now = [];
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i));
|
||||
}
|
||||
const before = window.__qaViewshedRefs || [];
|
||||
const sameCount = now.length === before.length;
|
||||
const sameIdentity = sameCount && before.every((p) => now.includes(p));
|
||||
return { sameCount, sameIdentity, count: now.length };
|
||||
})()`);
|
||||
});
|
||||
const cAfterViewshed = await readCounters();
|
||||
evidence.viewshedTrace = await page.evaluate(() =>
|
||||
window.__qaViewshedTraceRead?.(),
|
||||
);
|
||||
const lateFloorSamples =
|
||||
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
|
||||
record(
|
||||
@@ -1994,11 +1772,6 @@ async function main() {
|
||||
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
||||
// =========================================================================
|
||||
console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...');
|
||||
// NEXT above selects a different catalog camera without moving the view.
|
||||
// Frame that actual camera before requiring an on-screen physical drag.
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv')
|
||||
.module.setParams({ focusSelected: true, focusDurationSec: 0.2 }));
|
||||
await sleep(800);
|
||||
const GIZMO_PARTS = [
|
||||
'ring-heading',
|
||||
'ring-pitch',
|
||||
@@ -2009,12 +1782,15 @@ async function main() {
|
||||
'handle-fov-l',
|
||||
'handle-fov-r',
|
||||
];
|
||||
const gizmoStates = await page.evaluate(`(function(parts){
|
||||
${CONTRACT_SRC}
|
||||
const gizmoStates = await page.evaluate((parts) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
mod.setParams({ calibrationMode: true });
|
||||
return parts.map(gizmoPartState);
|
||||
})(${JSON.stringify(GIZMO_PARTS)})`);
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
return parts.map((p) => {
|
||||
const e = viewer.entities.getById('cctv-gizmo-' + p);
|
||||
return e ? (e.show ? 'shown' : 'hidden') : 'missing';
|
||||
});
|
||||
}, GIZMO_PARTS);
|
||||
record(
|
||||
'all 8 gizmo parts exist and show in ADJUST mode',
|
||||
gizmoStates.every((s) => s === 'shown'),
|
||||
@@ -2024,20 +1800,11 @@ async function main() {
|
||||
// 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.
|
||||
// The E arrow's shaft runs from the active camera's mount to its tip,
|
||||
// the `cctv-gizmo-move-east` point in the gizmo's point set.
|
||||
const eastDrag = await page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
const eastDrag = await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const active = mod.getUIState().activeCameraId;
|
||||
const mount = cctvRecord(active)?.frustumPositions?.mount;
|
||||
const tip = moverInstance('cctv-gizmo-points', 'cctv-gizmo-move-east');
|
||||
if (!mount || !tip || gizmoPartState('move-east') !== 'shown') return null;
|
||||
const pts = [
|
||||
new tip.position.constructor(mount.x, mount.y, mount.z),
|
||||
tip.position,
|
||||
];
|
||||
const e = viewer.entities.getById('cctv-gizmo-move-east');
|
||||
if (!e) return null;
|
||||
const pts = e.polyline.positions.getValue(viewer.clock.currentTime);
|
||||
const scene = viewer.scene;
|
||||
const w = scene.canvas.clientWidth;
|
||||
const h = scene.canvas.clientHeight;
|
||||
@@ -2066,7 +1833,7 @@ async function main() {
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
return { ...candidates[0], candidates, ux: dx / mag, uy: dy / mag };
|
||||
})()`);
|
||||
});
|
||||
|
||||
const calBeforeDrag = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
@@ -2253,7 +2020,7 @@ async function main() {
|
||||
record(
|
||||
'east-arrow drag changes the east offset (live, unsaved)',
|
||||
null,
|
||||
`${missDetail} — required physical drag evidence is missing from this headless Metal run`,
|
||||
`${missDetail} — verify on a current headful GPU surface`,
|
||||
);
|
||||
record(
|
||||
'east-arrow drag keeps mount elevation frozen until mouse-up',
|
||||
@@ -2277,7 +2044,7 @@ async function main() {
|
||||
);
|
||||
} else {
|
||||
const unavailable =
|
||||
'no on-screen east-arrow shaft found in the headless Metal view';
|
||||
'no on-screen east-arrow shaft found under this GL stack — verify on real GPU';
|
||||
record(
|
||||
'east-arrow drag changes the east offset (live, unsaved)',
|
||||
null,
|
||||
@@ -2311,13 +2078,16 @@ async function main() {
|
||||
);
|
||||
|
||||
// ADJUST off: parts hidden; leave a clean calibration for the next group.
|
||||
const gizmoOff = await page.evaluate(`(function(parts){
|
||||
${CONTRACT_SRC}
|
||||
const gizmoOff = await page.evaluate((parts) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
mod.setParams({ calibrationMode: false });
|
||||
mod.setParams({ calibration: { reset: true } });
|
||||
return parts.map(gizmoPartState);
|
||||
})(${JSON.stringify(GIZMO_PARTS)})`);
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
return parts.map((p) => {
|
||||
const e = viewer.entities.getById('cctv-gizmo-' + p);
|
||||
return e ? (e.show ? 'shown' : 'hidden') : 'missing';
|
||||
});
|
||||
}, GIZMO_PARTS);
|
||||
record(
|
||||
'gizmo parts hide when ADJUST mode turns off',
|
||||
gizmoOff.every((s) => s === 'hidden'),
|
||||
@@ -2325,8 +2095,8 @@ async function main() {
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshots supplement this state/geometry oracle. Positive rendered
|
||||
// media pixels are a separate mandatory pixel gate.
|
||||
// Screenshots for human visual review (NOT a pass/fail signal — SwiftShader
|
||||
// cannot fill plane image materials under headless software GL).
|
||||
// -----------------------------------------------------------------------
|
||||
await sleep(1000);
|
||||
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
||||
@@ -2340,16 +2110,10 @@ async function main() {
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.ok !== true) exitCode = 1;
|
||||
if (r.ok === false) exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await fixture?.dispose();
|
||||
await browser.close();
|
||||
evidence.pass =
|
||||
results.length > 0 &&
|
||||
results.every((r) => r.ok === true) &&
|
||||
!consoleErrors.length;
|
||||
saveEvidence();
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
@@ -2364,9 +2128,6 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
evidence.pass = false;
|
||||
evidence.error = redact(e?.stack || e);
|
||||
saveEvidence();
|
||||
console.error('Harness error:', evidence.error);
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
|
||||
@@ -30,7 +30,7 @@ const browser = await puppeteer.launch({
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await blockPaidQaMaps(page);
|
||||
await stubPlanespotters(page);
|
||||
const failures = [];
|
||||
const consoleErrors = [];
|
||||
const localHttpErrors = [];
|
||||
@@ -827,9 +827,9 @@ try {
|
||||
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
|
||||
// assertion when camera motion supersedes the manager's initial update.
|
||||
const readinessPage = await browser.newPage();
|
||||
await stubPlanespotters(readinessPage);
|
||||
let deferredInstallationReadiness;
|
||||
try {
|
||||
await blockPaidQaMaps(readinessPage);
|
||||
await readinessPage.setViewport({ width: 1440, height: 900 });
|
||||
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
await readinessPage.setRequestInterception(true);
|
||||
@@ -1153,14 +1153,6 @@ try {
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const startsClear = await page.evaluate(() => {
|
||||
const hidden = ['left-panel-stack', 'intel-hud', 'corner-actions',
|
||||
'cockpit-context', 'cockpit-signal-stream', 'cockpit-utility-controls'];
|
||||
return hidden.every((id) => document.getElementById(id)?.getClientRects().length === 0)
|
||||
&& document.getElementById('cockpit-speed-value').getClientRects().length > 0;
|
||||
});
|
||||
check('Cockpit starts with flight instruments and hides the map panels', startsClear);
|
||||
await page.click('#cockpit-panels-toggle');
|
||||
const firstCockpitContact = await page.evaluate(() => {
|
||||
const awareness = window.__godsEyeView.dataManager.layers
|
||||
.get('military-awareness')?.module;
|
||||
@@ -1522,9 +1514,6 @@ try {
|
||||
const before = read();
|
||||
document.getElementById('cockpit-entry')?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const startsClear = document.getElementById('cockpit-panels-toggle').getAttribute('aria-pressed') === 'false';
|
||||
document.getElementById('cockpit-panels-toggle').click();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const during = read();
|
||||
manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
@@ -1545,7 +1534,6 @@ try {
|
||||
before,
|
||||
during,
|
||||
after: read(),
|
||||
startsClear,
|
||||
exited,
|
||||
clearancePx,
|
||||
contactAutoCollapsed,
|
||||
@@ -1555,7 +1543,7 @@ try {
|
||||
});
|
||||
check(
|
||||
'Cockpit temporarily collapses standard panels and restores the exact map layout on exit',
|
||||
cockpitPanelRoundTrip.exited && cockpitPanelRoundTrip.startsClear
|
||||
cockpitPanelRoundTrip.exited
|
||||
&& Object.values(cockpitPanelRoundTrip.during).every(Boolean)
|
||||
&& JSON.stringify(cockpitPanelRoundTrip.after)
|
||||
=== JSON.stringify(cockpitPanelRoundTrip.before)
|
||||
@@ -1620,7 +1608,6 @@ try {
|
||||
const states = [read()];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
current.click();
|
||||
await Promise.resolve();
|
||||
states.push(read());
|
||||
}
|
||||
const originalStyle = manager.activeStyle;
|
||||
@@ -1632,10 +1619,8 @@ try {
|
||||
const enteredOnNoir = await reenter();
|
||||
const entryNoir = read();
|
||||
cockpit.setVisionMode('optical');
|
||||
await Promise.resolve();
|
||||
const normalInsideNoir = read();
|
||||
cockpit.setVisionMode('crt');
|
||||
await Promise.resolve();
|
||||
const cockpitOnlyCrt = read();
|
||||
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
|
||||
await settle();
|
||||
@@ -1710,9 +1695,6 @@ try {
|
||||
await page.screenshot({ path: path.join(shotsDir, 'vision-noir.png') });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('optical'));
|
||||
|
||||
await page.$eval('#cockpit-panels-toggle', button => {
|
||||
if (button.getAttribute('aria-pressed') === 'false') button.click();
|
||||
});
|
||||
const desktopState = async (variant, openKind) => page.evaluate(async ({ variantName, kind }) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const hud = document.getElementById('cockpit-hud');
|
||||
@@ -1898,7 +1880,7 @@ try {
|
||||
// the wall Cockpit actually solves against — not by injecting an anchor.
|
||||
// The card is bottom-anchored, so an explicit height moves its top edge.
|
||||
const minTop = Math.max(96, window.innerHeight * 0.12);
|
||||
const moveSignalTopTo = async (wantTop) => {
|
||||
const moveSignalTopTo = (wantTop) => {
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const rect = signal.getBoundingClientRect();
|
||||
const delta = wantTop - rect.top;
|
||||
@@ -1911,11 +1893,9 @@ try {
|
||||
// the now-shorter strip, so idempotence is asserted where the strip's
|
||||
// composition is unchanged.)
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
await Promise.resolve();
|
||||
const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top');
|
||||
const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only');
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
await Promise.resolve();
|
||||
return {
|
||||
firstPassTop,
|
||||
firstPassPrimaryOnly,
|
||||
@@ -1926,7 +1906,6 @@ try {
|
||||
signal.style.maxHeight = 'none';
|
||||
signal.style.height = '150px';
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
await Promise.resolve();
|
||||
const expandedHeight = Math.max(displayControl.scrollHeight, displayControl.getBoundingClientRect().height);
|
||||
const collapsedHeight = Math.max(50, radioControl.scrollHeight);
|
||||
const stripHeight = expandedHeight + collapsedHeight + 7;
|
||||
@@ -1951,11 +1930,11 @@ try {
|
||||
// because 150 px of card leaves a corridor sized for the OLD strip. Seed from
|
||||
// the measured requirement instead, which holds for any future panel height,
|
||||
// and assert the precondition rather than assuming it.
|
||||
const roomySeed = await moveSignalTopTo(exactSignalTop + 80);
|
||||
const roomySeed = moveSignalTopTo(exactSignalTop + 80);
|
||||
const roomySeedExpanded = !utility.classList.contains('layout-primary-only')
|
||||
&& roomySeed.firstPassPrimaryOnly === false;
|
||||
const exact = {
|
||||
...await moveSignalTopTo(exactSignalTop),
|
||||
...moveSignalTopTo(exactSignalTop),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||
@@ -1965,7 +1944,7 @@ try {
|
||||
const constrained = {
|
||||
// One pixel on the far side of the same fit (`exactSignalTop` already sits
|
||||
// one pixel inside it), so this is still the one-pixel constraint it claims.
|
||||
...await moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
|
||||
...moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingDisplay: getComputedStyle(radioControl).display,
|
||||
@@ -1976,7 +1955,7 @@ try {
|
||||
<= signal.getBoundingClientRect().top - 7,
|
||||
};
|
||||
const restored = {
|
||||
...await moveSignalTopTo(exactSignalTop + 80),
|
||||
...moveSignalTopTo(exactSignalTop + 80),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['military-awareness', 'military-installations'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, options) => {
|
||||
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
||||
if (pathname === '/api/vessels')
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
status: 'live',
|
||||
rows: ['123456789', '223456789'].map((mmsi, i) => ({
|
||||
mmsi,
|
||||
name: 'Toronto ' + i,
|
||||
lat: 43.65,
|
||||
lon: -79.38 + i * 0.05,
|
||||
last_position_epoch: Date.now() / 1000,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
// Contacts reads air quality near the subject; no station here.
|
||||
if (
|
||||
new URL(input.url ?? input, document.baseURI).hostname ===
|
||||
'api.weather.gc.ca'
|
||||
)
|
||||
return Promise.resolve(
|
||||
Response.json({ type: 'FeatureCollection', features: [] }),
|
||||
);
|
||||
if (pathname === '/api/vessels/track')
|
||||
return Promise.resolve(Response.json({ samples: [] }));
|
||||
if (pathname === '/api/flights')
|
||||
return Promise.resolve(
|
||||
Response.json({ time: Date.now() / 1000, states: [] }),
|
||||
);
|
||||
if (pathname === '/api/military')
|
||||
return Promise.resolve(Response.json({ now: Date.now(), ac: [] }));
|
||||
// One mapped station next to the Contacts subject, with a footprint.
|
||||
if (pathname === '/api/military-installations') {
|
||||
window.qaInstallationReads = (window.qaInstallationReads ?? 0) + 1;
|
||||
const ring = [
|
||||
[43.665, -79.345],
|
||||
[43.665, -79.315],
|
||||
[43.68, -79.315],
|
||||
[43.68, -79.345],
|
||||
[43.665, -79.345],
|
||||
];
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
status: 'ok',
|
||||
elements: [
|
||||
{
|
||||
type: 'way',
|
||||
id: 424242,
|
||||
tags: { military: 'naval_base', name: 'QA Harbour Station' },
|
||||
center: { lat: 43.6725, lon: -79.33 },
|
||||
geometry: ring.map(([lat, lon]) => ({ lat, lon })),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return fetch(input, options);
|
||||
};
|
||||
window.qaSelected = [];
|
||||
for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared'])
|
||||
window.addEventListener(type, ({ detail }) =>
|
||||
window.qaSelected.push({
|
||||
type,
|
||||
id: detail?.id,
|
||||
layerId: detail?.layerId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||
window.qaBefore = [...dataManager.getEnabledLayerIds()].sort();
|
||||
const outcome = await styleManager.setContextMode('contacts');
|
||||
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||
window.qaContext = dataManager.layers.get('military-awareness').module;
|
||||
window.qaSites = dataManager.layers.get('military-installations').module;
|
||||
const vessels = dataManager.layers.get('ais-live-vessels').module;
|
||||
if (!vessels.selectById('123456789')) throw Error('Missing first vessel');
|
||||
if (!window.qaContext.navigateNext({ targetLayer: 'ais-live-vessels' }))
|
||||
throw Error('Cannot navigate to the second vessel');
|
||||
});
|
||||
const sample = () =>
|
||||
p.evaluate(() => {
|
||||
const { dataManager, styleManager, engine } = window.__godsEyeView;
|
||||
return {
|
||||
context:
|
||||
window.qaContext ===
|
||||
dataManager.layers.get('military-awareness').module,
|
||||
sites:
|
||||
window.qaSites ===
|
||||
dataManager.layers.get('military-installations').module,
|
||||
mode: styleManager.getContextModeState().mode,
|
||||
subject: window.qaContext.getContextSnapshot()?.subject?.id,
|
||||
history:
|
||||
window.qaContext._getAwarenessNavigationStateForTest().historyKeys,
|
||||
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||
ring:
|
||||
engine.kind === 'navara'
|
||||
? engine.lines
|
||||
.list()
|
||||
.filter((line) => line.id === 'gev-awareness-rim').length
|
||||
: null,
|
||||
};
|
||||
});
|
||||
// The station zone: the fixture's installation is the one mapped site
|
||||
// within the Contacts radius of the subject, and the engine draws it where
|
||||
// a pointer can reach it (the engine's own picker returns it at an
|
||||
// uncovered canvas point at its marker or inside its footprint).
|
||||
const inside = [fromDegrees(-79.33, 43.6725)];
|
||||
for (let i = 1; i < 6; i++)
|
||||
for (let k = 1; k < 6; k++)
|
||||
inside.push(fromDegrees(-79.345 + 0.005 * k, 43.665 + 0.0025 * i));
|
||||
const zone = async () => {
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
!window.qaSites.getStats().loading &&
|
||||
window.qaSites
|
||||
.getAnalystRecords()
|
||||
.some((r) => r.name === 'QA Harbour Station'),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const drawn = await p
|
||||
.waitForFunction(
|
||||
(points) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const box = canvas.getBoundingClientRect();
|
||||
for (const world of points) {
|
||||
const centre = engine.projector.worldToWindow(world, {});
|
||||
if (!centre) continue;
|
||||
for (const [dx, dy] of [
|
||||
[0, 0],
|
||||
[0, -8],
|
||||
[8, 0],
|
||||
[-8, 0],
|
||||
[0, 8],
|
||||
]) {
|
||||
const at = { x: centre.x + dx, y: centre.y + dy };
|
||||
if (
|
||||
!canvas.contains(
|
||||
document.elementFromPoint(box.left + at.x, box.top + at.y),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const id = engine.picker.pick(at)?.id;
|
||||
if (String(id?.id ?? id) === 'osm:way:424242')
|
||||
return [Math.round(at.x), Math.round(at.y)];
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
inside,
|
||||
)
|
||||
.then((handle) => handle.jsonValue())
|
||||
.catch(() => null);
|
||||
return p.evaluate(
|
||||
(drawn) => ({
|
||||
drawn,
|
||||
records: window.qaSites.getAnalystRecords().map((r) => r.name),
|
||||
coverage: window.qaSites.getStats().coverage,
|
||||
count: window.qaSites.getStats().count,
|
||||
reads: window.qaInstallationReads,
|
||||
subject: window.qaContext.getContextSnapshot()?.subject?.id,
|
||||
}),
|
||||
drawn,
|
||||
);
|
||||
};
|
||||
const checkZone = async () => {
|
||||
const station = await zone();
|
||||
await strict.check(
|
||||
p,
|
||||
'military-installations',
|
||||
'the station zone holds the fixture station, drawn and pickable',
|
||||
station.count === 1 &&
|
||||
station.records.length === 1 &&
|
||||
station.records[0] === 'QA Harbour Station' &&
|
||||
station.coverage?.kind === 'subject' &&
|
||||
Array.isArray(station.drawn),
|
||||
station,
|
||||
);
|
||||
return station;
|
||||
};
|
||||
const firstZone = await checkZone();
|
||||
const baseline = await sample();
|
||||
console.log('baseline', JSON.stringify(baseline));
|
||||
assert.equal(baseline.mode, 'flights');
|
||||
assert.equal(baseline.subject, '223456789');
|
||||
assert.ok(baseline.history.includes('ais-live-vessels:123456789'));
|
||||
assert.ok(baseline.ring >= 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'military-awareness',
|
||||
'Contacts subject, navigation history and awareness rim',
|
||||
baseline,
|
||||
);
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
const current = await sample();
|
||||
console.log('handoff', kind, JSON.stringify(current));
|
||||
assert.deepEqual(current, {
|
||||
...baseline,
|
||||
ring: kind === 'navara' ? baseline.ring : null,
|
||||
});
|
||||
await strict.passed(
|
||||
p,
|
||||
'military-awareness',
|
||||
'same awareness model, Contacts mode, subject, history and enabled layers',
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'military-installations',
|
||||
'same installations model and enabled through the switch',
|
||||
);
|
||||
const station = await checkZone();
|
||||
await strict.check(
|
||||
p,
|
||||
'military-installations',
|
||||
'the switch reads no installation snapshot',
|
||||
station.reads === firstZone.reads,
|
||||
{ before: firstZone.reads, after: station.reads },
|
||||
);
|
||||
}
|
||||
await checkZone();
|
||||
const restored = await p.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
const result = await styleManager.setContextMode('off');
|
||||
return (
|
||||
result.ok &&
|
||||
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
|
||||
JSON.stringify(window.qaBefore)
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
restored,
|
||||
'Restore layers enabled by the user before entering Contacts',
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS Contacts history, dependency ownership and exit restore across both globes',
|
||||
);
|
||||
});
|
||||
@@ -28,6 +28,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
@@ -77,6 +78,7 @@ const browser = await puppeteer.launch({
|
||||
|
||||
async function openPage({ aircraft = false } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||
@@ -338,16 +340,10 @@ async function contacts() {
|
||||
candidate.querySelector('strong')?.textContent?.trim() ===
|
||||
'Mapped installations',
|
||||
);
|
||||
// The drawn sites are context handles in the shared context store
|
||||
// (src/layers/installations/render/display.js keeps a site's handle
|
||||
// while its geometry is unchanged), not Entities: a new handle is a
|
||||
// rebuilt site.
|
||||
const sites = [
|
||||
...(window.__gevContextStore?.entities.values() || []),
|
||||
].filter((record) =>
|
||||
String(record.entity?.gevTrackedId ?? '').startsWith('installations:'),
|
||||
);
|
||||
for (const { entity } of sites)
|
||||
const ds = window.__godsEyeView.viewer.dataSources.getByName(
|
||||
'military-installations',
|
||||
)[0];
|
||||
for (const entity of ds?.entities.values || [])
|
||||
if (!seen.has(entity)) {
|
||||
seen.add(entity);
|
||||
created += 1;
|
||||
@@ -363,7 +359,7 @@ async function contacts() {
|
||||
name: button.childNodes[0]?.textContent?.trim(),
|
||||
distance: button.querySelector('span')?.textContent?.trim(),
|
||||
})),
|
||||
entities: sites.length,
|
||||
entities: ds?.entities.values.length ?? 0,
|
||||
created,
|
||||
tracked:
|
||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null,
|
||||
@@ -435,7 +431,7 @@ async function contacts() {
|
||||
`${reverted.length}/${flight.length}`,
|
||||
);
|
||||
check(
|
||||
'drawn sites are not rebuilt while tracking',
|
||||
'site entities are not rebuilt while tracking',
|
||||
createdDuring === 0,
|
||||
`${createdDuring} created`,
|
||||
);
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run the globe contract suite (src/globe/testing/contractSuite.mjs) in
|
||||
* headless Chrome against the real engines of a running app, the way
|
||||
* contract.test.mjs runs it against the fake engine.
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4241
|
||||
* node scripts/qa-contract-suite.mjs http://localhost:4241 [--engine=cesium,navara] [--json=FILE]
|
||||
*
|
||||
* Serve the app keyless from the dev server: Google and Cesium ion requests
|
||||
* are refused here anyway, so neither engine draws photoreal tiles. The page
|
||||
* imports the suite from the dev server, so it shares the app's modules (an
|
||||
* engine event is recognised by the module that made it). It gets a real
|
||||
* pointer (the pick checks), real 2D canvases for sprite atlases, and no map
|
||||
* sources (the maps checks would ask for photoreal tiles). Exits 1 when any check fails on any engine.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
if (!origin) {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix,...] [--json=FILE]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const engines = (option('engine') ?? 'cesium,navara').split(',');
|
||||
// --only=prefix,prefix runs just the checks whose names start with one.
|
||||
const only = option('only')?.split(',') ?? null;
|
||||
|
||||
// Checks of state the running app drives itself, by name prefix, with the
|
||||
// owner. The app sets contact sonar on every frame (src/cyberSonarEngine.js),
|
||||
// so a value the suite sets does not survive to its next frame.
|
||||
const APP_OWNED = Object.freeze([
|
||||
['render.setContactSonar', 'src/cyberSonarEngine.js sets it every frame'],
|
||||
]);
|
||||
|
||||
async function runOn(browser, kind) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const { hostname } = new URL(request.url());
|
||||
if (BLOCKED_HOSTS.test(hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
// A view in the link: the app starts there instead of flying to a city.
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=10&lon=20&alt=1000000&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
window.__godsEyeView?.engine?.kind === expected &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
// A cold dev server compiles Navara's modules on the first visit.
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
kind,
|
||||
);
|
||||
// The suite owns the globe: no camera flight running, and the app's panels
|
||||
// let the pointer through to the canvas.
|
||||
// The app restores the view in the link with a flight that can start
|
||||
// after the loading screen goes: wait until the camera is there and still,
|
||||
// or that flight lands in the middle of a check.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const { camera } = window.__godsEyeView.engine;
|
||||
return (
|
||||
!camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000
|
||||
);
|
||||
},
|
||||
{ timeout: 120_000, polling: 250 },
|
||||
);
|
||||
// Only the engine's canvas takes the pointer: the world overlay draws on a
|
||||
// canvas of its own above it.
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.engine.host.canvas.setAttribute(
|
||||
'data-contract-target',
|
||||
'',
|
||||
),
|
||||
);
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
'* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }',
|
||||
});
|
||||
// The globe drawn and its tiles loaded: a restored view hides the globe
|
||||
// for a moment after the loading screen goes.
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const probe = document.createElement('canvas');
|
||||
probe.width = 1;
|
||||
probe.height = 1;
|
||||
const context = probe.getContext('2d', { willReadFrequently: true });
|
||||
const started = performance.now();
|
||||
let settled = 0;
|
||||
while (performance.now() - started < 120_000 && settled < 20) {
|
||||
await new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('contract-suite-settle');
|
||||
});
|
||||
context.drawImage(
|
||||
canvas,
|
||||
canvas.width / 2,
|
||||
canvas.height / 2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||
settled =
|
||||
r + g + b > 60 && engine.surface.tilesLoaded() ? settled + 1 : 0;
|
||||
}
|
||||
});
|
||||
// A real left click and pointer move at a canvas position.
|
||||
await page.exposeFunction('__contractPointer', async (type, x, y) => {
|
||||
const box = await page.evaluate(() => {
|
||||
const rect =
|
||||
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
|
||||
return { left: rect.left, top: rect.top };
|
||||
});
|
||||
const at = [box.left + x, box.top + y];
|
||||
if (type === 'click') await page.mouse.click(...at);
|
||||
else {
|
||||
await page.mouse.move(at[0] - 1, at[1]);
|
||||
await page.mouse.move(...at);
|
||||
}
|
||||
});
|
||||
const results = await page.evaluate(
|
||||
async (appOwned, only) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const waitFrames = (count) =>
|
||||
new Promise((resolve) => {
|
||||
let left = count;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--left > 0) return;
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('contract-suite');
|
||||
});
|
||||
const pointer = {
|
||||
click: (x, y) => window.__contractPointer('click', x, y),
|
||||
move: (x, y) => window.__contractPointer('move', x, y),
|
||||
};
|
||||
const { createContractSuite } =
|
||||
await import('/src/globe/testing/contractSuite.mjs');
|
||||
const outcome = [];
|
||||
// A real 2D canvas: a sprite atlas the engines can draw and pick.
|
||||
const makeCanvas = (width, height) =>
|
||||
Object.assign(document.createElement('canvas'), { width, height });
|
||||
// RGBA of the last drawn frame (both engines keep their drawing buffer).
|
||||
const scratch = document.createElement('canvas');
|
||||
const context = scratch.getContext('2d', { willReadFrequently: true });
|
||||
const readRegion = (x, y, width, height) => {
|
||||
const canvas = engine.host.canvas;
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
scratch.width = width;
|
||||
scratch.height = height;
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(
|
||||
canvas,
|
||||
x * scale,
|
||||
y * scale,
|
||||
width * scale,
|
||||
height * scale,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
return context.getImageData(0, 0, width, height).data;
|
||||
};
|
||||
for (const { name, run } of createContractSuite(engine, {
|
||||
waitFrames,
|
||||
pointer,
|
||||
readRegion,
|
||||
makeCanvas,
|
||||
})) {
|
||||
if (only && !only.some((prefix) => name.startsWith(prefix))) continue;
|
||||
const owner = appOwned.find(([prefix]) => name.startsWith(prefix));
|
||||
if (owner) {
|
||||
outcome.push({ name, skipped: owner[1] });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await run();
|
||||
outcome.push({ name, ok: true });
|
||||
} catch (error) {
|
||||
outcome.push({
|
||||
name,
|
||||
ok: false,
|
||||
error: String(error?.message ?? error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
},
|
||||
APP_OWNED,
|
||||
only,
|
||||
);
|
||||
await page.close();
|
||||
return { engine: kind, results, pageErrors };
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
// A Navara page can hold its main thread for minutes while it starts.
|
||||
protocolTimeout: 600_000,
|
||||
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
||||
});
|
||||
const report = [];
|
||||
try {
|
||||
for (const kind of engines) {
|
||||
const run = await runOn(browser, kind);
|
||||
report.push(run);
|
||||
const ran = run.results.filter(({ skipped }) => !skipped);
|
||||
const failed = ran.filter(({ ok }) => !ok);
|
||||
console.log(
|
||||
`${kind}: ${ran.length - failed.length}/${ran.length} checks pass`,
|
||||
);
|
||||
for (const { name, skipped } of run.results)
|
||||
if (skipped) console.log(` skipped ${name}: ${skipped}`);
|
||||
for (const { name, error } of failed)
|
||||
console.log(` FAIL ${name}: ${error}`);
|
||||
for (const message of run.pageErrors)
|
||||
console.log(` page error: ${message}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
const json = option('json');
|
||||
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.exit(
|
||||
report.every(({ results }) =>
|
||||
results.every(({ ok, skipped }) => ok || skipped),
|
||||
)
|
||||
? 0
|
||||
: 1,
|
||||
);
|
||||
@@ -1,188 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=55.3241&lon=10.7995&alt=25000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
const strict = createStrict({ origin: base.origin, layers: ['directions'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
let routeReads = 0,
|
||||
release;
|
||||
await p.setRequestInterception(true);
|
||||
p.on('request', async (req) => {
|
||||
const u = new URL(req.url());
|
||||
const json = (v) =>
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
if (u.pathname === '/api/terrain/heights') {
|
||||
const cells = (u.searchParams.get('points') ?? '').split(';');
|
||||
return json({ results: cells.map(() => ({ ellipsoid: 34 })) });
|
||||
}
|
||||
if (u.pathname !== '/api/route') return req.continue();
|
||||
routeReads++;
|
||||
const [a, b] = u.searchParams
|
||||
.get('coords')
|
||||
.split(';')
|
||||
.map((p) => p.split(',').map(Number));
|
||||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
if (routeReads === 1)
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return json({
|
||||
ok: true,
|
||||
geometry: [a, mid, b],
|
||||
distanceM: 9000,
|
||||
durationS: 800,
|
||||
steps: [a, mid, b].map(([lon, lat], i) => ({
|
||||
lon,
|
||||
lat,
|
||||
instruction: 'Route step ' + i,
|
||||
distanceM: 3000,
|
||||
durationS: 120,
|
||||
})),
|
||||
});
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
window.qaRoute = dataManager.layers.get('directions').module;
|
||||
await dataManager.setEnabled('directions', true, { origin: 'user' });
|
||||
});
|
||||
for (const [letter, x] of [
|
||||
['a', 480],
|
||||
['b', 645],
|
||||
]) {
|
||||
await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter);
|
||||
await p.mouse.click(x, 440);
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
}
|
||||
assert.equal(routeReads, 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'A and B set by clicks on the globe; one route request',
|
||||
);
|
||||
await strict.switchTo(p, 'cesium');
|
||||
release();
|
||||
await p.waitForFunction(() => window.qaRoute.getStats().count === 3);
|
||||
await p.waitForFunction(() => {
|
||||
window.qaRoute.setParams({ step: 1 });
|
||||
return window.qaRoute.getRowControls().list.items[1]?.active;
|
||||
});
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'the route asked for on Navara arrives on Cesium; a step is selectable',
|
||||
);
|
||||
const transfer = async (kind) => {
|
||||
await strict.switchTo(p, kind);
|
||||
assert.equal(
|
||||
await p.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('directions').module ===
|
||||
window.qaRoute,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(routeReads, 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'same model; the switch requests no route',
|
||||
);
|
||||
};
|
||||
await transfer('navara');
|
||||
assert.equal(
|
||||
await p.evaluate(
|
||||
() => window.qaRoute.getRowControls().list.items[1].active,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await p.evaluate(() => window.qaRoute.setParams({ fly: true }));
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')?.active,
|
||||
);
|
||||
const pos = () =>
|
||||
p.evaluate(() => window.__godsEyeView.engine.camera.pose().position);
|
||||
const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||
let progress = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
const before = await pos();
|
||||
await transfer(kind);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||
?.active,
|
||||
);
|
||||
const at = await pos();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const next = await pos();
|
||||
const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||
assert.equal(resumed.motionId, progress.motionId);
|
||||
assert.ok(resumed.traveledM > progress.traveledM);
|
||||
progress = resumed;
|
||||
console.log(kind, 'moved', d(at, next), 'handoff', d(at, before));
|
||||
assert.ok(d(at, next) > 1, 'dolly advances after switch to ' + kind);
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'FLY continues with the same motion and advances',
|
||||
{ moved: d(at, next) },
|
||||
);
|
||||
}
|
||||
await p.evaluate(() => window.qaRoute.setParams({ clear: true }));
|
||||
assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0);
|
||||
// CLEAR releases the camera: the route's motion ends, the camera stays
|
||||
// where it is, and a wheel zoom by the person moves it and is kept.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const released = await p.evaluate(() => ({
|
||||
motion: window.qaRoute.getFlightProgress(),
|
||||
fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||
?.active,
|
||||
}));
|
||||
const still = await pos();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const later = await pos();
|
||||
const height = () =>
|
||||
p.evaluate(() => window.__godsEyeView.engine.camera.height());
|
||||
const beforeZoom = await height();
|
||||
await p.mouse.move(512, 300);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await p.mouse.wheel({ deltaY: -240 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const zoomed = await height();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const kept = await height();
|
||||
await strict.check(
|
||||
p,
|
||||
'directions',
|
||||
'CLEAR removes the route and releases the camera: no route motion, the camera stays put, a wheel zoom moves it and is kept',
|
||||
released.motion === null &&
|
||||
!released.fly &&
|
||||
d(still, later) < 1 &&
|
||||
zoomed < beforeZoom * 0.95 &&
|
||||
Math.abs(kept - zoomed) < 0.02 * zoomed,
|
||||
{ released, drift: d(still, later), beforeZoom, zoomed, kept },
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS route delivery during handoff, A/B clicks, step selection, ongoing FLY, clear and single route request',
|
||||
);
|
||||
});
|
||||
+69
-112
@@ -35,7 +35,7 @@ const check = (name, passed, detail = '') => {
|
||||
};
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: process.env.QA_HEADFUL === '1' ? false : true,
|
||||
headless: false,
|
||||
executablePath: chromeExecutablePath(),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
@@ -110,7 +110,11 @@ const layerState = () =>
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const controls = module.getRowControls();
|
||||
const drawn = window.__gevQaDirections.drawn();
|
||||
const viewer = gev.viewer;
|
||||
const entities = [...viewer.entities.values];
|
||||
const routeEntities = entities.filter(
|
||||
(entity) => String(entity.id) === 'directions:route',
|
||||
);
|
||||
const listNodes = row
|
||||
? [...row.querySelectorAll('.data-row-list-item')]
|
||||
: [];
|
||||
@@ -132,9 +136,14 @@ const layerState = () =>
|
||||
domCurrent: listNodes.findIndex((node) =>
|
||||
node.classList.contains('current'),
|
||||
),
|
||||
routeEntities: drawn.routeSets,
|
||||
routePositions: drawn.routeVertices,
|
||||
markerCount: drawn.markersShown,
|
||||
routeEntities: routeEntities.length,
|
||||
routePositions:
|
||||
routeEntities[0]?.polyline?.positions?.getValue?.(
|
||||
viewer.clock.currentTime,
|
||||
)?.length || 0,
|
||||
markerCount: entities.filter((entity) =>
|
||||
String(entity.id).startsWith('directions:marker:'),
|
||||
).length,
|
||||
pointerOwner: window.__gevQa.pointerOwner(),
|
||||
cameraMotion: window.__gevQa.getActiveCameraMotion(),
|
||||
};
|
||||
@@ -220,96 +229,6 @@ try {
|
||||
releasePointer: own.releasePointer,
|
||||
getActiveCameraMotion: verbs.getActiveCameraMotion,
|
||||
};
|
||||
// What Directions draws, read through the globe contract
|
||||
// (src/layers/directions/render/display.js): the route is the
|
||||
// `directions-route` ground corridor set, cut into dash pieces; the A/B
|
||||
// discs and the maneuver dots are the `directions-markers` and
|
||||
// `directions-steps` point mover sets. The route's pieces are seen as the
|
||||
// display hands them to engine.ground; which instances are shown is read
|
||||
// from the Cesium engine's own point collection for the set (this gate
|
||||
// drives the Cesium camera).
|
||||
const engine = window.__godsEyeView.engine;
|
||||
let routePieces = null;
|
||||
const createGroundSet = engine.ground.createSet.bind(engine.ground);
|
||||
engine.ground.createSet = (options) => {
|
||||
const set = createGroundSet(options);
|
||||
if (options?.id === 'directions-route') {
|
||||
const replace = set.replace.bind(set);
|
||||
set.replace = (items) => {
|
||||
routePieces = items.map((item) => item.positions.length);
|
||||
return replace(items);
|
||||
};
|
||||
}
|
||||
return set;
|
||||
};
|
||||
const moverPrimitive = (id) => {
|
||||
const walk = (collection) => {
|
||||
for (let i = 0; i < collection.length; i += 1) {
|
||||
const primitive = collection.get(i);
|
||||
if (!primitive) continue;
|
||||
if (primitive.gevMoverSet === id) return primitive;
|
||||
if (
|
||||
primitive.gevMoverSet === undefined &&
|
||||
typeof primitive.get === 'function' &&
|
||||
typeof primitive.length === 'number'
|
||||
) {
|
||||
const found = walk(primitive);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(window.__godsEyeView.viewer.scene.primitives);
|
||||
};
|
||||
const moverCount = (id) =>
|
||||
engine.movers.list().find((set) => set.id === id)?.count ?? 0;
|
||||
const shownInstances = (id) => {
|
||||
const primitive = moverPrimitive(id);
|
||||
if (!primitive || primitive.show === false) return [];
|
||||
const shown = [];
|
||||
const count = Math.min(moverCount(id), primitive.length);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const instance = primitive.get(i);
|
||||
if (instance.show) shown.push(instance);
|
||||
}
|
||||
return shown;
|
||||
};
|
||||
window.__gevQaDirections = {
|
||||
drawn() {
|
||||
const route = engine.ground
|
||||
.list()
|
||||
.filter((set) => set.id === 'directions-route');
|
||||
// Every original vertex is kept, and each piece adds its two ends,
|
||||
// which are the next piece's start: vertices = total - 2·pieces + 2.
|
||||
const routeVertices =
|
||||
route.length && routePieces?.length
|
||||
? routePieces.reduce((sum, n) => sum + n, 0) -
|
||||
2 * routePieces.length +
|
||||
2
|
||||
: 0;
|
||||
return {
|
||||
routeSets: route.filter((set) => set.shown && set.count > 0).length,
|
||||
routeVertices,
|
||||
markersShown: shownInstances('directions-markers').length,
|
||||
stepCount: moverCount('directions-steps'),
|
||||
stepsShown: shownInstances('directions-steps').map((point) =>
|
||||
point.position.clone(),
|
||||
),
|
||||
};
|
||||
},
|
||||
// Every other layer's contract sets, to prove a disable touches only
|
||||
// the Directions ones.
|
||||
others() {
|
||||
const mine = (id) => String(id).startsWith('directions');
|
||||
return [
|
||||
...engine.movers.list().map((set) => `movers:${set.id}`),
|
||||
...engine.lines.list().map((line) => `lines:${line.id}`),
|
||||
...engine.ground.list().map((set) => `ground:${set.id}`),
|
||||
]
|
||||
.filter((key) => !mine(key.split(':').slice(1).join(':')))
|
||||
.sort();
|
||||
},
|
||||
};
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
@@ -781,11 +700,28 @@ try {
|
||||
const denver = await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const Cartographic = viewer.camera.positionCartographic.constructor;
|
||||
const { stepCount, stepsShown } = window.__gevQaDirections.drawn();
|
||||
const heights = stepsShown.map(
|
||||
(position) => Cartographic.fromCartesian(position).height,
|
||||
);
|
||||
return { dots: stepCount, shown: heights.length, heights };
|
||||
const scene = viewer.scene;
|
||||
let collection = null;
|
||||
for (let i = 0; i < scene.primitives.length; i += 1) {
|
||||
const primitive = scene.primitives.get(i);
|
||||
if (
|
||||
primitive &&
|
||||
typeof primitive.get === 'function' &&
|
||||
primitive.length > 0 &&
|
||||
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
||||
) {
|
||||
collection = primitive;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!collection) return { dots: 0, shown: 0, heights: [] };
|
||||
const heights = [];
|
||||
for (let i = 0; i < collection.length; i += 1) {
|
||||
const point = collection.get(i);
|
||||
if (!point.show) continue;
|
||||
heights.push(Cartographic.fromCartesian(point.position).height);
|
||||
}
|
||||
return { dots: collection.length, shown: heights.length, heights };
|
||||
});
|
||||
check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`);
|
||||
check(
|
||||
@@ -808,10 +744,15 @@ try {
|
||||
}
|
||||
|
||||
// ── 10. Disable: nothing is left behind. ────────────────────────────────
|
||||
const { othersBefore, drawnBefore } = await page.evaluate(() => ({
|
||||
othersBefore: window.__gevQaDirections.others(),
|
||||
drawnBefore: window.__gevQaDirections.drawn(),
|
||||
}));
|
||||
const { beforeDisable, directionsBeforeDisable } = await page.evaluate(() => {
|
||||
const entities = [...window.__godsEyeView.viewer.entities.values];
|
||||
return {
|
||||
beforeDisable: entities.length,
|
||||
directionsBeforeDisable: entities.filter((entity) =>
|
||||
String(entity.id).startsWith('directions:'),
|
||||
).length,
|
||||
};
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('directions', false, {
|
||||
origin: 'user',
|
||||
@@ -819,23 +760,39 @@ try {
|
||||
);
|
||||
await sleep(1500);
|
||||
const off = await page.evaluate(() => {
|
||||
const drawn = window.__gevQaDirections.drawn();
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const left = [...viewer.entities.values].filter((entity) =>
|
||||
String(entity.id).startsWith('directions:'),
|
||||
);
|
||||
let shownDots = 0;
|
||||
for (let i = 0; i < viewer.scene.primitives.length; i += 1) {
|
||||
const primitive = viewer.scene.primitives.get(i);
|
||||
if (
|
||||
primitive &&
|
||||
typeof primitive.get === 'function' &&
|
||||
primitive.length > 0 &&
|
||||
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
||||
) {
|
||||
shownDots = primitive.show ? primitive.length : 0;
|
||||
}
|
||||
}
|
||||
const row = document.querySelector(
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
return {
|
||||
entities: drawn.routeSets + drawn.markersShown,
|
||||
shownDots: drawn.stepsShown.length,
|
||||
entities: left.length,
|
||||
shownDots,
|
||||
controlsHidden:
|
||||
row?.querySelector('.data-toggle-controls')?.hidden === true,
|
||||
listHidden: row?.querySelector('.data-row-list')?.hidden === true,
|
||||
pointerOwner: window.__gevQa.pointerOwner(),
|
||||
motion: window.__gevQa.getActiveCameraMotion(),
|
||||
others: window.__gevQaDirections.others(),
|
||||
totalEntities: viewer.entities.values.length,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'disabling removes the route and hides both markers',
|
||||
'disabling removes every Directions entity',
|
||||
off.entities === 0,
|
||||
`${off.entities} left`,
|
||||
);
|
||||
@@ -852,9 +809,9 @@ try {
|
||||
);
|
||||
check('disabling leaves no camera motion running', off.motion === null);
|
||||
check(
|
||||
"only the Directions drawing went away: every other layer's sets stay",
|
||||
JSON.stringify(off.others) === JSON.stringify(othersBefore),
|
||||
`ours before: route ${drawnBefore.routeSets}, markers ${drawnBefore.markersShown}, dots ${drawnBefore.stepsShown.length}; others ${othersBefore.length} -> ${off.others.length}`,
|
||||
'exactly the Directions entities went away, and nothing else did',
|
||||
off.totalEntities === beforeDisable - directionsBeforeDisable,
|
||||
`${beforeDisable} total (${directionsBeforeDisable} ours) -> ${off.totalEntities}`,
|
||||
);
|
||||
|
||||
check(
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
const url = localPreview(process.argv[2]);
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&lat=30.2672&lon=-97.742&alt=4000&pitch=-90&heading=0&map=sentinel2&l=';
|
||||
const png = await sharp({
|
||||
create: { width: 64, height: 64, channels: 4, background: '#fd2233' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const features = [
|
||||
{
|
||||
id: 'point',
|
||||
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
|
||||
},
|
||||
{
|
||||
id: 'line',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-97.745, 30.268, 400],
|
||||
[-97.735, 30.268, 400],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'polygon',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-97.747, 30.264, 450],
|
||||
[-97.74, 30.264, 450],
|
||||
[-97.74, 30.267, 450],
|
||||
[-97.747, 30.264, 450],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
].map((f) => ({ ...f, type: 'Feature' }));
|
||||
const wav = Buffer.alloc(44 + 16000);
|
||||
wav.write('RIFF');
|
||||
wav.writeUInt32LE(wav.length - 8, 4);
|
||||
wav.write('WAVEfmt ', 8);
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20);
|
||||
wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(8000, 24);
|
||||
wav.writeUInt32LE(16000, 28);
|
||||
wav.writeUInt16LE(2, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
wav.write('data', 36);
|
||||
wav.writeUInt32LE(16000, 40);
|
||||
const pack = (id, format, path, placement) => ({
|
||||
id,
|
||||
version: 1,
|
||||
format,
|
||||
source: { adapter: 'assets', path },
|
||||
attribution: { text: 'Local fixture', license: 'CC0-1.0' },
|
||||
placement,
|
||||
});
|
||||
const camera = {
|
||||
lat: 30.2672,
|
||||
lon: -97.742,
|
||||
alt: 4000,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
altitudeReference: 'ellipsoid',
|
||||
};
|
||||
const shot = {
|
||||
id: 'one',
|
||||
title: 'Pack view',
|
||||
camera,
|
||||
durationSec: 5,
|
||||
holdSec: 3,
|
||||
visual: { style: 'normal', mapStack: 'sentinel2' },
|
||||
layers: { earthquakes: true },
|
||||
dataPackIds: ['data', 'image', 'audio'],
|
||||
interactions: features.map((f) => ({
|
||||
id: f.id,
|
||||
label: 'Show ' + f.id,
|
||||
target: { packId: 'data', featureId: f.id },
|
||||
action: { type: 'card', text: f.id },
|
||||
})),
|
||||
};
|
||||
const project = {
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
id: 'parity',
|
||||
title: 'Test project',
|
||||
anchors: [
|
||||
{
|
||||
id: 'station',
|
||||
lat: 30.271,
|
||||
lon: -97.735,
|
||||
alt: 500,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
],
|
||||
dataPacks: [
|
||||
pack('data', 'geojson', 'data.json', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
pack('image', 'image', 'image.png', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
bounds: [-97.738, 30.264, -97.732, 30.267],
|
||||
height: 500,
|
||||
}),
|
||||
pack('audio', 'media', 'audio.wav', { anchorId: 'station' }),
|
||||
],
|
||||
shots: [
|
||||
shot,
|
||||
{
|
||||
...shot,
|
||||
id: 'two',
|
||||
title: 'Next shot',
|
||||
camera: { ...camera, heading: 15 },
|
||||
move: { from: camera, easing: 'linear' },
|
||||
dataPackIds: [],
|
||||
interactions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
// Scene Director is not a catalog layer; the scenario answers the scene's
|
||||
// earthquake feed itself.
|
||||
const strict = createStrict({
|
||||
origin: url.origin,
|
||||
layers: [],
|
||||
fixtures: ['https://earthquake.usgs.gov/*'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 900 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message);
|
||||
});
|
||||
const reads = [];
|
||||
await p.setRequestInterception(true);
|
||||
p.on('request', (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
if (path.startsWith('/scene-assets/')) {
|
||||
reads.push(path);
|
||||
const file = path.split('/').pop();
|
||||
return req.respond({
|
||||
status: 200,
|
||||
contentType:
|
||||
file === 'data.json'
|
||||
? 'application/geo+json'
|
||||
: file === 'audio.wav'
|
||||
? 'audio/wav'
|
||||
: 'image/png',
|
||||
body:
|
||||
file === 'data.json'
|
||||
? JSON.stringify({ type: 'FeatureCollection', features })
|
||||
: file === 'audio.wav'
|
||||
? wav
|
||||
: png,
|
||||
});
|
||||
}
|
||||
if (new URL(req.url()).hostname === 'earthquake.usgs.gov')
|
||||
return req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: '{"type":"FeatureCollection","features":[]}',
|
||||
});
|
||||
return req.continue();
|
||||
});
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await assertKeylessPreview(p);
|
||||
await p.evaluate(async (project) => {
|
||||
const a = window.__godsEyeView;
|
||||
|
||||
await a.styleManager.initialRestorePromise;
|
||||
window.director = a.sceneDirector;
|
||||
await window.director.importProjectFile(
|
||||
new File([JSON.stringify(project)], 'qa.json'),
|
||||
);
|
||||
const out = await window.director.loadShot('parity', 'one', {
|
||||
flyDuration: 0.2,
|
||||
});
|
||||
if (!out.started)
|
||||
throw Error(
|
||||
'Pack load failed: ' +
|
||||
JSON.stringify(out) +
|
||||
' ' +
|
||||
window.director._presentation.status,
|
||||
);
|
||||
}, project);
|
||||
await p.waitForSelector('[data-director-pack="audio"] audio');
|
||||
await p.evaluate(() => {
|
||||
window.qaAudio = document.querySelector(
|
||||
'[data-director-pack="audio"] audio',
|
||||
);
|
||||
});
|
||||
const at = (world) =>
|
||||
p.evaluate((world) => {
|
||||
const a = window.__godsEyeView;
|
||||
const xy = a.engine.projector.worldToWindow(world),
|
||||
rect = a.engine.host.canvas.getBoundingClientRect();
|
||||
return { x: xy.x + rect.x, y: xy.y + rect.y };
|
||||
}, world);
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind)
|
||||
await strict.switchTo(p, kind);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const st = await p.evaluate(() => ({
|
||||
same: window.director === window.__godsEyeView.sceneDirector,
|
||||
media:
|
||||
window.qaAudio ===
|
||||
document.querySelector('[data-director-pack="audio"] audio'),
|
||||
packs: window.director.getDataPackState(),
|
||||
actions: window.director.getInteractionState(),
|
||||
}));
|
||||
assert.ok(
|
||||
st.same && st.media && st.packs.count === 3 && st.actions.count === 3,
|
||||
);
|
||||
const xy = await at(fromDegrees(-97.742, 30.27, 450));
|
||||
await p.mouse.click(xy.x, xy.y);
|
||||
await p.waitForFunction(
|
||||
() => document.activeElement?.dataset.directorAction === 'point',
|
||||
);
|
||||
await p.keyboard.press('Enter');
|
||||
await p.waitForFunction(
|
||||
() => window.director.getInteractionState().selected === 'point',
|
||||
);
|
||||
for (const [id, coords] of [
|
||||
['line', [-97.74, 30.268, 400]],
|
||||
['polygon', [-97.742, 30.265, 450]],
|
||||
]) {
|
||||
const hit = await at(fromDegrees(...coords));
|
||||
await p.mouse.click(hit.x, hit.y);
|
||||
await p.waitForFunction(
|
||||
(id) => document.activeElement?.dataset.directorAction === id,
|
||||
{ timeout: 10000 },
|
||||
id,
|
||||
);
|
||||
}
|
||||
const imageAt = await at(fromDegrees(-97.735, 30.2655, 500));
|
||||
const sample = await sharp(await p.screenshot())
|
||||
.extract({
|
||||
left: Math.round(imageAt.x),
|
||||
top: Math.round(imageAt.y),
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
console.log(kind, st.packs.count, st.actions.count, Array.from(sample));
|
||||
assert.ok(
|
||||
sample[0] > 100 && sample[0] > sample[1] * 2,
|
||||
'authored image really draws',
|
||||
);
|
||||
assert.equal(
|
||||
reads.length,
|
||||
3,
|
||||
'packs are not acquired again on a globe change',
|
||||
);
|
||||
}
|
||||
await p.evaluate(() => {
|
||||
window.qaRun = window.director.startScene('parity', {
|
||||
single: true,
|
||||
preview: false,
|
||||
});
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.director.running &&
|
||||
window.director.getPlaybackTimingState().snapshot?.shotId === 'one',
|
||||
);
|
||||
await strict.switchTo(p, 'cesium');
|
||||
assert.ok(
|
||||
await p.evaluate(
|
||||
() =>
|
||||
window.director.running &&
|
||||
window.director === window.__godsEyeView.sceneDirector,
|
||||
),
|
||||
);
|
||||
await p.waitForFunction(
|
||||
() => window.director.getPlaybackTimingState().snapshot?.shotId === 'two',
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
await strict.switchTo(p, 'navara');
|
||||
const complete = await p.evaluate(() => window.qaRun);
|
||||
assert.ok(complete.started && complete.shots === 2);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS authored packs, geographic picks, same player, original multi-shot timeline',
|
||||
);
|
||||
});
|
||||
@@ -47,9 +47,7 @@ const load = () =>
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
// A view in the link: the app starts there instead of flying to a city
|
||||
// after the shot has placed the camera.
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0#v=2&lat=30.2672&lon=-97.742&alt=4000&heading=0&pitch=-90&l=`,
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
@@ -107,7 +105,7 @@ try {
|
||||
from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' },
|
||||
easing: 'linear',
|
||||
},
|
||||
visual: { style: 'normal', mapStack: 'sentinel2' },
|
||||
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||
layers: { traffic: false },
|
||||
dataPackIds: ['data'],
|
||||
interactions: [
|
||||
@@ -164,26 +162,21 @@ try {
|
||||
),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.sceneDirector.engine.surface.tilesLoaded(),
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const point = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const { cartesian3 } = await import('/src/geo/index.js');
|
||||
const pos = cartesian3.fromDegrees(-97.742, 30.2672, 600);
|
||||
const screen = d.engine.projector.worldToWindow(pos);
|
||||
const rect = d.engine.host.canvas.getBoundingClientRect();
|
||||
const entity = d.viewer.entities.values.find(
|
||||
(e) => e.name === 'data / point',
|
||||
);
|
||||
const pos = entity.position.getValue(d.viewer.clock.currentTime);
|
||||
const screen = d.viewer.scene.cartesianToCanvasCoordinates(pos);
|
||||
const rect = d.viewer.scene.canvas.getBoundingClientRect();
|
||||
return { x: screen.x + rect.left, y: screen.y + rect.top };
|
||||
});
|
||||
await page.mouse.click(point.x, point.y);
|
||||
// The click's pick answers asynchronously; the focus follows it.
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => document.activeElement?.dataset.directorAction === 'card',
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
check(
|
||||
'real globe pick selects the feature and focuses its keyboard action',
|
||||
await page.evaluate(
|
||||
@@ -211,8 +204,7 @@ try {
|
||||
`${output}/report.json`,
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
tilesSettled:
|
||||
window.__godsEyeView.sceneDirector.engine.surface.tilesLoaded(),
|
||||
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
@@ -225,7 +217,8 @@ try {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
Math.abs(
|
||||
window.__godsEyeView.sceneDirector.engine.camera.height() - 5000,
|
||||
window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic
|
||||
.height - 5000,
|
||||
) < 1,
|
||||
),
|
||||
);
|
||||
|
||||
+98
-126
@@ -37,21 +37,6 @@ const browser = await puppeteer.launch({
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
// Daylight over San Francisco: the amber-pixel checks count the drawn mark,
|
||||
// and the globe's night side shows warm city lights the count would include.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const offset = Date.UTC(2026, 5, 15, 20, 0) - Date.now();
|
||||
const RealDate = Date;
|
||||
globalThis.Date = class ShiftedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) super(RealDate.now() + offset);
|
||||
else super(...args);
|
||||
}
|
||||
static now() {
|
||||
return RealDate.now() + offset;
|
||||
}
|
||||
};
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
|
||||
let failures = 0;
|
||||
@@ -204,12 +189,7 @@ async function waitForMarks(expected, timeout = 15_000) {
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
// The view in the address too: the boot flight lands there instead of
|
||||
// flying away from the view set below once deferred init runs.
|
||||
await page.goto(
|
||||
`${BASE_URL}/?welcome=0#v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.height}&heading=${VIEW.heading}&pitch=${VIEW.pitch}`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevDrawTool &&
|
||||
@@ -329,12 +309,13 @@ try {
|
||||
check(
|
||||
'three enable/disable cycles add no listener, handler or preview data source',
|
||||
after.domListeners === before.domListeners &&
|
||||
after.previewDataSources === 0 &&
|
||||
before.previewDataSources === 0 &&
|
||||
after.previewDataSources === 1 &&
|
||||
before.previewDataSources === 1 &&
|
||||
after.sceneHandler === false &&
|
||||
after.pointerOwner === null,
|
||||
after.pointerOwner === null &&
|
||||
after.stockDoubleClick === before.stockDoubleClick,
|
||||
`listeners ${before.domListeners}→${after.domListeners}, previews ${after.previewDataSources}, ` +
|
||||
`handler ${after.sceneHandler}, owner ${after.pointerOwner}`,
|
||||
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
|
||||
);
|
||||
|
||||
// ── the viewer's own click actions are borrowed, then given back ────────
|
||||
@@ -620,46 +601,24 @@ try {
|
||||
: 'no CCTV camera or bundled site could be reached — the ownership case cannot be proved',
|
||||
);
|
||||
if (target) {
|
||||
// Layers select through their own state now, not the viewer's
|
||||
// selectedEntity: a CCTV marker makes its camera the active one, a data
|
||||
// centre selects its record in the shared context store.
|
||||
const isSelected = (spot) =>
|
||||
page.evaluate((spot) => {
|
||||
if (spot.kind === 'cctv')
|
||||
return (
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
?.module?.getUIState?.()?.activeCameraId === spot.id
|
||||
);
|
||||
return window.__gevContextStore?.selectedEntityId === spot.id;
|
||||
}, spot);
|
||||
// Clear the selection (another camera active; no context selection), as
|
||||
// the old check cleared the viewer's selectedEntity.
|
||||
const deselect = (spot) =>
|
||||
page.evaluate((spot) => {
|
||||
if (spot.kind === 'cctv') {
|
||||
const module =
|
||||
window.__godsEyeView.dataManager.layers.get('cctv')?.module;
|
||||
const other = (module?.getUIState?.()?.cameras || []).find(
|
||||
(camera) => camera.id !== spot.id,
|
||||
);
|
||||
if (other) module.selectCamera(other.id);
|
||||
} else if (window.__gevContextStore)
|
||||
window.__gevContextStore.selectedEntityId = null;
|
||||
}, spot);
|
||||
await deselect(target);
|
||||
await clickWorld(target.x, target.y, { settle: 900 });
|
||||
const selectedNormally = await isSelected(target);
|
||||
const selectedNormally = await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.viewer.selectedEntity),
|
||||
);
|
||||
check(
|
||||
`clicking a ${target.layer} selects it when nothing owns the pointer`,
|
||||
selectedNormally,
|
||||
);
|
||||
await deselect(target);
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.selectedEntity = undefined;
|
||||
});
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await clickWorld(target.x, target.y, { settle: 900 });
|
||||
const whileDrawing = await drawState();
|
||||
const selectedWhileDrawing = await isSelected(target);
|
||||
const selectedWhileDrawing = await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.viewer.selectedEntity),
|
||||
);
|
||||
check(
|
||||
`clicking the same ${target.layer} while drawing adds a vertex and selects nothing`,
|
||||
whileDrawing.vertices === 1 && selectedWhileDrawing === false,
|
||||
@@ -718,37 +677,19 @@ try {
|
||||
null,
|
||||
};
|
||||
});
|
||||
// The reload restores the view in the address (the clicked CCTV camera's
|
||||
// flight left a low, near-horizontal view there) with a boot flight that can
|
||||
// land after a setView: set the view until it holds.
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (view.lon * Math.PI) / 180,
|
||||
latitude: (view.lat * Math.PI) / 180,
|
||||
height: view.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: (view.pitch * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
}, VIEW);
|
||||
await wait(2500);
|
||||
const held = await page.evaluate((view) => {
|
||||
const carto = window.__godsEyeView.viewer.camera.positionCartographic;
|
||||
return (
|
||||
Math.abs((carto.latitude * 180) / Math.PI - view.lat) < 0.01 &&
|
||||
Math.abs((carto.longitude * 180) / Math.PI - view.lon) < 0.01 &&
|
||||
Math.abs(carto.height - view.height) < view.height * 0.2
|
||||
);
|
||||
}, VIEW);
|
||||
if (held) break;
|
||||
}
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (view.lon * Math.PI) / 180,
|
||||
latitude: (view.lat * Math.PI) / 180,
|
||||
height: view.height,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
}, VIEW);
|
||||
await wait(2500);
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await clickWorld(420, 280);
|
||||
@@ -872,7 +813,7 @@ try {
|
||||
finalState.active === false &&
|
||||
finalState.pointerOwner === null &&
|
||||
finalState.sceneHandler === false &&
|
||||
finalState.previewDataSources === 0,
|
||||
finalState.previewDataSources === 1,
|
||||
JSON.stringify(finalState),
|
||||
);
|
||||
// ── destroy(): the shell disposing the tool leaves nothing behind ───────
|
||||
@@ -918,9 +859,9 @@ try {
|
||||
JSON.stringify(destroyed.diagnostics),
|
||||
);
|
||||
check(
|
||||
'destroy() leaves no preview data source and gives the window handle back',
|
||||
'destroy() detaches the preview data source and gives the window handle back',
|
||||
destroyed.previews === 0 &&
|
||||
destroyed.dataSources === beforeDestroy &&
|
||||
destroyed.dataSources === beforeDestroy - 1 &&
|
||||
destroyed.handleGone,
|
||||
`previews ${destroyed.previews}, dataSources ${beforeDestroy} → ${destroyed.dataSources}, handle gone ${destroyed.handleGone}`,
|
||||
);
|
||||
@@ -989,15 +930,17 @@ async function findClickableTarget() {
|
||||
viewer.scene.requestRender();
|
||||
}, camera);
|
||||
await wait(4000);
|
||||
const spot = await onScreen(['cctv']);
|
||||
if (spot) return { ...spot, kind: 'cctv', layer: 'CCTV camera' };
|
||||
const spot = await onScreen(
|
||||
(entity) => typeof entity.id === 'string' && entity.id.startsWith('CAM-'),
|
||||
);
|
||||
if (spot) return { ...spot, layer: 'CCTV camera' };
|
||||
}
|
||||
|
||||
// Bundled infrastructure as the keyless fallback: a different guarded handler,
|
||||
// the same shared claim, and always present without a key. The layer
|
||||
// registers each site in the shared context store with its coordinates.
|
||||
// the same shared claim, and always present without a key.
|
||||
const site = await page
|
||||
.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
await window.__godsEyeView.dataManager.setEnabled(
|
||||
'local-datacenters',
|
||||
true,
|
||||
@@ -1005,15 +948,30 @@ async function findClickableTarget() {
|
||||
origin: 'user',
|
||||
},
|
||||
);
|
||||
const centreOf = (entity) => {
|
||||
const time = viewer.clock.currentTime;
|
||||
const point = entity.position?.getValue?.(time);
|
||||
if (point) return point;
|
||||
const hierarchy = entity.polygon?.hierarchy?.getValue?.(time);
|
||||
if (hierarchy?.positions?.length) return hierarchy.positions[0];
|
||||
const line = entity.polyline?.positions?.getValue?.(time);
|
||||
return line?.length ? line[0] : null;
|
||||
};
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
for (const record of window.__gevContextStore?.entities.values() ??
|
||||
[]) {
|
||||
if (record.layerId !== 'local-datacenters') continue;
|
||||
if (
|
||||
Number.isFinite(record.latitude) &&
|
||||
Number.isFinite(record.longitude)
|
||||
)
|
||||
return { lon: record.longitude, lat: record.latitude };
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||
if (entity.__localLayerId !== 'local-datacenters') continue;
|
||||
const point = centreOf(entity);
|
||||
if (!point) continue;
|
||||
const carto = window.Cesium
|
||||
? window.Cesium.Cartographic.fromCartesian(point)
|
||||
: viewer.scene.globe.ellipsoid.cartesianToCartographic(point);
|
||||
if (!carto) continue;
|
||||
return {
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
@@ -1035,40 +993,54 @@ async function findClickableTarget() {
|
||||
viewer.scene.requestRender();
|
||||
}, site);
|
||||
await wait(4000);
|
||||
const spot = await onScreen([
|
||||
'local-datacenters-tips',
|
||||
'local-datacenters-pins',
|
||||
]);
|
||||
return spot ? { ...spot, kind: 'context', layer: 'data centre' } : null;
|
||||
const spot = await onScreen(
|
||||
(entity) => entity.__localLayerId === 'local-datacenters',
|
||||
);
|
||||
return spot ? { ...spot, layer: 'data centre' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas coordinates of a spot clear of the side panels where the engine's
|
||||
* pick returns an instance of one of the mover sets `owners` (layers draw
|
||||
* through engine.movers; a pick reports the set as `owner` and the instance's
|
||||
* pickId as `id`). Picking, not projecting, proves the spot is clickable: an
|
||||
* instance beyond the horizon or behind a building does not count.
|
||||
* Canvas coordinates of the first entity matching `belongs` that is on screen
|
||||
* and clear of the side panels. Annotation marks and the draw tool's own
|
||||
* preview vertices are on screen too, so the predicate has to be specific.
|
||||
*/
|
||||
async function onScreen(owners, timeout = 20_000) {
|
||||
async function onScreen(belongs, timeout = 20_000) {
|
||||
return page
|
||||
.waitForFunction(
|
||||
(wanted) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const sets = engine.movers
|
||||
.list()
|
||||
.filter((set) => wanted.includes(set.id) && set.count > 0);
|
||||
if (!sets.length) return false;
|
||||
for (let y = 190; y < 600; y += 20) {
|
||||
for (let x = 310; x < 900; x += 20) {
|
||||
const hit = engine.picker.pick({ x, y });
|
||||
if (hit && wanted.includes(hit.owner) && hit.id != null)
|
||||
return { x, y, id: String(hit.id) };
|
||||
(belongsSource) => {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const belongsTo = new Function(`return (${belongsSource})`)();
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const scene = viewer.scene;
|
||||
const collections = [viewer.entities];
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
const source = viewer.dataSources.get(i);
|
||||
if (source?.name === 'gev-draw-preview') continue;
|
||||
collections.push(source.entities);
|
||||
}
|
||||
for (const source of collections) {
|
||||
for (const entity of source.values) {
|
||||
if (!belongsTo(entity)) continue;
|
||||
const position = entity.position?.getValue?.(
|
||||
viewer.clock.currentTime,
|
||||
);
|
||||
if (!position) continue;
|
||||
const window2d = scene.cartesianToCanvasCoordinates(position);
|
||||
if (!window2d) continue;
|
||||
const { x, y } = window2d;
|
||||
if (!(x > 300 && x < 900 && y > 180 && y < 600)) continue;
|
||||
// Projecting into the window is not the same as being clickable:
|
||||
// the entity may be beyond the horizon or behind a building. Only a
|
||||
// spot where the scene actually picks it proves the control case.
|
||||
const hit = scene.pick({ x: Math.round(x), y: Math.round(y) });
|
||||
if (!hit?.id || !belongsTo(hit.id)) continue;
|
||||
return { x: Math.round(x), y: Math.round(y) };
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout, polling: 1000 },
|
||||
owners,
|
||||
{ timeout, polling: 500 },
|
||||
belongs.toString(),
|
||||
)
|
||||
.then(
|
||||
(handle) => handle.jsonValue(),
|
||||
|
||||
Loaded 100 of 1623 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user