mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 22:32:19 +00:00
Compare commits
484
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b703f3ea | ||
|
|
a757beba77 | ||
|
|
ab5c593ab6 | ||
|
|
02ad2d045a | ||
|
|
ed47aeedbb | ||
|
|
6a7fed6cb7 | ||
|
|
2d0aa25e35 | ||
|
|
1856477e0a | ||
|
|
af0dc2e2ec | ||
|
|
ed68be0610 | ||
|
|
b870f446c6 | ||
|
|
49b2849528 | ||
|
|
00cb44ad2c | ||
|
|
ed6b6114cf | ||
|
|
c1795c7dc3 | ||
|
|
69617af7ef | ||
|
|
9e2c424030 | ||
|
|
982d197111 | ||
|
|
3ab0bbf7c1 | ||
|
|
697dc91e81 | ||
|
|
e98bf9c485 | ||
|
|
be395f0f52 | ||
|
|
d83f06755f | ||
|
|
34b4fdce57 | ||
|
|
ed47de6a2c | ||
|
|
ee87482f07 | ||
|
|
1d10ba242d | ||
|
|
cd571dfb32 | ||
|
|
ab41c765cc | ||
|
|
6b0d3f4675 | ||
|
|
4bbf9949d5 | ||
|
|
88f434ded7 | ||
|
|
7c03d68a2f | ||
|
|
705b7a5314 | ||
|
|
97e192ebce | ||
|
|
555906da6b | ||
|
|
b5cc4feacb | ||
|
|
2c62e1e378 | ||
|
|
b3daed4a9b | ||
|
|
087adc6dc9 | ||
|
|
d3433f66c9 | ||
|
|
2dcec2195e | ||
|
|
facc9541ab | ||
|
|
f288b6ba1e | ||
|
|
b73fe82e27 | ||
|
|
39206ef4c3 | ||
|
|
417890b862 | ||
|
|
8caff876cb | ||
|
|
560b72c3f7 | ||
|
|
00153acbeb | ||
|
|
7c5ebf4814 | ||
|
|
225c1c25c7 | ||
|
|
3c304502a0 | ||
|
|
df02033465 | ||
|
|
6c416f3fe6 | ||
|
|
53b07ba91f | ||
|
|
373edf2989 | ||
|
|
ee697422c9 | ||
|
|
731eadc34a | ||
|
|
08d5c86e80 | ||
|
|
dd56cba2f3 | ||
|
|
59ff0b38a2 | ||
|
|
a2905eb700 | ||
|
|
d51e4140b7 | ||
|
|
bcff542b16 | ||
|
|
aac65c21c4 | ||
|
|
6727aaadcd | ||
|
|
4f654df3c0 | ||
|
|
e0d3e93a28 | ||
|
|
82ed68f443 | ||
|
|
ef104aaac3 | ||
|
|
50e486ceec | ||
|
|
75cf0aeaf9 | ||
|
|
a07d9f5d85 | ||
|
|
27939a2d84 | ||
|
|
1ac9f28c4a | ||
|
|
e9ba5e9859 | ||
|
|
37263a3e2b | ||
|
|
18da76e8d8 | ||
|
|
60f9de8511 | ||
|
|
5b27f26e14 | ||
|
|
28f3030c31 | ||
|
|
a90407d547 | ||
|
|
33ccef9662 | ||
|
|
49b180f1fd | ||
|
|
233c4def5b | ||
|
|
0953287c3d | ||
|
|
b03edaaf2b | ||
|
|
9ae7dea918 | ||
|
|
ffbceb977c | ||
|
|
20ad9b41b3 | ||
|
|
bd4f9eaee4 | ||
|
|
9c2a2d6951 | ||
|
|
ba04ceaa32 | ||
|
|
ed6ae54ef6 | ||
|
|
a913e5466e | ||
|
|
2a761aa5eb | ||
|
|
f437c615a0 | ||
|
|
60fb8fee07 | ||
|
|
0ef29781e5 | ||
|
|
69fbf5b38d | ||
|
|
68845a4977 | ||
|
|
b4556f9763 | ||
|
|
7f478e3815 | ||
|
|
371d848eec | ||
|
|
18f69c2699 | ||
|
|
e1095ee553 | ||
|
|
aa6959c97b | ||
|
|
be08163caa | ||
|
|
db4bdba64f | ||
|
|
fbadf792d8 | ||
|
|
fd65288101 | ||
|
|
7f3754d812 | ||
|
|
c1e857f798 | ||
|
|
3814221cbc | ||
|
|
26597ad25c | ||
|
|
0459ee6d58 | ||
|
|
4252ed0798 | ||
|
|
52c6f68bd5 | ||
|
|
656943641c | ||
|
|
f385986383 | ||
|
|
dacfd33f10 | ||
|
|
910d693b94 | ||
|
|
f4b83a3a31 | ||
|
|
3907b7084e | ||
|
|
97c4dc76ce | ||
|
|
c079b329d0 | ||
|
|
c73c5382d4 | ||
|
|
b53b6a48a7 | ||
|
|
47d63128ff | ||
|
|
48885d9e61 | ||
|
|
f1ec9d4a22 | ||
|
|
406dd9122e | ||
|
|
1559bd57f4 | ||
|
|
2fbdca62be | ||
|
|
30c86a6001 | ||
|
|
e18f12d34c | ||
|
|
54fe0aea40 | ||
|
|
974a0b6361 | ||
|
|
6dc643182e | ||
|
|
dc1144b947 | ||
|
|
fc75e245a6 | ||
|
|
145adc08e0 | ||
|
|
f06b93fbc9 | ||
|
|
3e261d1baf | ||
|
|
35e70ec280 | ||
|
|
48988015f1 | ||
|
|
2213be6566 | ||
|
|
59a233db51 | ||
|
|
9daa4bd2db | ||
|
|
92c8f8d9da | ||
|
|
90166024e9 | ||
|
|
9825b02aab | ||
|
|
a6c64713ee | ||
|
|
7abf74518f | ||
|
|
c502404e1d | ||
|
|
350a7f5abb | ||
|
|
0ee92eb864 | ||
|
|
909be62ae7 | ||
|
|
8bea1419f6 | ||
|
|
e1d3a588fc | ||
|
|
843c4a111d | ||
|
|
43b4e9348b | ||
|
|
727bb29cae | ||
|
|
0c0df50a40 | ||
|
|
db9bf64821 | ||
|
|
a2f5b8fca3 | ||
|
|
06ae95ceb3 | ||
|
|
bc1b56b63a | ||
|
|
b3efcfb7a2 | ||
|
|
1100cdcff5 | ||
|
|
f89aee1eac | ||
|
|
b8305de14b | ||
|
|
f87da8523e | ||
|
|
91685c94d9 | ||
|
|
a56f2f9352 | ||
|
|
43ab3ecaaf | ||
|
|
ebc717df8b | ||
|
|
726e201314 | ||
|
|
90d64e6916 | ||
|
|
cbe1d841ea | ||
|
|
b49f9dddf0 | ||
|
|
125fbc5a3b | ||
|
|
9cc108835e | ||
|
|
8d7f055572 | ||
|
|
227e3c2051 | ||
|
|
ae13a56f6d | ||
|
|
9f8381ff71 | ||
|
|
01ae045346 | ||
|
|
59a2e31aa8 | ||
|
|
b20579b06a | ||
|
|
a9282b30bb | ||
|
|
1a3f6f2486 | ||
|
|
c2d7a6dbb0 | ||
|
|
89339cdd96 | ||
|
|
5983c663bd | ||
|
|
ef174b0ff7 | ||
|
|
6ba67d01af | ||
|
|
d8ef366ab1 | ||
|
|
f0d38897f4 | ||
|
|
d8c63c69d8 | ||
|
|
eee4f48745 | ||
|
|
c392d6fb50 | ||
|
|
074f4af10e | ||
|
|
dcb8de9d2c | ||
|
|
4c28cc0212 | ||
|
|
00dfed7959 | ||
|
|
f7945d6844 | ||
|
|
96b5b0bb1f | ||
|
|
5b7171f485 | ||
|
|
f5c14ec30d | ||
|
|
dea10cac4f | ||
|
|
60a230b2ff | ||
|
|
057dbe24af | ||
|
|
f0d7def9c7 | ||
|
|
1773494116 | ||
|
|
3144169057 | ||
|
|
37ea4e1f93 | ||
|
|
31b1f27a2e | ||
|
|
f2117a98f2 | ||
|
|
40a81c58e6 | ||
|
|
df73c5b21a | ||
|
|
c96d4ecfb1 | ||
|
|
d5218c4722 | ||
|
|
4f8a9f2615 | ||
|
|
1c09c90e13 | ||
|
|
dae4011604 | ||
|
|
299a5943a0 | ||
|
|
f7cd6797ec | ||
|
|
1910a3b2d5 | ||
|
|
c45ec634ee | ||
|
|
d86047fe8f | ||
|
|
58c8a481ed | ||
|
|
63b2590b9f | ||
|
|
4a9b95e31b | ||
|
|
b977ef29e3 | ||
|
|
293ffb2bd3 | ||
|
|
b62f60482e | ||
|
|
b29e20632b | ||
|
|
736ee8d694 | ||
|
|
03cd379c0e | ||
|
|
57f7edc693 | ||
|
|
c34a2c38fc | ||
|
|
8e1a3bab3e | ||
|
|
a99b862580 | ||
|
|
2bac200c01 | ||
|
|
061ed1b682 | ||
|
|
d1fdb9eb45 | ||
|
|
741b6f67eb | ||
|
|
17eea266f1 | ||
|
|
111ba1626c | ||
|
|
05578c61b9 | ||
|
|
35ed499ae4 | ||
|
|
f0c2523ac4 | ||
|
|
b2cc00833d | ||
|
|
5564156c09 | ||
|
|
eed8acc3f7 | ||
|
|
f4879fc24b | ||
|
|
7f0572cf4c | ||
|
|
1a1eef0e5c | ||
|
|
a066a2d1ca | ||
|
|
9cc34e324f | ||
|
|
cf6d04b7db | ||
|
|
7fb11507d5 | ||
|
|
53c114965d | ||
|
|
65d41b971c | ||
|
|
4d2b061e3b | ||
|
|
64c5cd25e6 | ||
|
|
64759c552e | ||
|
|
12cd73bed9 | ||
|
|
baabff6acf | ||
|
|
2853938df7 | ||
|
|
dd040b4f87 | ||
|
|
0c190c3a15 | ||
|
|
62296a833c | ||
|
|
05b646daa3 | ||
|
|
14f7b32537 | ||
|
|
160e445677 | ||
|
|
a49671a391 | ||
|
|
133ca14119 | ||
|
|
24190a3e35 | ||
|
|
c4a97a653c | ||
|
|
b01f1ee7dc | ||
|
|
b98cbeebf2 | ||
|
|
75df3389f3 | ||
|
|
4e09559337 | ||
|
|
811bcaff85 | ||
|
|
605b9490f3 | ||
|
|
5cea3e8cbb | ||
|
|
2b4955c817 | ||
|
|
d7b3d45135 | ||
|
|
c1d829ef51 | ||
|
|
868d061856 | ||
|
|
cb350fa58d | ||
|
|
11992828fb | ||
|
|
87bbe14748 | ||
|
|
81bcae907f | ||
|
|
e08ddd787b | ||
|
|
1bda760763 | ||
|
|
b7431d7823 | ||
|
|
d7ab85f5c4 | ||
|
|
2ee09c5911 | ||
|
|
1930ca247a | ||
|
|
6fe5bda028 | ||
|
|
99bda8a35e | ||
|
|
c0062c4d3d | ||
|
|
a0e5f04c30 | ||
|
|
4bd7f97cc4 | ||
|
|
73ea608881 | ||
|
|
2087b55b47 | ||
|
|
8b179a7e01 | ||
|
|
96ce795f8c | ||
|
|
3a2a53aef9 | ||
|
|
0d15e80e28 | ||
|
|
3cbc17a926 | ||
|
|
5e658d8c9d | ||
|
|
f71c76c306 | ||
|
|
b8f4d097c4 | ||
|
|
1da65dc264 | ||
|
|
dc7a7c4d53 | ||
|
|
15f1f9f5b6 | ||
|
|
c76872dfaa | ||
|
|
8279af2e6d | ||
|
|
0df0fb8261 | ||
|
|
8328aab779 | ||
|
|
9ba5a3aba9 | ||
|
|
f59d134928 | ||
|
|
5a8bc33fbd | ||
|
|
b8560e5b26 | ||
|
|
694616d5c2 | ||
|
|
798d6636ab | ||
|
|
93c71ffc86 | ||
|
|
f72bf6a774 | ||
|
|
311e178d0c | ||
|
|
a72cfc4098 | ||
|
|
46b3aceafc | ||
|
|
9d587c1d37 | ||
|
|
d82a6f1550 | ||
|
|
1a694baf5a | ||
|
|
965a82144c | ||
|
|
f4f0088814 | ||
|
|
c58bc9864b | ||
|
|
5dd05a4a81 | ||
|
|
a9b3125a99 | ||
|
|
a3152ab719 | ||
|
|
c89185f352 | ||
|
|
e8870a4766 | ||
|
|
0600964249 | ||
|
|
766c786093 | ||
|
|
4baefeb8f9 | ||
|
|
1839d62160 | ||
|
|
6bdb995fe3 | ||
|
|
000ef99464 | ||
|
|
83fbe6949f | ||
|
|
c8a4f3ed98 | ||
|
|
69a78710cc | ||
|
|
620ada6df3 | ||
|
|
1fe8267550 | ||
|
|
6b27b21cc4 | ||
|
|
6eec8f6cb0 | ||
|
|
71b7177dc0 | ||
|
|
5ff8c5d7a6 | ||
|
|
ed9ea7e679 | ||
|
|
ea1e2f9c04 | ||
|
|
65e30546dd | ||
|
|
0b8cb855fa | ||
|
|
941b308c29 | ||
|
|
10d0e49ddb | ||
|
|
9dc446bd24 | ||
|
|
3a0a3b0551 | ||
|
|
7c0891deda | ||
|
|
e007350645 | ||
|
|
2da7b6d0d9 | ||
|
|
dcfb1219d5 | ||
|
|
ba1191b2e5 | ||
|
|
132a941e9a | ||
|
|
03a0ff3a01 | ||
|
|
b14bf7e013 | ||
|
|
20e9d4a0fa | ||
|
|
1dd3220812 | ||
|
|
443c581280 | ||
|
|
4dfcba8989 | ||
|
|
d81ebf1268 | ||
|
|
2ce1c55f69 | ||
|
|
7f13ef4a87 | ||
|
|
8e702f0c3b | ||
|
|
9f1a0611fa | ||
|
|
c493a8303b | ||
|
|
bf46eb6d97 | ||
|
|
907cb2092d | ||
|
|
ba039f7974 | ||
|
|
18b5475b8a | ||
|
|
bc281ab246 | ||
|
|
ec7dcc944e | ||
|
|
834d34296f | ||
|
|
d6fe0f3805 | ||
|
|
f0ce1b04c7 | ||
|
|
b0cbed081b | ||
|
|
e1be2eac19 | ||
|
|
7d5a484796 | ||
|
|
311722e6de | ||
|
|
d7193e3b9e | ||
|
|
a4e753d550 | ||
|
|
4e195dac6b | ||
|
|
68ff4e1c25 | ||
|
|
645f042450 | ||
|
|
f640890cf6 | ||
|
|
a89719aba9 | ||
|
|
39d522c9a9 | ||
|
|
d483e5897e | ||
|
|
ae52695b15 | ||
|
|
6436f580d9 | ||
|
|
0957d9712b | ||
|
|
93866c42fc | ||
|
|
e804351205 | ||
|
|
3c2e924363 | ||
|
|
8812117013 | ||
|
|
c327a25744 | ||
|
|
b0cb8ce881 | ||
|
|
86aa0bc9ba | ||
|
|
0109097440 | ||
|
|
4623c8b616 | ||
|
|
76851ee949 | ||
|
|
f7f4e9a45c | ||
|
|
7c7422e281 | ||
|
|
4aaf974600 | ||
|
|
ce985768bc | ||
|
|
da6141be4b | ||
|
|
6dad3894bd | ||
|
|
5188e8869d | ||
|
|
bb4e6bafa4 | ||
|
|
c33ee31efc | ||
|
|
f1a2df5cfe | ||
|
|
27e28373d0 | ||
|
|
26968f4903 | ||
|
|
d50eaa28af | ||
|
|
1cca89adf2 | ||
|
|
a8a2ad5582 | ||
|
|
abf16ae8af | ||
|
|
bb8c0b83c4 | ||
|
|
ac081a008d | ||
|
|
177446c0be | ||
|
|
179c846f3e | ||
|
|
80cc0b536e | ||
|
|
312ed416e9 | ||
|
|
b65dbab709 | ||
|
|
8270172086 | ||
|
|
64d3297301 | ||
|
|
4ded3fbf15 | ||
|
|
306e3e9467 | ||
|
|
72fec292d4 | ||
|
|
d112ecf296 | ||
|
|
c6e1b65c67 | ||
|
|
0b9deddd70 | ||
|
|
ec38e6ef16 | ||
|
|
16e63f4c50 | ||
|
|
a3e929870d | ||
|
|
840e34a881 | ||
|
|
a4ac0b9b0d | ||
|
|
90c8e7763a | ||
|
|
b6ac0e9c99 | ||
|
|
6dbd9c983e | ||
|
|
ab0acb0e09 | ||
|
|
12c9651a67 | ||
|
|
3e3aa0bd9f | ||
|
|
83c13f330d | ||
|
|
be56144d75 | ||
|
|
c2a0d20f3d | ||
|
|
aaa6074663 | ||
|
|
58a196374f | ||
|
|
a5c98c6b07 | ||
|
|
ab2368f3f3 | ||
|
|
d831cbd23a | ||
|
|
cd94a26b9f | ||
|
|
708c96b6f6 | ||
|
|
528ec75f32 | ||
|
|
f2fcd82290 | ||
|
|
e3061872cc | ||
|
|
a915bdb3e5 | ||
|
|
ee8b94d987 | ||
|
|
2b83606765 | ||
|
|
1a69368ae6 | ||
|
|
d886be68a8 | ||
|
|
4a92ed8377 |
No files matched your search
@@ -352,11 +352,3 @@ GEMINI_CHAT_MODEL=gemini-3.5-flash-lite
|
||||
# the free plan). After that Local says the daily limit is reached.
|
||||
# Raise it on a paid plan.
|
||||
# WORLDNEWSAPI_DAILY_READS=30
|
||||
|
||||
# Rail layer: every feed is keyless, so nothing needs setting. Packs are an
|
||||
# opt-in extra for live trains (the operator's lines, stop names, next stops):
|
||||
# scripts/build-rail-paths.mjs (--feed <id> ... or --all) writes path and
|
||||
# schedule packs, and the server reads them from this directory
|
||||
# (default .cache/rail-paths). The build reads it from its own environment,
|
||||
# not from this file: export it, or pass the same folder as --out.
|
||||
# RAIL_PATHS_DIR=
|
||||
@@ -59,6 +59,10 @@ 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.
|
||||
|
||||
@@ -14,8 +14,6 @@ qa-shots/
|
||||
output/
|
||||
.gev-logs/
|
||||
.gev-cache/
|
||||
# Local build output, e.g. rail path packs (scripts/build-rail-paths.mjs).
|
||||
.cache/
|
||||
# Browser QA gate evidence (screenshots, console and network dumps).
|
||||
qa-artifacts/
|
||||
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
|
||||
|
||||
+3
-34
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -34,6 +34,7 @@ How to read this:
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **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" |
|
||||
@@ -110,36 +111,6 @@ 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 |
|
||||
| **Metro-North** (api-endpoint.mta.info, rrgtfsfeeds.s3.amazonaws.com) | Rail layer trains, New York, NY: Metro-North Railroad (MTA). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api-endpoint.mta.info) add delays at its stops. | [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions): "Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server." This proxy is that server: browsers never contact MTA. The feed stamps every vehicle with the header time, so a fix is aged from the snapshot, not from the train. | "Trains (MTA): [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions)" |
|
||||
| **LIRR** (api-endpoint.mta.info, rrgtfsfeeds.s3.amazonaws.com) | Rail layer trains, New York, NY: Long Island Rail Road (MTA). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api-endpoint.mta.info) add delays at its stops. | [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions): "Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server." The feed was seen frozen for two days on 2026-11-06; the layer ages trains by their own report time, so a frozen feed empties instead of showing old trains as live. | "Trains (MTA): [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions)" |
|
||||
| **MBTA rail** (cdn.mbta.com) | Rail layer trains, Boston, MA: Massachusetts Bay Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (cdn.mbta.com) add delays at its stops. | [MassDOT Developers License Agreement](https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf): "MassDOT ... hereby grants You (Licensee) non-exclusive, limited, and revocable rights to use, reproduce, and redistribute the Data. ... Clearly acknowledge MassDOT as the provider of the Data." The same feed the Transit layer polls; this layer keeps only Commuter Rail, rapid transit and light rail. | "Trains (MBTA / MassDOT): [MassDOT Developers License Agreement](https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf)" |
|
||||
| **SEPTA Regional Rail** (www3.septa.org, github.com) | Rail layer trains, Philadelphia, PA: Southeastern Pennsylvania Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (www3.septa.org) add delays at its stops. | [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/): "non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data". The agreement names the static data; SEPTA publishes the realtime feeds on the same developer page with no separate terms. Credit is text only: SEPTA trademarks may not be used. | "Trains (SEPTA): [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/)" |
|
||||
| **SEPTA trolleys** (www3.septa.org, github.com) | Rail layer trains, Philadelphia, PA: Southeastern Pennsylvania Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (www3.septa.org) add delays at its stops. | [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/): "non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data". The bus feed, filtered to the trolley routes and the Norristown High Speed Line. The Market-Frankford and Broad Street lines are in no SEPTA feed. Routes T1–T5, G1, D1 and D2 (trams) and M1, the Norristown High Speed Line (metro). | "Trains (SEPTA): [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/)" |
|
||||
| **MARC** (mdotmta-gtfs-rt.s3.amazonaws.com, feeds.mta.maryland.gov) | Rail layer trains, Baltimore–Washington: Maryland Transit Administration. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (mdotmta-gtfs-rt.s3.amazonaws.com) add delays at its stops. | [MDOT MTA developer feeds (no endorsement implied)](https://www.mta.maryland.gov/developer-resources): "MTA does not guarantee the accuracy of the data nor does it warrant or endorse any application that uses this data." Published for open fetch with this disclaimer and no licence; the site terms cover only the website and apps. Shown with attribution and the no-endorsement wording, and switched off if MDOT MTA publishes restrictive terms. | "Trains (Maryland Transit Administration): [MDOT MTA developer feeds (no endorsement implied)](https://www.mta.maryland.gov/developer-resources)" |
|
||||
| **Tri-Rail** (gtfsr.tri-rail.com, gtfs.tri-rail.com) | Rail layer trains, South Florida: South Florida Regional Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsr.tri-rail.com) add delays at its stops. | [No licence stated (open feed pages)](https://gtfsr.tri-rail.com/): "Download Realtime Feeds Below:". Used without a published licence (project owner decision, 2026-11-10). gtfsr.tri-rail.com (the page quoted above, read 2026-11-10) links the GTFS-RT trip updates, alerts and position updates with no terms, and the static GTFS (gtfs.tri-rail.com/gtfs.zip, publisher ETA Transit Systems) states none either; no SFRTA data terms were found. The realtime vehicles carry the train number as their trip id and no route. | "Trains (South Florida Regional Transportation Authority (Tri-Rail)): [No licence stated (open feed pages)](https://gtfsr.tri-rail.com/)" |
|
||||
| **RTD** (open-data.rtd-denver.com, www.rtd-denver.com) | Rail layer trains, Denver, CO: Regional Transportation District. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (open-data.rtd-denver.com) add delays at its stops. | [RTD GTFS-Realtime License Agreement](https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement): "non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data". RTD marks may not be used with the data, so the credit is text only. The published `www` URL redirects off its own origin, which the proxy refuses, so the final host is the one registered. | "Trains (RTD): [RTD GTFS-Realtime License Agreement](https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement)" |
|
||||
| **CapMetro MetroRail** (data.texas.gov) | Rail layer trains, Austin, TX: Capital Metropolitan Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [CapMetro Open Data Terms](https://www.capmetro.org/metrolabs/): "non-exclusive, limited and revocable rights to use, reproduce, and redistribute CMTA Data". The bus feed the Transit layer polls; MetroRail is route 550. | "Trains (CapMetro): [CapMetro Open Data Terms](https://www.capmetro.org/metrolabs/)" |
|
||||
| **METRO light rail** (svc.metrotransit.org) | Rail layer trains, Minneapolis–St Paul, MN: Metro Transit (Metropolitan Council). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [No licence published](https://svc.metrotransit.org/). Publishes no licence or terms: the data feeds page linked above (read 2026-11-10) lists the GTFS and GTFS-realtime files and shows only "© 2020 Metro Transit", so there is nothing to quote. Used without a published licence, on the same basis as the Transit layer's Metro Transit feed, which polls the same file (DATA_SOURCES.md). Blue Line 901 and Green Line 902 only; Northstar ended on 2026-01-04 and route 888 is now a bus. | "Trains (Metro Transit / Metropolitan Council): [No licence published](https://svc.metrotransit.org/)" |
|
||||
| **LYNX** (gtfsrealtime.ridetransit.org) | Rail layer trains, Charlotte, NC: Charlotte Area Transit System. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrealtime.ridetransit.org) add delays at its stops. | [No licence published](https://www.charlottenc.gov/cats). Publishes no terms; used without a published licence (project owner decision, 2026-11-10). The feed host (gtfsrealtime.ridetransit.org) serves the files with no page and no terms, so there is nothing to quote (2026-11-10: 126 vehicles, 13 of them trains; 205 trip updates, 11 on the two lines; every realtime trip id is a static trip id). No CATS page with terms for them was found: charlottenc.gov answered 403 to automated requests, and the CATS Terms and Conditions found there by search govern the CATS mobile app. Routes 501 LYNX Blue Line (light rail) and 510 CityLYNX Gold Line (streetcar), both route_type 0: transit, tram. | "Trains (Charlotte Area Transit System): [No licence published](https://www.charlottenc.gov/cats)" |
|
||||
| **GRT ION** (webapps.regionofwaterloo.ca) | Rail layer trains, Waterloo Region, ON: Grand River Transit (Region of Waterloo). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [Region of Waterloo Open Data Licence v2.0](https://www.regionofwaterloo.ca/government-and-council/transparency-and-accountability/open-data/): "You are free to: Copy, publish, distribute and transmit the Information ... Use the Information commercially". The registered URL is the `/2` light-rail feed; the suffix-less URL serves a file frozen since February 2026 and is not used. | "Trains (Contains information licensed under the Open Data Licence – Region of Waterloo): [Region of Waterloo Open Data Licence v2.0](https://www.regionofwaterloo.ca/government-and-council/transparency-and-accountability/open-data/)" |
|
||||
| **Queensland Rail Citytrain** (gtfsrt.api.translink.com.au) | Rail layer trains, South East Queensland, Australia: Translink (Queensland Government). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrt.api.translink.com.au) add delays at its stops. | [CC BY 4.0](https://translink.com.au/about-translink/open-data): "Our data is licensed under a Creative Commons Attribution 4.0 International License." The terms add: "You must not represent that the State in any way endorses your Application." | "Trains (Translink / Queensland Government): [CC BY 4.0](https://translink.com.au/about-translink/open-data)" |
|
||||
| **G:link** (gtfsrt.api.translink.com.au) | Rail layer trains, Gold Coast, Australia: Translink (Queensland Government). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrt.api.translink.com.au) add delays at its stops. | [CC BY 4.0](https://translink.com.au/about-translink/open-data): "Our data is licensed under a Creative Commons Attribution 4.0 International License." | "Trains (Translink / Queensland Government): [CC BY 4.0](https://translink.com.au/about-translink/open-data)" |
|
||||
| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Rail layer trains, Adelaide, Australia: Adelaide Metro (Rail Commissioner). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfs.adelaidemetro.com.au) add delays at its stops. | [CC BY 4.0](https://data.sa.gov.au/data/dataset/https-gtfs-adelaidemetro-com-au): "Creative Commons Attribution 4.0". Licence as catalogued on data.sa.gov.au; the operator developer page sits behind a bot check. The feed is labelled beta. Its gateway answers base64 text unless the request sends `Accept: application/x-google-protobuf`, so the proxy sends that header for this feed. | "Trains (Adelaide Metro / Government of South Australia): [CC BY 4.0](https://data.sa.gov.au/data/dataset/https-gtfs-adelaidemetro-com-au)" |
|
||||
| **Irish Rail** (api.irishrail.ie, www.transportforireland.ie) | Rail layer trains, Ireland: Iarnród Éireann / Irish Rail. Placed at the station or timing point last reported, not by GPS. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, the delay each train's report states moves its stops. | [No licence published (positions); CC BY 4.0 (NTA timetable)](https://developer.nationaltransport.ie/usagepolicy). The train positions publish no licence or terms: the Irish Rail realtime API page (api.irishrail.ie/realtime/, read 2026-11-10) documents the calls and says only that "Irish Rail provides this information as is" (quoted under `limits`), so there is nothing to quote. They are used without a published licence, credited "Iarnród Éireann / Irish Rail". The NTA's CC BY 4.0 covers only the NTA's GTFS data (the timetable), not these positions. Positions are the station or timing point the signalling system last reported, not GPS. | "Trains (Iarnród Éireann / Irish Rail; timetable: National Transport Authority, provided as is): [No licence published (positions); CC BY 4.0 (NTA timetable)](https://developer.nationaltransport.ie/usagepolicy)" |
|
||||
| **VR (Finland)** (rata.digitraffic.fi) | Rail layer trains, Finland: VR, via Fintraffic Digitraffic. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (rata.digitraffic.fi) add delays at its stops. | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/): "Fintraffic's open data is licensed under the Creative Commons 4.0 By license. It gives the right to distribute, remix, tweak, and build upon our data, even commercially, as long as you credit the source for the original creation." The Digitraffic-User header names the application, never a person. | "Trains (Source: Fintraffic / digitraffic.fi, license CC 4.0 BY): [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/)" |
|
||||
| **Renfe long and medium distance** (gtfsrt.renfe.com, ssl.renfe.com) | Rail layer trains, Spain: Renfe (AVE, Avlo, Alvia, Euromed, Intercity, Media Distancia). Live GPS positions. Its vehicles name no route, so it is listed only once its path and schedule packs are built from its static GTFS; trip updates (gtfsrt.renfe.com) then add delays at its stops. | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): "CC-BY-4.0". Licence field of the data.renfe.com datasets (posicion-vehiculos-av-ld-md and the static GTFS). "Esta información se actualiza cada 15 segundos". Iryo and OUIGO España are not in Renfe’s feeds. | "Trains (Renfe): [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)" |
|
||||
| **Renfe Cercanías** (gtfsrt.renfe.com, ssl.renfe.com) | Rail layer trains, Spain: Renfe Cercanías and Rodalies. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrt.renfe.com) add delays at its stops. | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): "CC-BY-4.0". Licence field of the data.renfe.com Cercanías datasets. Every vehicle in this feed is a Cercanías or Rodalies train. | "Trains (Renfe): [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)" |
|
||||
| **Vy** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: Vy (Norway), via Entur. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" |
|
||||
| **Go-Ahead** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: Go-Ahead Norge, via Entur. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api.entur.io) add delays at its stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" |
|
||||
| **Flytoget** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: Flytoget, via Entur. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api.entur.io) add delays at its stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" |
|
||||
| **SJ Nord** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: SJ Nord, via Entur. Live GPS positions, read from SIRI VM. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, the delay each train's report states moves its stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". SJ Nord trains are missing from Entur’s GTFS-RT vehicle positions (0 vehicles on 2026-11-06); they are read from SIRI VM instead (13 trains on 2026-11-09). Its journey refs are DatedServiceJourneys, not the GTFS trip ids, so a train is matched to its run by line and position. | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" |
|
||||
| **KTMB** (api.data.gov.my) | Rail layer trains, Peninsular Malaysia: Keretapi Tanah Melayu (ETS, Intercity, KTM Komuter, Shuttle Tebrau). Live GPS positions. Its vehicles name no route, so it is listed only once its path and schedule packs are built from its static GTFS. | [CC BY 4.0](https://developer.data.gov.my/realtime-api/gtfs-realtime): "All GTFS Realtime feeds are licensed under CC BY 4.0". developer.data.gov.my, read 2026-11-09; its GTFS Static page says the same of "All GTFS Static feeds". The static GTFS has no shapes. | "Trains (KTMB, via data.gov.my (Government of Malaysia)): [CC BY 4.0](https://developer.data.gov.my/realtime-api/gtfs-realtime)" |
|
||||
| **Seattle Streetcar** (s3.amazonaws.com, metro.kingcounty.gov) | Rail layer trains, Seattle, WA: City of Seattle (South Lake Union and First Hill lines), via King County Metro. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (s3.amazonaws.com) add delays at its stops. | [King County Transit Data Terms of Use](https://kingcounty.gov/en/dept/metro/rider-tools/mobile-and-web-apps): "King County grants you a limited, revocable license to use, reproduce, and redistribute the Data in accordance with these Terms." Transit Data Terms of Use on the page linked above (undated; read 2026-11-10), which links the GTFS and GTFS-RT used here and covers "schedule, geographic, real-time, or any other data posted by King County". King County also permits users "to copy, distribute, store (electronically or otherwise), create derivative works based on and perform and display (publicly or otherwise) such Data, and make such Data available to end users", and asks for the legend the credit carries; no King County service marks beyond it. Commercial use is not mentioned. The feed bundles King County Metro, Sound Transit and City of Seattle routes; only the City of Seattle streetcars (agency 23) are kept, `100340` South Lake Union and `102638` First Hill (transit, tram). Link light rail is not in this GTFS. | "Trains (Transit scheduling, geographic, and real-time data provided by permission of King County): [King County Transit Data Terms of Use](https://kingcounty.gov/en/dept/metro/rider-tools/mobile-and-web-apps)" |
|
||||
| **Connector streetcar** (tmgtfsprd.sorttrpcloud.com, www.go-metro.com) | Rail layer trains, Cincinnati, OH: Southwest Ohio Regional Transit Authority (SORTA/Metro). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (tmgtfsprd.sorttrpcloud.com) add delays at its stops. | [SORTA/Metro developer data Terms](https://www.go-metro.com/about/developer-data/): "The Southwest Ohio Regional Transit Authority (SORTA/Metro) hereby grants you (Licensee) non-exclusive, limited and revocable rights to use, reproduce, and redistribute SORTA/Metro’s transit route schedules, pricing, and other associated data (“Data”) subject to the following Terms:". The developer data page linked above (read 2026-11-10), which links the GTFS and these GTFS-RT files. SORTA/Metro trademarks "may not be used in association with Data unless approved in writing by SORTA/Metro", so the credit is text only; "Any modification, alteration, combination or arrangement of the Data is solely at your own risk." Commercial use is not mentioned. Seen 2026-11-10: 2 streetcars among 147 vehicles, 6 streetcar trips among 265 updates, trip ids those of the static file. The file of 2026-10-01 ends on 2026-12-05. One rail route, `100` Streetcar - OTR - Banks (route_type 0): transit, tram. | "Trains (Southwest Ohio Regional Transit Authority (SORTA/Metro)): [SORTA/Metro developer data Terms](https://www.go-metro.com/about/developer-data/)" |
|
||||
| **Manyōsen** (gtfs-rt-files.buscatch.jp, api.gtfs-data.jp) | Rail layer trains, Toyama, Japan: 万葉線 Manyōsen (Takaoka–Kōshinokata tram). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfs-rt-files.buscatch.jp) add delays at its stops. | [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen): ""feed_license": "CC0 1.0"". GTFS Data Repository record of the feed "万葉線", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. Tram: transit. | "Trains (万葉線株式会社, via the GTFS Data Repository (gtfs-data.jp)): [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen)" |
|
||||
| **Toyama tram** (gtfs-rt-files.buscatch.jp) | Rail layer trains, Toyama, Japan: 富山地方鉄道 Toyama Chihō Railway city tram (city lines, Toyamakō Line, loop line). Live GPS positions. No static GTFS is registered, so there are no path or schedule packs, and its trip updates are not used. | [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/chitetsu/feeds/chitetsushinaidensha): ""feed_license": "CC0 1.0"". GTFS Data Repository record of the feed "富山地方鉄道市内電車", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. The tram-only feed (the company's buses are a separate feed). Tram: transit. | "Trains (富山地方鉄道株式会社, via the GTFS Data Repository (gtfs-data.jp)): [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/chitetsu/feeds/chitetsushinaidensha)" |
|
||||
| **Bordeaux tram** (bdx.mecatran.com) | Rail layer trains, Bordeaux, France: TBM (Bordeaux Métropole): trams A to F. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (bdx.mecatran.com) add delays at its stops. | [Licence Ouverte / Open Licence 1.0](https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite): "fr-lo". Licence field of the transport.data.gouv.fr dataset "Réseau urbain et scolaire TBM" (publisher Bordeaux Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates; Licence Ouverte 1.0 (Etalab) allows reuse, commercial included, with attribution. Tram: transit, tram. | "Trains (Bordeaux Métropole (TBM)): [Licence Ouverte / Open Licence 1.0](https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite)" |
|
||||
| **Montpellier tram** (gtfsproxy.e-tam.fr) | Rail layer trains, Montpellier, France: TaM (Montpellier Méditerranée Métropole): trams 1 to 5. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsproxy.e-tam.fr) add delays at its stops. | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/): "odc-odbl". Licence field of the transport.data.gouv.fr dataset "Réseau urbain TaM" (publisher Montpellier Méditerranée Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates. ODbL is share-alike, with the access point's special conditions (doc.transport.data.gouv.fr "Licence ODbL", read 2026-11-10): the share-alike clause covers information "de même nature, de même granularité, de mêmes conditions temporelles et de même emprise géographique", and "Les bases de données ainsi repartagées doivent être publiées sur le Point d'accès national transport.data.gouv.fr, dans le format d'origine." The path and schedule packs built from this GTFS are such a database once shared: offered under ODbL with the attribution kept. Tram: transit, tram. | "Trains (TaM / Montpellier Méditerranée Métropole): [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-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 |
|
||||
@@ -267,7 +238,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All 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.
|
||||
- **Rail (GTFS-Realtime trains).** Each feed in `src/data/railFeeds.js` records its licence, its credit and the passage of its terms the decision rests on, and a feed is switched on only when those terms were read and cover this use, or, for those that publish no licence at all, on the basis its entry records. Such a basis is never a licence, and the credit does not present it as one: its label says there is no licence ("No licence published", "No licence stated") or names the publication the use rests on. Five enabled feeds publish no licence for their train positions. MARC is used on what its publication says (open publication with a no-endorsement disclaimer), and METRO light rail on the same basis as the Transit layer's Metro Transit feed. Tri-Rail and Charlotte's LYNX are used without a published licence by project owner decision of 2026-11-10. The fifth is Irish Rail, whose train positions publish no licence while its NTA timetable is CC BY 4.0. The registry lists 30 feeds, all switched on, and every one is keyless: its realtime feed and its static timetable are published for anyone to fetch, so no feed needs a key, an account or a registration, and the server holds no rail credential. No rail request carries a credential of the deployer's; one feed's published URL includes a fixed public value its publisher prints for everyone (Bordeaux's `apiKey`). `/api/rail/feeds` lists the offered feeds, `/api/rail/trains/<feedId>` returns one feed's train snapshot, `/api/rail/updates/<feedId>` its trip updates, and `/api/rail/paths/<feedId>` and `/api/rail/schedule/<feedId>` its path and schedule packs, when built. The proxy requests only registered feeds and reuses the Transit proxy's request policy (a short snapshot cache with single-flight refresh, serve-stale, admission limits, the failure cooldown ladder, redirects followed hop by hop only on the feed's own https origin, and an 8 MiB body cap unless a feed sets its own, at most 64 MiB). Many operators publish trains in the same feed as their buses, so each feed has a classifier: the proxy keeps only vehicles it recognises as trains, tags each with a category (`transit`, `local`, `regional`, `national` or `international`) and a mode (`rail`, `metro` or `tram`), and drops everything else server-side, counted in the snapshot as `dropped`; the browser never receives a bus. Every train shown is live, where its operator last reported it: 29 feeds publish live GPS positions (SJ Nord's through SIRI VM), and Irish Rail reports the station or timing point a train last passed. No train is placed or moved from a timetable; a feed whose live positions fail shows as unavailable, and its trains age out as any quiet train does. 22 feeds register trip updates, polled while one of their trains is selected and that feed's schedule pack is loaded. 28 feeds work out of the box. Packs are an opt-in extra for live trains: nothing is downloaded when the app starts, and `node scripts/build-rail-paths.mjs --feed <id>` (repeatable) or `--all` downloads a feed's static GTFS (29 of the 30 have one) and writes a path pack (the operator's track shapes) and a schedule pack (the stop times for a window of days) to `.cache/rail-paths/` (or to `RAIL_PATHS_DIR` when set, the directory the server then serves). With them, a live train is drawn on its operator's route line and its card names its stops, its next stop and, from the trip updates, their delays. Renfe long and medium distance and KTMB, whose vehicles name no route, are classified from their pack's routes, so `/api/rail/feeds` lists them only once that pack is built. Lines are the operator's own shapes as published: nothing is routed or redrawn from other map data, and no line is drawn where an operator publishes no shape. **Share-alike sources bind the deployer.** A pack built from a ShareAlike or ODbL source is a derived database of it, so a deployer who serves it publicly makes it subject to the same licence and must offer it under that licence with the attribution kept. That covers the Montpellier packs (ODbL). For the ODbL datasets of France's national access point, its special conditions also require that "Les bases de données ainsi repartagées doivent être publiées sur le Point d'accès national transport.data.gouv.fr, dans le format d'origine" ([Licence ODbL](https://doc.transport.data.gouv.fr/le-point-d-acces-national/cadre-juridique/conditions-dutilisation-des-donnees/licence-odbl), read 2026-11-10): a deployer who serves the Montpellier packs publicly takes on republishing that derived data there, as GTFS. The build downloads each static GTFS itself, over https and with no credential. A feed's `staticRouteTypes` lists the route types it keeps when its classifier accepts every route of a mode-specific feed. A frequency-based trip becomes one run per departure on its template's stops and run times. Packs are build output, not part of the repository, and cover a fixed window of days, so they are rebuilt before it ends, anLine truncated
|
||||
- **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.
|
||||
@@ -299,7 +269,6 @@ 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 |
|
||||
| **Ukraine outline** (Rail layer no-positions rule; 1 ring, 7,801 points) | `src/data/railNoPositions.js` | **ODbL 1.0** (OpenStreetMap, via geoBoundaries gbOpen UKR ADM0, commit 9469f09; simplified to 0.0005°) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + geoBoundaries |
|
||||
| **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" |
|
||||
@@ -358,7 +327,7 @@ credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders.
|
||||
|
||||
### Bundled OpenStreetMap data
|
||||
|
||||
Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), the Rail layer's Ukraine outline in [`railNoPositions.js`](src/data/railNoPositions.js) (OpenStreetMap via geoBoundaries gbOpen, Runfola et al. 2020, simplified; the module's code stays MIT), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL.
|
||||
Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL.
|
||||
|
||||
### NASA FIRMS acknowledgement
|
||||
|
||||
|
||||
@@ -340,6 +340,8 @@ 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` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
|
||||
---
|
||||
@@ -384,7 +386,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic
|
||||
- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
|
||||
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients.
|
||||
|
||||
Thirty-five tools (thirty-four unless an Overpass server is set), four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
Thirty tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
|
||||
**🎥 Direct it** — drone-operator camera verbs:
|
||||
|
||||
@@ -446,7 +448,6 @@ Twenty-three layers and map sources. **Twenty-one have a keyless path.** Some of
|
||||
| 📷 **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), 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 | 🟢 |
|
||||
| 🚆 **Rail** | Live trains across the US, Canada, Australia, Ireland, Finland, Spain, Norway, Malaysia, Japan and France, from 30 keyless operator feeds: live GPS positions from 29 of them and Irish Rail’s station reports, 28 live out of the box, and Renfe long and medium distance and KTMB once their pack is built. No train is placed from a timetable. Opt-in packs (`node scripts/build-rail-paths.mjs --feed <id>` or `--all`) add the operator’s own route lines, stop names, next stops and trip-update delays. Coloured by service category (urban, local, regional, national, international) with a filter chip and legend for each; trains are drawn once when several feeds carry them, and open a card per train. Buses in mixed feeds are dropped by the server | Operator GTFS-Realtime feeds (static GTFS for opt-in packs) | 🟢 |
|
||||
| 🚲 **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 |
|
||||
|
||||
@@ -74,7 +74,6 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **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.
|
||||
- **Rail fetches registered feeds only, and none needs a credential.** `/api/rail/feeds` lists the offered feeds; `/api/rail/trains/<id>`, `/api/rail/updates/<id>`, `/api/rail/paths/<id>` and `/api/rail/schedule/<id>` resolve the id against `src/data/railFeeds.js` (`RAIL_FEED_ID_PATTERN`: lowercase letters, digits and hyphens). The browser never supplies a URL, and an id that is not registered, or a trip-updates route for a feed that registers none, does not resolve at all; any method but GET answers 405 (`Allow: GET`, never cached). Every registered feed and every static timetable is open: no rail request carries a credential of the deployer's; one feed's published URL includes a fixed public value its publisher prints for everyone (Bordeaux's `apiKey`), and the only rail setting the server reads is `RAIL_PATHS_DIR`. Upstream requests follow the Transit proxy's policy: https only, each redirect hop validated against the feed's own https origin before it is requested (three hops at most), a request timeout, bodies capped at 8 MiB unless the registry sets a feed's own cap (never above 64 MiB), and a per-feed admission limiter and the failure cooldown ladder bound what the process asks of any operator. Responses are decoded server-side: GTFS-Realtime under entity-count and string-length ceilings, a differential feed is refused, and an empty or header-less GTFS-Realtime reply (an error page served as 200, say) is refused as unreadable, so the last good snapshot is served stale or the request fails with 502. The Irish Rail and SIRI VM XML is read by small scanners, not an XML library: a document that declares a DTD or an entity is refused, and the whole text, the number of trains and every field are capped. Only vehicles the feed's classifier recognises as trains leave the server, and no train position inside Ukraine is ever sent (`src/data/railNoPositions.js`). Snapshots and trip updates live in memory for the feed's own freshness window, with serve-stale after a failure; there is no disk cache and no history. Path and schedule packs are optional build output, read from disk (`.cache/rail-paths/` or `RAIL_PATHS_DIR`) by a reader that re-checks the id against the same pattern, so a name never leaves that directory. A pack over 24 MiB is refused before it is read and answers 503, never a partial file, and so does a pack that cannot be read: only a missing pack is 404; the rest pass through as bytes, through a 64 MiB in-memory read cache keyed by each file's modification time and size, and are served as JSON holding timetables and track shapes only, `no-cache` with an ETag from that time and size (304 when unchanged). A schedule pack of another format version or another feed, or whose window has ended, is not used, and a feed that cannot be read without it answers 503 rather than an empty list. The pack build (`scripts/build-rail-paths.mjs`) runs only when a person runs it, never on app start: it downloads over https only (a redirect to plain http is refused), caps each download in size and time, sends no credential, and refuses an `--out` folder (or a `RAIL_PATHS_DIR` it writes to) inside `src/` or `public/`.
|
||||
- **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. 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.
|
||||
@@ -133,7 +132,6 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`
|
||||
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
|
||||
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
|
||||
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
|
||||
- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,<lat>,<lon>` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; freshness is judged only by the result's `issued_at`, so a result older than five minutes only arms, whether the panel was mounted again (reopening a conversation) or got it later on the same mount, while one re-sent or remounted within five minutes of issue opens the photo again; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The OPEN press uses the link's point only while the ground under the screen centre is within 1 km of it, or when the screen centre cannot be picked at all (no ground picking, or a globe with no size yet); with the sky in the middle it opens nothing. The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link pasted into an open tab follows the same rule, and a link's source switch and layer are never saved as the recipient's own preferences. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image.
|
||||
|
||||
## Scope & expectations
|
||||
|
||||
|
||||
+22
-2
@@ -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; the complete resolved dependency inventory remains in
|
||||
`package-lock.json`.
|
||||
Street Level viewer, and the CesiumJS code ported into `src/geo/`; the complete
|
||||
resolved dependency inventory remains in `package-lock.json`.
|
||||
|
||||
## Web RTL-SDR
|
||||
|
||||
@@ -45,3 +45,23 @@ Street Level viewer; the complete resolved dependency inventory remains in
|
||||
- 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>
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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,6 +6,9 @@
|
||||
* 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/';
|
||||
@@ -18,6 +21,27 @@ 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,
|
||||
@@ -26,6 +50,10 @@ 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 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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/);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
});
|
||||
+50
-2
@@ -5,6 +5,9 @@ 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
|
||||
@@ -183,7 +186,9 @@ export function createBrowserViteConfig({
|
||||
apiResponseHeadersPlugin(),
|
||||
// Next, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
cesium(),
|
||||
// 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 },
|
||||
// Application views are Vue single-file components.
|
||||
vue(VUE_PLUGIN_OPTIONS),
|
||||
...plugins,
|
||||
@@ -194,6 +199,20 @@ 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
|
||||
@@ -206,6 +225,18 @@ 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',
|
||||
@@ -258,6 +289,23 @@ export function createBrowserViteConfig({
|
||||
mapillaryToken ?? '',
|
||||
),
|
||||
},
|
||||
build: { chunkSizeWarningLimit: 1500 },
|
||||
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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
- "Show me X on Street View" (or on Mapillary, KartaView or Panoramax): a
|
||||
`show_street_level` voice and typed action flies to the place, switches to
|
||||
Google 3D when the source needs it, lights that source in Street Level,
|
||||
turns the layer on and opens the nearest photo, saying which step failed.
|
||||
With no place named it looks under the screen centre, as OPEN does. Views
|
||||
and share links carry the same request (`street_level`, `sl=`). A plain
|
||||
`sl=google` link never loads a billed panorama by itself: it arms Street
|
||||
View and leaves the load to OPEN GOOGLE STREET VIEW; embedded views open it,
|
||||
and so does the God's Eye View panel for a result issued in the last five
|
||||
minutes; a link whose source has no working key says so. A request stops
|
||||
when the user navigates, or enters cockpit, clean view, recording or scene
|
||||
playback, before its photo loads (collapsing the panel does not stop it: the
|
||||
photo reveals it); a new photo replaces the open one in place, so EXPAND
|
||||
stays. When nothing is found and a 360°, flat or SINCE filter is on, the
|
||||
answer names the filter. Failure cards are in the interface language. An
|
||||
unavailable map stack now reports the
|
||||
controller's own reason instead of always asking for a Cesium ion token
|
||||
(Said Turksever, @asturksever, #1113).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Routing outages report "Routing service unavailable" in voice route planning
|
||||
and "Routing unavailable" in Directions instead of "no route found"
|
||||
(quanticsoul4772, #1509).
|
||||
@@ -1,5 +0,0 @@
|
||||
- FIRMS fire ages round to whole hours before choosing a unit, so an age of
|
||||
47.5–48 hours reads "2d", as exactly 48 hours does, not "48h". Detections
|
||||
are filtered to the trailing 24 hours, so this shows mostly in the
|
||||
`STALE · cached {age}` stats line after about two days without a successful
|
||||
refresh (@quanticsoul4772, #1531).
|
||||
@@ -0,0 +1,10 @@
|
||||
- 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).
|
||||
@@ -1,24 +0,0 @@
|
||||
- Add Rail in the Movement group, off by default: live trains from 30 keyless
|
||||
operator feeds in the United States, Canada, Australia, Ireland, Finland,
|
||||
Spain, Norway, Malaysia, Japan and France, from commuter and long-distance
|
||||
rail to the streetcars and trams of Seattle, Cincinnati, Toyama, Bordeaux
|
||||
and Montpellier. A new `/api/rail` proxy fetches registered feeds only,
|
||||
needs no key and drops buses from mixed feeds. Every train shown is live:
|
||||
29 feeds publish GPS positions and Irish Rail its station reports, and 28
|
||||
of them show with no setup. No train is placed or moved from a timetable;
|
||||
a feed whose live positions fail shows as unavailable. Packs are an opt-in
|
||||
extra: `node scripts/build-rail-paths.mjs --feed <id>` (or `--all`) builds
|
||||
a feed's path and schedule packs into `.cache/rail-paths/`, which add the
|
||||
operator's own route lines, stop names, the next stop and trip-update
|
||||
delays to its live trains; Renfe long and medium distance and KTMB, whose
|
||||
vehicles name no route, appear once their pack is built. Nothing is
|
||||
downloaded when the app starts, and lines are only ever the operator's own
|
||||
shapes. Trains are coloured by category (urban, local, regional, national,
|
||||
international), each with a chip and legend entry shared via token `02`,
|
||||
glide between reports with Transit's delayed playback, are drawn once when
|
||||
several feeds carry them, and open a card on click. A selected train that
|
||||
is moving is followed by the camera, as an aircraft is, and any other is
|
||||
flown to once; the selected train and its line stay on the map from orbit.
|
||||
No train is placed in Ukraine. Feeds that publish no licence are used on
|
||||
the basis each one's entry records, and their credit never presents that
|
||||
as a licence (Denis Groshev, @ZloyS, #1675).
|
||||
@@ -1,4 +0,0 @@
|
||||
- The News layer reads its time from the world clock, so a host that follows
|
||||
the clock for News can show a past time's reports with their ages, window and
|
||||
counts as they were then. Nothing changes while the timeline is live
|
||||
(Denis Groshev, @ZloyS, #1677).
|
||||
@@ -1,6 +0,0 @@
|
||||
- Fix the page freezing for seconds at a time with Transit on over a terrain
|
||||
globe, most visibly while zooming over a large fleet. Every reread asked the
|
||||
terrain for the height of every trail fix of every vehicle in view; heights
|
||||
are now kept per point, read again only while the camera moves or tiles
|
||||
load, and read within a time budget that puts vehicle positions first
|
||||
(mazy1998, #1688).
|
||||
@@ -1,5 +0,0 @@
|
||||
- Read terrain heights through one shared, cached and budgeted reader
|
||||
(`src/services/terrainSurface.js`) in Transit, Rail, Bike Share and the local
|
||||
data layers. Opening Data Centers no longer blocks the page for over two
|
||||
seconds, opening Bike Share for most of a second, and Rail asks the terrain
|
||||
about a twentieth as often (mazy1998, #1689).
|
||||
@@ -195,6 +195,10 @@ signal })` returns one tile or null for a transparent tile, each as an
|
||||
`ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested
|
||||
size, which the layer draws once and closes. Without them the layers read the
|
||||
image and tile routes (`WeatherFrameFeed` in `src/layers/weather/source.js`).
|
||||
On globe surfaces, a source with `getFrameTile` currently requires Cesium for
|
||||
radar, regional infrared and lightning. Navara pauses those products with
|
||||
`This weather source requires Cesium`; decoded global infrared images and
|
||||
the whole-frame images used over 3D tiles draw through the shared display.
|
||||
|
||||
`application/services` accepts boundary, terrain, regional-context, weather and
|
||||
summary services; its weather service supplies cockpit local conditions, not the
|
||||
|
||||
+120
-12
@@ -19,6 +19,17 @@ excluded. The formatter validates every entry before writing any file.
|
||||
|
||||
New world text labels must use the world-overlay host; Cesium text labels are forbidden.
|
||||
|
||||
## QA scripts
|
||||
|
||||
New `scripts/qa-*.mjs` scenarios use `scripts/qa/browser.mjs` for headless launch
|
||||
and bounded shutdown, and `scripts/qa/ready.mjs` for application, camera and
|
||||
frame readiness. Select the GPU backend with `QA_ANGLE`. Keep timed pauses only
|
||||
when elapsed time is part of the assertion, and explain that measurement.
|
||||
Read scene state through the globe contract or the QA probe; native reads belong
|
||||
only in explicit engine-adapter checks. The counters in
|
||||
`node scripts/report-qa-harness.mjs` must not grow; lower the baseline with
|
||||
`--write-baseline` after removing duplication.
|
||||
|
||||
## Current component ownership
|
||||
|
||||
Package imports use `gods-eye-view`; `package.json` is the authoritative
|
||||
@@ -30,8 +41,10 @@ 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 Cesium resources with explicit controller/renderer owners |
|
||||
| `src/layers/<family>/` | Source acquisition, records and shared contract displays with explicit lifecycle 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 |
|
||||
@@ -53,18 +66,70 @@ 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. Unused imports still count. Node exports have only a `node`
|
||||
dependencies. Shared foundations use directory entries: `src/i18n/`,
|
||||
`src/geo/` and `src/globe/`. An entry ending in `/` owns the files below it;
|
||||
`src/globe/` excludes `cesium/`, `navara/` and `testing/`, which need separate
|
||||
ownership. The import-direction gate still enforces engine rules.
|
||||
Aggregate groups use directories where their module lists cover the directory;
|
||||
small groups retain explicit snapshots outside the shared foundations.
|
||||
Unused imports still count. Node exports have only a `node`
|
||||
condition; browser groups cannot use build-only dependency exceptions.
|
||||
|
||||
Run `node scripts/check-package-boundaries.mjs --write` to regenerate each
|
||||
group's `modules` list in `scripts/package-boundaries.json`, retaining useful
|
||||
entries in order and appending newly reached paths in sorted order. Directory
|
||||
entries stay directories while they own reached modules; they never expand
|
||||
into file lists. Without `--write`, the check rejects both undeclared reached
|
||||
modules and declarations no longer reached, printing additions and removals
|
||||
for each changed group. A directory becomes stale only when no reached module
|
||||
belongs to it. Review the manifest diff like a lockfile diff before accepting
|
||||
changes to ownership.
|
||||
|
||||
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. 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
|
||||
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
|
||||
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 two
|
||||
exceptions are the Bhote Koshi event and locator 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's
|
||||
Cesium rendering (the particle canvas, its GPU path and the relief shading)
|
||||
lives in `src/globe/cesium/`; `src/layers/wind/` keeps the data, model,
|
||||
presentation and engine-neutral inspection. The remaining annotation migration
|
||||
is 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
|
||||
@@ -391,10 +456,12 @@ the caller.
|
||||
## Visual effects
|
||||
|
||||
`ui/effects` exports the effects controller and existing preset definitions.
|
||||
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
|
||||
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
|
||||
without loading the renderer. UI presentation and product-action coordination
|
||||
remain in their callers.
|
||||
|
||||
@@ -506,7 +573,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 the application's render governor.
|
||||
these to their engine's render requests.
|
||||
|
||||
A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`.
|
||||
Each source has a user-facing `descriptor`, availability/reason, and either an
|
||||
@@ -550,12 +617,22 @@ 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. Scene engines remain page-owned,
|
||||
so this change does not introduce multiple simultaneous viewers.
|
||||
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.
|
||||
|
||||
Direct `src/data` compatibility entries retain their old defaults and testing
|
||||
exports. Browser regression probes use the registered instance's testing surface
|
||||
@@ -594,6 +671,23 @@ 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
|
||||
@@ -768,3 +862,17 @@ 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.
|
||||
+372
-482
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(viewer, style, data, { dataPacks: { sources } })` accept a
|
||||
`new SceneDirector(scene, 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,
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
# 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/index.mjs`.
|
||||
|
||||
A new contract member lands in one change with its implementation on Cesium,
|
||||
Navara and the fake engine, a behavioral check in the contract suite, and a
|
||||
mutant that the check kills. Keep check names prefixed with the member they
|
||||
exercise; optional behavior belongs in `engineCapabilities`.
|
||||
|
||||
## 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 image or plain-colour overlay is a mesh at its altitude,
|
||||
including negative heights (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` and a rotated overlay whose box passes a pole 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 the shared
|
||||
overlay pass after tone mapping and anti-aliasing, the pass that draws sprites
|
||||
and lines, 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.
|
||||
|
||||
Three reports keep migration debt from growing: `scripts/report-engine-coupling.mjs`
|
||||
counts application coupling, `scripts/report-qa-harness.mjs` counts browser QA
|
||||
duplication, and `src/globe/testing/contractCoverage.test.mjs` reports contract
|
||||
members the fake-engine suite leaves uncalled. After removing coupling or moving
|
||||
QA work to `scripts/qa-strict-harness.mjs` helpers, run the matching report with
|
||||
`--write-baseline` and keep only decreases in its baseline. After adding behavioral
|
||||
contract checks, remove the newly exercised names from
|
||||
`src/globe/testing/contract-uncalled-baseline.json`; keep it sorted, and remove
|
||||
members that no longer exist too. Run the matching tests with
|
||||
`node --import=./build/registerVue.js --test` followed by their paths; the numeric
|
||||
baselines reject growth, and the contract baseline also rejects stale entries.
|
||||
|
||||
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 a viewer, or start the application.
|
||||
calling the factory does not load data, create an engine, 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,7 +21,13 @@ const layers = createInfrastructureLayers({
|
||||
governorRequestRender,
|
||||
});
|
||||
|
||||
// Register these layers with the existing data-layer manager and viewer.
|
||||
// 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 });
|
||||
```
|
||||
|
||||
| Function | Arguments / responsibility |
|
||||
@@ -34,30 +40,40 @@ const layers = createInfrastructureLayers({
|
||||
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
|
||||
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
||||
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||
| `terrainSurface` | Optional. The application's shared terrain surface (`groundFloor.terrainSurface`), so its layers read each terrain height once; without it each layer keeps its own |
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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/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.
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
Dataset files and source/license notices remain under `src/data/local_data/`.
|
||||
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.
|
||||
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.
|
||||
+63
-1
@@ -1,6 +1,6 @@
|
||||
# KNOWN ISSUES
|
||||
|
||||
Updated: November 5, 2026
|
||||
Updated: November 10, 2026
|
||||
|
||||
This file tracks active runtime issues only.
|
||||
|
||||
@@ -10,6 +10,68 @@ For the roadmap and open backlog, see the repository issue tracker.
|
||||
|
||||
## Open
|
||||
|
||||
### Supplied decoded weather tiles require Cesium on globe surfaces
|
||||
|
||||
Status: Open (Cesium only)
|
||||
|
||||
Custom sources supplying individual decoded radar, regional infrared or
|
||||
lightning tiles use Cesium's imagery provider scheduler
|
||||
(`src/globe/cesium/weatherFrames.js`). Navara hides these controls on globe
|
||||
surfaces and requests no supplied tiles there. Full-extent cloud images and
|
||||
raised weather shells remain available on both engines. A full port needs
|
||||
decoded tile callbacks in the imagery contract and the Navara adapter.
|
||||
|
||||
### Street-vehicle label depth occlusion requires Cesium
|
||||
|
||||
Status: Open (Cesium only)
|
||||
|
||||
GPU depth occlusion for street-vehicle brackets and callouts uses Cesium's
|
||||
depth probe (`src/data/detection.js`). Navara retains the existing geometric
|
||||
visibility checks, without the GPU depth result. The contract needs an
|
||||
asynchronous depth-probe operation before this can run on both engines.
|
||||
|
||||
### Large raised KML polygons omit their fill
|
||||
|
||||
Status: Open (bounded triangulation)
|
||||
|
||||
On both engines, raised polygons with more than 32,768 vertices draw only
|
||||
their outlines. Those with more than 128 rings draw neither fill nor
|
||||
outlines. These limits bound main-thread triangulation and outline work;
|
||||
the omitted geometry is counted as undrawn in the import result.
|
||||
|
||||
### Navara terrain tokens are not refreshed while the page stays open
|
||||
|
||||
Status: Open (reload required)
|
||||
|
||||
An expired terrain token is not refreshed during an open Navara session.
|
||||
Reload the page to request a new terrain token.
|
||||
|
||||
### Navara does not fall back when a map source fails
|
||||
|
||||
Status: Open (manual map selection)
|
||||
|
||||
Navara does not report tile errors, so a failing map source does not trigger
|
||||
fallback to another map. Select another map manually when tiles fail to load.
|
||||
|
||||
### Orbital imagery look-ahead is unavailable on Navara
|
||||
|
||||
Status: Open (Cesium only)
|
||||
|
||||
The orbital cockpit works on both engines. On Navara it loads imagery for
|
||||
its current view but does not prefetch imagery ahead of the ground track.
|
||||
At high optical zoom, imagery can arrive later than on Cesium. Navara's
|
||||
package does not expose a raster prefetch operation sharing its tile cache
|
||||
and request priorities.
|
||||
|
||||
### Ship bridge waterline uses loaded geometry on Navara
|
||||
|
||||
Status: Open (terrain fallback)
|
||||
|
||||
Navara's ship bridge samples loaded mesh geometry and detailed terrain for
|
||||
the waterline. It does not request the finest mesh detail around the ship,
|
||||
so the bridge height can differ while nearby tiles load. Cesium retains its
|
||||
finest-detail mesh sampling.
|
||||
|
||||
### Wildlife grid relies on the iNaturalist UTFGrid cell schema
|
||||
|
||||
Status: Open (guarded fallback)
|
||||
|
||||
@@ -68,16 +68,6 @@ cannot name still makes a place view. Opening a vessel thing view with a
|
||||
world time waits for the vessel layer's snapshot at that time and selects the
|
||||
vessel once it has it.
|
||||
|
||||
## Street-level photos (`sl`)
|
||||
|
||||
Views built by `show_in_gods_eye_view` or `src/view/index.js` can add
|
||||
`sl=<source>,<lat>,<lon>` to the long form. Once the link has restored, the
|
||||
app opens the nearest photo there, or, for a source that bills every load,
|
||||
arms the panel so OPEN loads it (see `docs/TOOLS.md`). The app's own links do
|
||||
not carry it: the address bar's long form drops `sl` when the camera next
|
||||
moves, and the short form has no field for it. A reload or the app's Copy
|
||||
Link keeps the lit source, the layer and the map, but not the photo request.
|
||||
|
||||
## Payload
|
||||
|
||||
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
|
||||
@@ -133,7 +123,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 |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm, sentinel2 |
|
||||
| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
|
||||
| 17 | `bloomIntensity` | uint | style | |
|
||||
| 18 | `bloomVersion` | uint | 2 | |
|
||||
|
||||
+5
-23
@@ -102,29 +102,11 @@ action draws (pins, highlights, areas, arrows, routes and labels at a place
|
||||
name or coordinates). Links carry them in the `an` parameter, and the app
|
||||
draws them once the link has been restored.
|
||||
|
||||
A view can open street-level photos too: `street_level` names a source
|
||||
(`google` for Street View, `mapillary`, `kartaview`, `panoramax`, or none for
|
||||
the lit one) and a point, by default the spot the camera looks at. Street View
|
||||
shows only on the Google 3D map, so a `google` source sets `map` to
|
||||
`photoreal` and refuses another; a view cannot both follow an entity and open
|
||||
photos. Links carry it as `sl=<source>,<lat>,<lon>`: once restored, the app
|
||||
runs `show_street_level`, which flies there and opens the nearest photo. A
|
||||
plain link with a `google` source only arms the panel for OPEN GOOGLE STREET
|
||||
VIEW; a view posted into the embedded app opens it, and so does the panel
|
||||
page's own opening link, but only for a tool result issued in the last five
|
||||
minutes (an older one, such as a host remounting the panel, only arms);
|
||||
`?embed=1` on a published link does not. Mapillary,
|
||||
KartaView and Panoramax, which do not bill per image, open at once. A link
|
||||
that follows an entity ignores `sl`.
|
||||
|
||||
Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a
|
||||
`camera`, plus `layers`, `style`, `map`, `follow`, `annotations` and
|
||||
`street_level`. A tilted camera over an area looks at its center from behind;
|
||||
camera fields given with an area override its framing. A passed-through
|
||||
view's `street_level` point moves with a new `area` or camera position (a
|
||||
turned camera keeps it). `resolveViewArguments` turns them into a view. The
|
||||
app's own links (the address bar, Copy Link, `/share/<code>`) do not carry
|
||||
`sl`; see `docs/SHARE-LINK-FORMAT.md`.
|
||||
`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A
|
||||
tilted camera over an area looks at its center from behind; camera fields
|
||||
given with an area override its framing. `resolveViewArguments` turns them
|
||||
into a view.
|
||||
|
||||
### The God's Eye View panel
|
||||
|
||||
@@ -317,5 +299,5 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
|
||||
| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section |
|
||||
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
|
||||
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks, something to follow, or street-level photos (Street View or Mapillary) at a place |
|
||||
| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow |
|
||||
| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel |
|
||||
@@ -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 `subscribeWorldOverlayActions()`, which
|
||||
the world overlay presents through `subscribeActions()`, which
|
||||
the application shell publishes to the `gev-world-overlay-actions` store
|
||||
and answers with `activateWorldOverlayAction()`; the module needs no Vue
|
||||
and answers with `activateAction()`; 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
|
||||
|
||||
Generated
+1113
-5
File diff suppressed because it is too large.
Load diff
+11
-9
@@ -54,21 +54,28 @@
|
||||
"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"
|
||||
"mcp": "node server/mcp/stdio.js",
|
||||
"check:main-chunk": "node scripts/check-main-chunk.mjs"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -90,7 +97,7 @@
|
||||
"./infrastructure/geojson": "./src/data/localGeojsonCore.js",
|
||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||
"./application": "./src/app/application.js",
|
||||
"./application/viewer": "./src/app/viewer.js",
|
||||
"./application/viewer": "./src/globe/cesium/viewer.js",
|
||||
"./time": "./src/time/worldClock.js",
|
||||
"./build/vite": {
|
||||
"node": "./build/vite.js"
|
||||
@@ -204,8 +211,8 @@
|
||||
"./geography/neighborhoods": "./src/data/neighborhoodPolygons.js",
|
||||
"./maps/controller": "./src/maps/controller.js",
|
||||
"./maps/catalog": "./src/maps/catalog.js",
|
||||
"./maps/imagery": "./src/maps/imagery.js",
|
||||
"./maps/terrain": "./src/maps/terrain.js",
|
||||
"./maps/imagery": "./src/globe/cesium/mapImagery.js",
|
||||
"./maps/terrain": "./src/globe/cesium/terrain.js",
|
||||
"./maps/3d": "./src/maps/google3d.js",
|
||||
"./maps/defaults": "./src/maps/defaultSources.js",
|
||||
"./voice/controller": "./src/voice/realtimeController.js",
|
||||
@@ -285,11 +292,6 @@
|
||||
"./layers/transit/standalone": "./src/data/transit.js",
|
||||
"./layers/transit/source": "./src/layers/transit/source.js",
|
||||
"./sources/transit-service": "./src/sources/transitService.js",
|
||||
"./server/providers/rail": {
|
||||
"node": "./server/providers/rail.js"
|
||||
},
|
||||
"./sources/rail-service": "./src/sources/railService.js",
|
||||
"./layers/rail/source": "./src/layers/rail/source.js",
|
||||
"./sources/map-features": "./src/sources/overpassFeatures.js",
|
||||
"./server/providers/mapillary": {
|
||||
"node": "./server/providers/mapillary.js"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,257 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as geo from '../src/geo/index.js';
|
||||
|
||||
const directory = new URL('./fixtures/tiles3d/', import.meta.url);
|
||||
const origin = { lon: -97.7431, lat: 30.2672 };
|
||||
const transform = geo.transforms.eastNorthUpToFixedFrame(
|
||||
geo.cartesian3.fromDegrees(origin.lon, origin.lat),
|
||||
);
|
||||
const box = (west, south, east, north, bottom, top) => [
|
||||
[
|
||||
[west, south, top],
|
||||
[east, south, top],
|
||||
[east, north, top],
|
||||
[west, north, top],
|
||||
],
|
||||
[
|
||||
[west, north, bottom],
|
||||
[east, north, bottom],
|
||||
[east, south, bottom],
|
||||
[west, south, bottom],
|
||||
],
|
||||
[
|
||||
[west, south, bottom],
|
||||
[east, south, bottom],
|
||||
[east, south, top],
|
||||
[west, south, top],
|
||||
],
|
||||
[
|
||||
[east, south, bottom],
|
||||
[east, north, bottom],
|
||||
[east, north, top],
|
||||
[east, south, top],
|
||||
],
|
||||
[
|
||||
[east, north, bottom],
|
||||
[west, north, bottom],
|
||||
[west, north, top],
|
||||
[east, north, top],
|
||||
],
|
||||
[
|
||||
[west, north, bottom],
|
||||
[west, south, bottom],
|
||||
[west, south, top],
|
||||
[west, north, top],
|
||||
],
|
||||
];
|
||||
|
||||
// glTF is Y-up; the tileset transform is east/north/up after the 3D Tiles
|
||||
// loader's Y-to-Z rotation. Untextured flat faces need no external assets.
|
||||
function glb(faces) {
|
||||
const positions = [],
|
||||
normals = [];
|
||||
for (const face of faces) {
|
||||
const vertices = face.map(([x, y, z]) => [x, z, -y]);
|
||||
const [a, b, c] = vertices;
|
||||
const u = b.map((v, i) => v - a[i]);
|
||||
const v = c.map((v, i) => v - a[i]);
|
||||
const normal = [
|
||||
u[1] * v[2] - u[2] * v[1],
|
||||
u[2] * v[0] - u[0] * v[2],
|
||||
u[0] * v[1] - u[1] * v[0],
|
||||
];
|
||||
const length = Math.hypot(...normal);
|
||||
for (let i = 1; i < vertices.length - 1; i++)
|
||||
for (const vertex of [a, vertices[i], vertices[i + 1]]) {
|
||||
positions.push(...vertex);
|
||||
normals.push(...normal.map((n) => n / length));
|
||||
}
|
||||
}
|
||||
const bytes = Buffer.from(
|
||||
new Float32Array([...positions, ...normals]).buffer,
|
||||
);
|
||||
const bounds = (fn) =>
|
||||
[0, 1, 2].map((axis) => fn(...positions.filter((_, i) => i % 3 === axis)));
|
||||
const json = Buffer.from(
|
||||
JSON.stringify({
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [
|
||||
{
|
||||
primitives: [{ attributes: { POSITION: 0, NORMAL: 1 }, material: 0 }],
|
||||
},
|
||||
],
|
||||
materials: [
|
||||
{
|
||||
pbrMetallicRoughness: {
|
||||
baseColorFactor: [0.5, 0.5, 0.5, 1],
|
||||
metallicFactor: 0,
|
||||
roughnessFactor: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
buffers: [{ byteLength: bytes.length }],
|
||||
bufferViews: [0, 1].map((i) => ({
|
||||
buffer: 0,
|
||||
byteOffset: i * positions.length * 4,
|
||||
byteLength: positions.length * 4,
|
||||
target: 34962,
|
||||
})),
|
||||
accessors: [
|
||||
{
|
||||
bufferView: 0,
|
||||
componentType: 5126,
|
||||
count: positions.length / 3,
|
||||
type: 'VEC3',
|
||||
min: bounds(Math.min),
|
||||
max: bounds(Math.max),
|
||||
},
|
||||
{
|
||||
bufferView: 1,
|
||||
componentType: 5126,
|
||||
count: positions.length / 3,
|
||||
type: 'VEC3',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const padded = Buffer.alloc(Math.ceil(json.length / 4) * 4, 0x20);
|
||||
json.copy(padded);
|
||||
const result = Buffer.alloc(12 + 8 + padded.length + 8 + bytes.length);
|
||||
result.writeUInt32LE(0x46546c67, 0);
|
||||
result.writeUInt32LE(2, 4);
|
||||
result.writeUInt32LE(result.length, 8);
|
||||
result.writeUInt32LE(padded.length, 12);
|
||||
result.writeUInt32LE(0x4e4f534a, 16);
|
||||
padded.copy(result, 20);
|
||||
result.writeUInt32LE(bytes.length, 20 + padded.length);
|
||||
result.writeUInt32LE(0x004e4942, 24 + padded.length);
|
||||
bytes.copy(result, 28 + padded.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
function probe(x, y, z) {
|
||||
const position = geo.matrix4.multiplyByPoint(transform, { x, y, z }, {});
|
||||
const cartographic = geo.cartographic.fromCartesian(position);
|
||||
return {
|
||||
position,
|
||||
cartographic,
|
||||
lon: geo.math.toDegrees(cartographic.longitude),
|
||||
lat: geo.math.toDegrees(cartographic.latitude),
|
||||
height: cartographic.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTilesFixture() {
|
||||
const roof = box(110, -60, 210, 60, 0, 60);
|
||||
roof.shift();
|
||||
roof.push(
|
||||
[
|
||||
[110, -60, 60],
|
||||
[160, -60, 100],
|
||||
[160, 60, 100],
|
||||
[110, 60, 60],
|
||||
],
|
||||
[
|
||||
[160, -60, 100],
|
||||
[210, -60, 60],
|
||||
[210, 60, 60],
|
||||
[160, 60, 100],
|
||||
],
|
||||
[
|
||||
[110, -60, 60],
|
||||
[210, -60, 60],
|
||||
[160, -60, 100],
|
||||
],
|
||||
[
|
||||
[210, 60, 60],
|
||||
[110, 60, 60],
|
||||
[160, 60, 100],
|
||||
],
|
||||
);
|
||||
const tiles = [
|
||||
[
|
||||
'plate',
|
||||
box(-300, -300, 300, 300, -2, 0),
|
||||
[0, 0, -1, 300, 0, 0, 0, 300, 0, 0, 0, 1],
|
||||
],
|
||||
[
|
||||
'block50',
|
||||
box(-210, -60, -110, 60, 0, 50),
|
||||
[-160, 0, 25, 50, 0, 0, 0, 60, 0, 0, 0, 25],
|
||||
],
|
||||
[
|
||||
'block200',
|
||||
box(-50, 120, 50, 220, 0, 200),
|
||||
[0, 170, 100, 50, 0, 0, 0, 50, 0, 0, 0, 100],
|
||||
],
|
||||
['roof', roof, [160, 0, 50, 50, 0, 0, 0, 60, 0, 0, 0, 50]],
|
||||
];
|
||||
const tileset = {
|
||||
asset: { version: '1.1' },
|
||||
geometricError: 1000,
|
||||
root: {
|
||||
transform: Array.from(transform),
|
||||
boundingVolume: { box: [0, 0, 99, 300, 0, 0, 0, 300, 0, 0, 0, 101] },
|
||||
geometricError: 1000,
|
||||
refine: 'ADD',
|
||||
children: tiles.map(([name, , box]) => ({
|
||||
boundingVolume: { box },
|
||||
geometricError: 0,
|
||||
content: { uri: `${name}.glb` },
|
||||
})),
|
||||
},
|
||||
extras: {
|
||||
origin,
|
||||
datum: 'WGS84 ellipsoid; local ENU heights in metres',
|
||||
probes: {
|
||||
plate: probe(0, -170, 0),
|
||||
block50: probe(-160, 0, 50),
|
||||
block200: probe(0, 170, 200),
|
||||
roof: probe(180, 0, 84),
|
||||
},
|
||||
// More than one pass of surfacePoints, all away from the roof edges.
|
||||
points: Array.from({ length: 40 }, (_, i) => {
|
||||
const x = 140 + (i % 8) * 5;
|
||||
return probe(
|
||||
x,
|
||||
-40 + Math.floor(i / 8) * 20,
|
||||
100 - 0.8 * Math.abs(x - 160),
|
||||
);
|
||||
}),
|
||||
},
|
||||
};
|
||||
const files = new Map(
|
||||
tiles.map(([name, faces]) => [`${name}.glb`, glb(faces)]),
|
||||
);
|
||||
files.set(
|
||||
'tileset.json',
|
||||
Buffer.from(JSON.stringify(tileset, null, 2) + '\n'),
|
||||
);
|
||||
assert.ok(
|
||||
[...files.values()].reduce((sum, b) => sum + b.length, 0) < 200_000,
|
||||
);
|
||||
return files;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
const check = process.argv.includes('--check');
|
||||
const files = buildTilesFixture();
|
||||
if (!check) mkdirSync(directory, { recursive: true });
|
||||
for (const [name, bytes] of files) {
|
||||
const file = new URL(name, directory);
|
||||
if (check)
|
||||
assert.deepEqual(readFileSync(file), bytes, `${name} is reproducible`);
|
||||
else writeFileSync(file, bytes);
|
||||
}
|
||||
console.log(
|
||||
`${check ? 'Checked' : 'Generated'} ${files.size} fixture files (${[...files.values()].reduce((sum, b) => sum + b.length, 0)} bytes)`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// 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.');
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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$/;
|
||||
const code = /\.(?:[mc]?js|vue)$/;
|
||||
const entry = (file) =>
|
||||
file === 'src/main.js' || file.startsWith('src/standalone/');
|
||||
const tests = (file) =>
|
||||
@@ -19,6 +19,17 @@ 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(
|
||||
@@ -70,6 +81,25 @@ 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) =>
|
||||
@@ -87,7 +117,10 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
let analysis;
|
||||
try {
|
||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
|
||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
|
||||
dynamic: true,
|
||||
filename: file,
|
||||
});
|
||||
} catch (error) {
|
||||
report(file, error.message);
|
||||
return null;
|
||||
@@ -109,6 +142,19 @@ 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:'))
|
||||
@@ -131,6 +177,18 @@ 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 (
|
||||
@@ -177,6 +235,8 @@ 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);
|
||||
@@ -202,7 +262,12 @@ export function checkImportDirections(root) {
|
||||
if (
|
||||
builtins.has(specifier) ||
|
||||
specifier.startsWith('node:') ||
|
||||
/^(?:cesium|vite)(?:\/|$)/.test(specifier)
|
||||
/^(?: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)))
|
||||
)
|
||||
report(
|
||||
file,
|
||||
@@ -217,6 +282,51 @@ 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) {
|
||||
@@ -231,8 +341,17 @@ 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 };
|
||||
return {
|
||||
modules: files.length,
|
||||
portableEntries: roots.size,
|
||||
engineImportExceptionCount: usedExceptions.size,
|
||||
engineImportExceptions: [...usedExceptions],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/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();
|
||||
@@ -1,17 +1,28 @@
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import { readFile, realpath, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
|
||||
import { build, normalizePath } from 'vite';
|
||||
import * as prettier from 'prettier';
|
||||
|
||||
/** Build each browser or Node export group and reject imports outside its declared ownership. */
|
||||
/** Build each browser or Node export group and check its module snapshot. */
|
||||
export async function checkPackageBoundaries(root) {
|
||||
return buildPackageBoundaries(root, false);
|
||||
}
|
||||
|
||||
/** Regenerate module ownership from each export group's complete build graph. */
|
||||
export async function writePackageBoundaries(root) {
|
||||
return buildPackageBoundaries(root, true);
|
||||
}
|
||||
|
||||
async function buildPackageBoundaries(root, write) {
|
||||
root = await realpath(root);
|
||||
const readJson = async (name) =>
|
||||
JSON.parse(await readFile(path.join(root, name), 'utf8'));
|
||||
const pkg = await readJson('package.json');
|
||||
const groups = await readJson('scripts/package-boundaries.json');
|
||||
const manifest = await readJson('scripts/package-boundaries.json');
|
||||
const { engineImportExceptions, ...groups } = manifest;
|
||||
const declaredExports = Object.keys(pkg.exports || {}).sort();
|
||||
const classifiedExports = Object.values(groups)
|
||||
.flatMap((group) => group.exports)
|
||||
@@ -25,6 +36,7 @@ export async function checkPackageBoundaries(root) {
|
||||
);
|
||||
}
|
||||
const reports = [];
|
||||
const differences = [];
|
||||
for (const [name, group] of Object.entries(groups)) {
|
||||
const node = group.runtime === 'node';
|
||||
if (group.runtime && !['browser', 'node'].includes(group.runtime)) {
|
||||
@@ -32,12 +44,23 @@ export async function checkPackageBoundaries(root) {
|
||||
}
|
||||
if (
|
||||
!Array.isArray(group.modules) ||
|
||||
!group.modules.length ||
|
||||
(!write && !group.modules.length) ||
|
||||
!Array.isArray(group.external)
|
||||
) {
|
||||
throw new Error(`Invalid package boundary: ${name}`);
|
||||
}
|
||||
// The shared globe directory excludes engines and their test support.
|
||||
const allowed = new Set();
|
||||
const allowedDirectories = [];
|
||||
const modulePaths = new Map();
|
||||
const globeDirectory = `${normalizePath(path.join(root, 'src/globe'))}/`;
|
||||
const ownsDirectory = (id, directory) =>
|
||||
id.startsWith(directory) &&
|
||||
(directory !== globeDirectory ||
|
||||
!/^(?:cesium|navara|testing)\//.test(id.slice(directory.length)));
|
||||
const owns = (id) =>
|
||||
allowed.has(id) ||
|
||||
allowedDirectories.some((directory) => ownsDirectory(id, directory));
|
||||
for (const module of group.modules) {
|
||||
if (
|
||||
typeof module !== 'string' ||
|
||||
@@ -47,7 +70,13 @@ export async function checkPackageBoundaries(root) {
|
||||
) {
|
||||
throw new Error(`Boundary modules must be repository paths: ${name}`);
|
||||
}
|
||||
const resolved = await realpath(path.join(root, module));
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await realpath(path.join(root, module));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') continue;
|
||||
throw error;
|
||||
}
|
||||
const relative = path.relative(root, resolved);
|
||||
if (
|
||||
relative === '..' ||
|
||||
@@ -56,7 +85,12 @@ export async function checkPackageBoundaries(root) {
|
||||
) {
|
||||
throw new Error(`Boundary module escapes repository: ${name}`);
|
||||
}
|
||||
allowed.add(normalizePath(resolved));
|
||||
if (module.endsWith('/')) {
|
||||
allowedDirectories.push(`${normalizePath(resolved)}/`);
|
||||
} else {
|
||||
allowed.add(normalizePath(resolved));
|
||||
}
|
||||
modulePaths.set(module, normalizePath(resolved));
|
||||
}
|
||||
for (const external of group.external) {
|
||||
if (
|
||||
@@ -79,11 +113,7 @@ export async function checkPackageBoundaries(root) {
|
||||
) {
|
||||
throw new Error(`Node export must have only a node condition: ${key}`);
|
||||
}
|
||||
if (
|
||||
typeof target !== 'string' ||
|
||||
!target.startsWith('./') ||
|
||||
!allowed.has(normalizePath(path.resolve(root, target)))
|
||||
) {
|
||||
if (typeof target !== 'string' || !target.startsWith('./')) {
|
||||
throw new Error(`Export must point to an owned module: ${key}`);
|
||||
}
|
||||
return path.resolve(root, target);
|
||||
@@ -109,11 +139,6 @@ 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 (!allowed.has(id)) {
|
||||
throw new Error(
|
||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
||||
);
|
||||
}
|
||||
seen.add(id);
|
||||
},
|
||||
},
|
||||
@@ -138,9 +163,58 @@ export async function checkPackageBoundaries(root) {
|
||||
},
|
||||
},
|
||||
});
|
||||
reports.push({ name, exports: input.length, modules: seen.size });
|
||||
const reached = [...seen];
|
||||
const kept = group.modules.filter((module) => {
|
||||
const resolved = modulePaths.get(module);
|
||||
return module.endsWith('/')
|
||||
? reached.some((id) => ownsDirectory(id, `${resolved}/`))
|
||||
: seen.has(resolved);
|
||||
});
|
||||
const removed = group.modules.filter((module) => !kept.includes(module));
|
||||
const added = reached
|
||||
.filter((id) => !owns(id))
|
||||
.map((id) => {
|
||||
const relative = path.relative(root, id);
|
||||
if (
|
||||
!path.isAbsolute(id) ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new Error(`Boundary module escapes repository: ${name}`);
|
||||
}
|
||||
return normalizePath(relative);
|
||||
})
|
||||
.sort();
|
||||
if (added.length || removed.length)
|
||||
differences.push({ name, added, removed });
|
||||
if (write) {
|
||||
group.modules = [...kept, ...added];
|
||||
} else {
|
||||
reports.push({ name, exports: input.length, modules: seen.size });
|
||||
}
|
||||
}
|
||||
return reports;
|
||||
if (!write && differences.length) {
|
||||
throw new Error(
|
||||
[
|
||||
'Package boundary snapshots differ. Run node scripts/check-package-boundaries.mjs --write and review the diff.',
|
||||
...differences.flatMap(({ name, added, removed }) => [
|
||||
`${name}: ${added.length} added, ${removed.length} removed.`,
|
||||
...added.map((module) => ` + ${module}`),
|
||||
...removed.map((module) => ` - ${module}`),
|
||||
]),
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
if (write && differences.length) {
|
||||
const file = path.join(root, 'scripts/package-boundaries.json');
|
||||
const formatted = await prettier.format(JSON.stringify(manifest), {
|
||||
...(await prettier.resolveConfig(file)),
|
||||
filepath: file,
|
||||
});
|
||||
await writeFile(file, formatted);
|
||||
}
|
||||
return write ? differences : reports;
|
||||
}
|
||||
|
||||
const invoked = process.argv[1]
|
||||
@@ -148,13 +222,25 @@ const invoked = process.argv[1]
|
||||
: '';
|
||||
if (import.meta.url === invoked) {
|
||||
try {
|
||||
const reports = await checkPackageBoundaries(
|
||||
fileURLToPath(new URL('../', import.meta.url)),
|
||||
);
|
||||
for (const report of reports)
|
||||
console.log(
|
||||
`Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`,
|
||||
);
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
if (process.argv.includes('--write')) {
|
||||
const reports = await writePackageBoundaries(root);
|
||||
for (const { name, added, removed } of reports) {
|
||||
console.log(
|
||||
`Updated ${name}: ${added.length} added, ${removed.length} removed.`,
|
||||
);
|
||||
if (added.length + removed.length <= 20) {
|
||||
for (const module of added) console.log(` + ${module}`);
|
||||
for (const module of removed) console.log(` - ${module}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const reports = await checkPackageBoundaries(root);
|
||||
for (const report of reports)
|
||||
console.log(
|
||||
`Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
|
||||
import { fakeContractOptions } from '../src/globe/testing/contractFixtures.js';
|
||||
import {
|
||||
createContractSuite,
|
||||
runContractSuite,
|
||||
} from '../src/globe/testing/contractSuite/index.mjs';
|
||||
|
||||
const replace = (object, member, wrap) => {
|
||||
object[member] = wrap(object[member].bind(object));
|
||||
};
|
||||
const handle = (engine, area, factory, mutate) =>
|
||||
replace(engine[area], factory, (create) => (...args) => {
|
||||
const value = create(...args);
|
||||
mutate(value);
|
||||
return value;
|
||||
});
|
||||
|
||||
const mutants = [
|
||||
[
|
||||
'field of view changes are ignored',
|
||||
'camera.setFovy: changes projection',
|
||||
(e) => { e.camera.setFovy = () => {}; },
|
||||
],
|
||||
[
|
||||
'optical fields below one degree are ignored',
|
||||
'camera.setFovy: changes projection',
|
||||
(e) => {
|
||||
replace(e.camera, 'setFovy', (set) => (radians) => {
|
||||
if (radians >= Math.PI / 180) set(radians);
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'retarget omits followChanged',
|
||||
'camera.following: set, retarget',
|
||||
(e) => {
|
||||
let previous;
|
||||
replace(e.camera.followChanged, 'raiseEvent', (raise) => (value) => {
|
||||
if (value !== previous) raise(value);
|
||||
previous = value;
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'pointer cancels an active flight',
|
||||
'camera.flyTo: a pointer gesture',
|
||||
(e) => {
|
||||
replace(e.fake, 'input', (input) => (type, event) => {
|
||||
if (type === 'down') e.camera.cancelFlight();
|
||||
input(type, event);
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'terrain errors are labelled as tiles',
|
||||
'maps.errors: terrain failures',
|
||||
(e) => {
|
||||
replace(
|
||||
e.maps.errors,
|
||||
'raiseEvent',
|
||||
(raise) => (error) => raise({ ...error, kind: 'tile' }),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'interrupted flight omits cancel',
|
||||
'camera.flyTo: a newer flight',
|
||||
(e) => {
|
||||
replace(
|
||||
e.camera,
|
||||
'flyTo',
|
||||
(fly) => (options) => fly({ ...options, cancel: undefined }),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'pure rotation omits changed',
|
||||
'camera.changed: a pure heading',
|
||||
(e) => {
|
||||
let previous = e.camera.pose().position;
|
||||
replace(e.camera.changed, 'raiseEvent', (raise) => (...args) => {
|
||||
const position = e.camera.pose().position;
|
||||
const moved =
|
||||
Math.hypot(
|
||||
position.x - previous.x,
|
||||
position.y - previous.y,
|
||||
position.z - previous.z,
|
||||
) > 0.01;
|
||||
previous = position;
|
||||
if (moved) raise(...args);
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'request during render is lost',
|
||||
'render.request: a request inside',
|
||||
(e) => {
|
||||
let inFrame = false;
|
||||
e.frames.preRender.addEventListener(() => {
|
||||
inFrame = true;
|
||||
});
|
||||
e.frames.postRender.addEventListener(() => {
|
||||
inFrame = false;
|
||||
});
|
||||
replace(e.render, 'request', (request) => (...args) => {
|
||||
if (!inFrame) request(...args);
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'terrain revision stays fixed',
|
||||
'maps.terrainRevision: changes when setTerrain',
|
||||
(e) => {
|
||||
e.maps.terrainRevision = () => 0;
|
||||
},
|
||||
],
|
||||
[
|
||||
'removed mover remains listed',
|
||||
'movers.list: shown follows',
|
||||
(e) => {
|
||||
const removed = [];
|
||||
const list = e.movers.list;
|
||||
handle(e, 'movers', 'createSet', (set) => {
|
||||
replace(set, 'destroy', (destroy) => () => {
|
||||
const row = list().find(({ id }) => id === set.id);
|
||||
if (row) removed.push(row);
|
||||
destroy();
|
||||
});
|
||||
});
|
||||
e.movers.list = () => [...list(), ...removed];
|
||||
},
|
||||
],
|
||||
[
|
||||
'stop omits followChanged',
|
||||
'camera.following: set, retarget',
|
||||
(e) => {
|
||||
replace(e.camera.followChanged, 'raiseEvent', (raise) => (value) => {
|
||||
if (value !== null) raise(value);
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'following forgets the handle',
|
||||
'camera.following: set, retarget',
|
||||
(e) => {
|
||||
e.camera.following = () => null;
|
||||
},
|
||||
],
|
||||
[
|
||||
'retarget keeps the old target',
|
||||
'camera.following: set, retarget',
|
||||
(e) => {
|
||||
handle(e, 'camera', 'follow', (follow) => {
|
||||
follow.retarget = () => {};
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'texture limit is not a power of two',
|
||||
'render.maxTextureSize:',
|
||||
(e) => {
|
||||
e.render.maxTextureSize = () => 3;
|
||||
},
|
||||
],
|
||||
[
|
||||
'continuous mode never changes',
|
||||
'render.setContinuous: continuous',
|
||||
(e) => {
|
||||
e.render.setContinuous = () => {};
|
||||
},
|
||||
],
|
||||
[
|
||||
'clock is never latched',
|
||||
'clock.frameTime: advances before',
|
||||
(e) => {
|
||||
e.clock.frameTime = () => 0;
|
||||
},
|
||||
],
|
||||
[
|
||||
'terrain samples are reversed',
|
||||
'surface.sampleTerrainMostDetailed: preserves',
|
||||
(e) => {
|
||||
replace(
|
||||
e.surface,
|
||||
'sampleTerrainMostDetailed',
|
||||
(sample) =>
|
||||
async (...args) =>
|
||||
(await sample(...args)).reverse(),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'unknown terrain becomes zero',
|
||||
'surface.sampleTerrainMostDetailed: preserves',
|
||||
(e) => {
|
||||
replace(
|
||||
e.surface,
|
||||
'sampleTerrainMostDetailed',
|
||||
(sample) =>
|
||||
async (...args) =>
|
||||
(await sample(...args)).map((height) => height ?? 0),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'terrain rejection is swallowed',
|
||||
'surface.sampleTerrainMostDetailed: passes',
|
||||
(e) => {
|
||||
replace(
|
||||
e.surface,
|
||||
'sampleTerrainMostDetailed',
|
||||
(sample) =>
|
||||
async (...args) => {
|
||||
try {
|
||||
return await sample(...args);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'failed model reports no failure',
|
||||
'movers.createSet: failed model retries',
|
||||
(e) => {
|
||||
handle(e, 'movers', 'createSet', (set) => {
|
||||
set.failed = () => false;
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'failed model reports ready',
|
||||
'movers.createSet: failed model retries',
|
||||
(e) => {
|
||||
handle(e, 'movers', 'createSet', (set) => {
|
||||
set.ready = () => true;
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'failed model never retries',
|
||||
'movers.createSet: failed model retries',
|
||||
(e) => {
|
||||
handle(e, 'movers', 'createSet', (set) => {
|
||||
replace(set, 'commit', (commit) => () => {
|
||||
const schedule = globalThis.setTimeout;
|
||||
globalThis.setTimeout = () => null;
|
||||
try {
|
||||
commit();
|
||||
} finally {
|
||||
globalThis.setTimeout = schedule;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'hidden mover reports shown',
|
||||
'movers.list: shown follows',
|
||||
(e) => {
|
||||
replace(
|
||||
e.movers,
|
||||
'list',
|
||||
(list) => () => list().map((row) => ({ ...row, shown: true })),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'map error loses HTTP status',
|
||||
'maps.errors: forwards source',
|
||||
(e) => {
|
||||
replace(
|
||||
e.maps.errors,
|
||||
'raiseEvent',
|
||||
(raise) => (error) => raise({ ...error, status: undefined }),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'map errors are swallowed',
|
||||
'maps.errors: forwards source',
|
||||
(e) => {
|
||||
e.maps.errors.raiseEvent = () => {};
|
||||
},
|
||||
],
|
||||
[
|
||||
'old map providers keep reporting',
|
||||
'maps.errors: forwards source',
|
||||
(e) => {
|
||||
replace(e.maps, 'setBaseImagery', (set) => async (source) => {
|
||||
await set(source);
|
||||
source.provider?.errorEvent?.addEventListener((error) => {
|
||||
e.maps.errors.raiseEvent({
|
||||
source,
|
||||
kind: 'tile',
|
||||
status: error?.statusCode,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'flight remains active after completion',
|
||||
'camera.flyTo: duration 0',
|
||||
(e) => {
|
||||
e.camera.flightActive = () => true;
|
||||
},
|
||||
],
|
||||
[
|
||||
'setView leaves the flight running',
|
||||
'camera.setView: cancels an active',
|
||||
(e) => {
|
||||
e.camera.setView = ({ destination, orientation }) =>
|
||||
e.fake.setPose({ position: destination, ...orientation });
|
||||
},
|
||||
],
|
||||
[
|
||||
'instant flight omits complete',
|
||||
'camera.flyTo: duration 0',
|
||||
(e) => {
|
||||
replace(
|
||||
e.camera,
|
||||
'flyTo',
|
||||
(fly) => (options) => fly({ ...options, complete: undefined }),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'hidden meshes remain shown',
|
||||
'meshes.createMesh: picks use',
|
||||
(e) => {
|
||||
handle(e, 'meshes', 'createMesh', (mesh) => {
|
||||
replace(
|
||||
mesh,
|
||||
'show',
|
||||
(show) => (value) => show(value === false ? true : value),
|
||||
);
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'imagery ignores alpha changes',
|
||||
'imagery.createLayer: layers are',
|
||||
(e) => {
|
||||
handle(e, 'imagery', 'createLayer', (layer) => {
|
||||
layer.setAlpha = () => {};
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'clearing tiles forgets base imagery',
|
||||
'maps.setBaseImagery: setPhotorealTiles',
|
||||
(e) => {
|
||||
replace(e.maps, 'setPhotorealTiles', (set) => async (source) => {
|
||||
await set(source);
|
||||
if (source === null) e.fake.maps.imagery = null;
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'tiles-only ground also draws on terrain',
|
||||
'ground.createSet: tiles drape',
|
||||
(e) => {
|
||||
replace(
|
||||
e.ground,
|
||||
'createSet',
|
||||
(create) => (options) =>
|
||||
create({
|
||||
...options,
|
||||
drape: options.drape === 'tiles' ? 'both' : options.drape,
|
||||
}),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'released photoreal tiles still answer surface queries',
|
||||
'surface.sampleHeightDetailed: leaving photoreal',
|
||||
(e) => {
|
||||
replace(e.maps, 'setPhotorealTiles', (set) => async (source) => {
|
||||
await set(source);
|
||||
if (source === null)
|
||||
e.fake.script('sampleHeightDetailed', () => ({
|
||||
height: 50,
|
||||
source: 'tiles',
|
||||
}));
|
||||
});
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
for (const [name, killedBy, mutate] of mutants) {
|
||||
test(`${name} — killed by ${killedBy}`, async () => {
|
||||
const engine = createFakeEngine();
|
||||
try {
|
||||
mutate(engine);
|
||||
const results = await runContractSuite(engine, {
|
||||
...fakeContractOptions(engine),
|
||||
maxWaitFrames: 160,
|
||||
});
|
||||
const check = results.find(({ name }) => name.startsWith(killedBy));
|
||||
assert.ok(check, `No check named ${killedBy}`);
|
||||
assert.equal(check.ok, false, `Surviving mutant: ${name}`);
|
||||
assert.ok(
|
||||
check.error.name === 'ContractError' ||
|
||||
check.error.message === 'No frame requested',
|
||||
`Unexpected failure: ${check.error.stack}`,
|
||||
);
|
||||
} finally {
|
||||
engine.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('released tile surface check runs without a tileset fixture', async () => {
|
||||
const engine = createFakeEngine();
|
||||
try {
|
||||
const check = createContractSuite(engine, {
|
||||
...fakeContractOptions(engine),
|
||||
fixtures: null,
|
||||
mapSources: null,
|
||||
}).find(({ name }) =>
|
||||
name.startsWith('surface.sampleHeightDetailed: leaving photoreal'),
|
||||
);
|
||||
assert.ok(check);
|
||||
await check.run();
|
||||
engine.fake.script('sampleHeightDetailed', () => ({
|
||||
height: 50,
|
||||
source: 'tiles',
|
||||
}));
|
||||
await assert.rejects(check.run(), /released photoreal tiles/);
|
||||
} finally {
|
||||
engine.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('tiles-only ground pixels on terrain are detected without a tileset fixture or a picking defect', async () => {
|
||||
for (const drawsOnTerrain of [false, true]) {
|
||||
const engine = createFakeEngine();
|
||||
try {
|
||||
const check = createContractSuite(engine, {
|
||||
...fakeContractOptions(engine),
|
||||
fixtures: null,
|
||||
mapSources: null,
|
||||
readRegion(x, y, width, height) {
|
||||
const shown = engine.ground
|
||||
.list()
|
||||
.some((set) => set.id === 'contract-tiles-drape' && set.shown);
|
||||
const color =
|
||||
drawsOnTerrain && shown
|
||||
? [255, 0, 255, 255]
|
||||
: [50, 100, 150, 255];
|
||||
return Uint8Array.from(
|
||||
{ length: width * height * 4 },
|
||||
(_, i) => color[i % 4],
|
||||
);
|
||||
},
|
||||
}).find(({ name }) => name.startsWith('ground.createSet: tiles drape'));
|
||||
assert.ok(check, 'tiles drape check runs without a tileset fixture');
|
||||
if (drawsOnTerrain)
|
||||
await assert.rejects(check.run(), /tiles drape changed terrain pixels/);
|
||||
else await check.run();
|
||||
} finally {
|
||||
engine.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 "^[[:space:]]*createApplicationCctv({" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
|
||||
! grep -q "createApplicationCctv(" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
|
||||
echo "error: CCTV layer not wired in src/standalone/catalog.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"metrics": {
|
||||
"attachRendererWithViewer": 0,
|
||||
"restoreFlagCopies": 0,
|
||||
"surfaceCallsOutsideGlobe": 10,
|
||||
"engineSwitchLayerIds": 0,
|
||||
"cesiumLayerRendererLines": 0,
|
||||
"navaraLayerRendererLines": 0,
|
||||
"cesiumImportFiles": 2,
|
||||
"viewerLines": 632,
|
||||
"navaraKindChecks": 1,
|
||||
"engineSniffCalls": 0,
|
||||
"engineExtensionReads": 4,
|
||||
"groundEffectPasses": 0,
|
||||
"cesiumSdkLoads": 3,
|
||||
"screenSpaceHandlerFiles": 2,
|
||||
"engineNameLines": 20
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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'],
|
||||
);
|
||||
});
|
||||
|
||||
test('renderer attachment counts definitions once per module, not calls', () => {
|
||||
const { measure } = ENGINE_COUPLING_METRICS.find(
|
||||
({ id }) => id === 'attachRendererWithViewer',
|
||||
);
|
||||
assert.equal(
|
||||
measure([
|
||||
{
|
||||
text: 'attachRenderer(viewer, options = {}) {}\nattachRenderer(scene) {}',
|
||||
},
|
||||
{ text: 'function attachRenderer(\n _viewer, options\n) {}' },
|
||||
{ text: 'const attachRenderer = (nextViewer) => {};' },
|
||||
{
|
||||
text: 'attachRenderer(_scene); layer.attachRenderer(viewer, options);',
|
||||
},
|
||||
{ text: 'attachRenderer(engine) {}' },
|
||||
{ text: 'attachRenderer(viewerState) {}' },
|
||||
]),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('restore flags count layer directories and surface calls exclude globe', () => {
|
||||
const measure = (id, files) =>
|
||||
ENGINE_COUPLING_METRICS.find((metric) => metric.id === id).measure(files);
|
||||
assert.equal(
|
||||
measure('restoreFlagCopies', [
|
||||
{
|
||||
file: 'src/layers/traffic/lifecycle.js',
|
||||
text: 'state._restoringViewport = true;',
|
||||
},
|
||||
{
|
||||
file: 'src/layers/traffic/viewport.js',
|
||||
text: 'state._restoringViewport',
|
||||
},
|
||||
{ file: 'src/layers/alpr/index.js', text: 'let restoringView = false;' },
|
||||
{
|
||||
file: 'src/layers/streetLevel/index.js',
|
||||
text: 'let waitingForView = false;',
|
||||
},
|
||||
{ file: 'src/app/view.js', text: 'let restoringView = false;' },
|
||||
]),
|
||||
3,
|
||||
);
|
||||
const text = [
|
||||
'terrainHeight',
|
||||
'sampleHeight',
|
||||
'sampleHeightDetailed',
|
||||
'sampleTerrainMostDetailed',
|
||||
'clampToHeight',
|
||||
'raycast',
|
||||
]
|
||||
.map((name) => `engine.surface.${name}(point);`)
|
||||
.join('\n');
|
||||
assert.equal(
|
||||
measure('surfaceCallsOutsideGlobe', [
|
||||
{ file: 'src/globe/navara/surface.js', text },
|
||||
{ file: 'src/layers/traffic/viewport.js', text },
|
||||
{
|
||||
file: 'src/app/view.js',
|
||||
text: 'engine.surface?.raycast?.(ray); surface.tilesLoaded();',
|
||||
},
|
||||
]),
|
||||
7,
|
||||
);
|
||||
});
|
||||
|
||||
test('engine switch layer ids count lines and follow the catalog', () => {
|
||||
const { measure } = ENGINE_COUPLING_METRICS.find(
|
||||
({ id }) => id === 'engineSwitchLayerIds',
|
||||
);
|
||||
assert.equal(
|
||||
measure([
|
||||
{
|
||||
file: 'src/standalone/engineSwitch.js',
|
||||
text: "get('directions'); get('satellites');\nget('traffic');\nget('not-a-layer');",
|
||||
},
|
||||
{ file: 'src/app/view.js', text: "get('directions');" },
|
||||
]),
|
||||
2,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
# Import reference corpus
|
||||
|
||||
`reference.json` records Cesium 1.138.0 DataSources in Node with a DOM shim,
|
||||
canvas pins stubbed and KMZ images resolved locally. It is independent of
|
||||
`planImport` and checks entity geometry and styles, without rendering pixels.
|
||||
|
||||
Record and compare offline from the repository root; no server or browser is
|
||||
needed. Recording uses the installed Cesium version, saved in the reference:
|
||||
|
||||
```sh
|
||||
node scripts/qa-geo-import-reference.mjs --record
|
||||
node scripts/qa-geo-import-reference.mjs
|
||||
```
|
||||
|
||||
Recording uses Cesium's `KmlDataSource` and `GeoJsonDataSource` after the import's
|
||||
sanitization and GeoJSON grounding policy. Files are read from this directory;
|
||||
packed images use local blob URLs. Comparison uses 0.01 metres for heights,
|
||||
1e-7 degrees for coordinates and one 8-bit channel (1/255) for colours.
|
||||
Repeated ring endpoints and ignored short holes are normalized; input winding
|
||||
is retained. Image overlay order is compared separately, including ties in
|
||||
document order. Pixel composition and ordering between primitive families
|
||||
still need browser acceptance.
|
||||
|
||||
| File | Cases |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `absolute.kml` | Absolute pins, lines and polygons; positive and negative image heights; raised rotated colour overlay |
|
||||
| `relative.kml` | Relative pins, lines, polygons and image overlay |
|
||||
| `clamped.kml` | Clamped heights and tessellation |
|
||||
| `rotations.kml` | Zero, positive, negative and full turns; a tinted 360-degree span |
|
||||
| `pole.kml` | Rotation near and past a pole; polar polygon |
|
||||
| `antimeridian.kml` | Dateline polygon and rotated image |
|
||||
| `holes.kml` | Ring closure and opposite windings with holes |
|
||||
| `short-holes.kml` | Short and duplicate-corner holes |
|
||||
| `draw-order.kml` | Negative, positive and equal image orders; plain colour |
|
||||
| `styles.kml` | KML colours, line width, icon scale, heading and hot spot |
|
||||
| `geometries.geojson` | Grounded point, line and polygon |
|
||||
| `large-polygon.kml` | A raised polygon with 20,000 vertices |
|
||||
| `packed.kmz` | An icon and a tinted, rotated overlay using a packed PNG |
|
||||
|
||||
The corpus checks negative absolute image heights and raised plain-colour
|
||||
overlays against Cesium. Short holes are skipped on direct record input without
|
||||
rejecting the whole ground polygon. Raised fills have a 32,768-vertex ceiling
|
||||
and raised polygons a 128-ring ceiling in `src/data/geoImportLimits.js`;
|
||||
a repeated closing vertex does not consume triangulation budget. The large
|
||||
polygon fixture exercises a 20,000-vertex fill within that budget.
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>absolute-pin</name><Point><altitudeMode>absolute</altitudeMode><coordinates>10,20,350</coordinates></Point></Placemark>
|
||||
<Placemark><name>absolute-line-0</name><LineString><altitudeMode>absolute</altitudeMode><tessellate>0</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>absolute-line-1</name><LineString><altitudeMode>absolute</altitudeMode><tessellate>1</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>absolute-polygon</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<GroundOverlay><name>absolute-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>absolute</altitudeMode><altitude>200</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>negative-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>absolute</altitudeMode><altitude>-100</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>raised-color</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>absolute</altitudeMode><altitude>200</altitude><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>30</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<GroundOverlay><name>crossing-image</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>179</west><south>-2</south><east>-179</east><north>2</north><rotation>30</rotation></LatLonBox></GroundOverlay>
|
||||
<Placemark><name>crossing-ring</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>179,-1,100 -179,-1,100 -179,1,100 179,1,100</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>clamped-pin</name><Point><altitudeMode>clampToGround</altitudeMode><coordinates>10,20,350</coordinates></Point></Placemark>
|
||||
<Placemark><name>clamped-line-0</name><LineString><altitudeMode>clampToGround</altitudeMode><tessellate>0</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>clamped-line-1</name><LineString><altitudeMode>clampToGround</altitudeMode><tessellate>1</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>clamped-polygon</name><Polygon><altitudeMode>clampToGround</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<GroundOverlay><name>clamped-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>clampToGround</altitudeMode><altitude>200</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<GroundOverlay><name>top</name><drawOrder>9</drawOrder><color>804020ff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>first</name><drawOrder>-2</drawOrder><color>ff00ff00</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>equal-a</name><drawOrder>3</drawOrder><color>ff0000ff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>equal-b</name><drawOrder>3</drawOrder><color>ffff0000</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>plain-color</name><drawOrder>0</drawOrder><color>804020ff</color><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>25</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"json-point"},"geometry":{"type":"Point","coordinates":[10,20,99]}},{"type":"Feature","properties":{"name":"json-line"},"geometry":{"type":"LineString","coordinates":[[10,20,4],[11,21,8]]}},{"type":"Feature","properties":{"name":"json-polygon"},"geometry":{"type":"Polygon","coordinates":[[[0,0,100],[2,0,100],[2,2,100],[0,2,100],[0,0,100]],[[0.5,0.5,100],[0.5,1.5,100],[1.5,1.5,100],[1.5,0.5,100],[0.5,0.5,100]]]}}]}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>open</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>closed</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>reversed</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 0,2,100 2,2,100 2,0,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 1.5,0.5,100 1.5,1.5,100 0.5,1.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>ground-holes</name><Polygon><altitudeMode>clampToGround</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>large</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>10.1,20.0,500 10.099999995,20.000031416,500 10.09999998,20.000062832,500 10.099999956,20.000094248,500 10.099999921,20.000125664,500 10.099999877,20.00015708,500 10.099999822,20.000188495,500 10.099999758,20.000219911,500 10.099999684,20.000251327,500 10.0999996,20.000282743,500 10.099999507,20.000314159,500 10.099999403,20.000345575,500 10.099999289,20.00037699,500 10.099999166,20.000408406,500 10.099999033,20.000439822,500 10.09999889,20.000471237,500 10.099998737,20.000502653,500 10.099998574,20.000534068,500 10.099998401,20.000565484,500 10.099998219,20.000596899,500 10.099998026,20.000628314,500 10.099997824,20.00065973,500 10.099997612,20.000691145,500 10.09999739,20.00072256,500 10.099997158,20.000753975,500 10.099996916,20.00078539,500 10.099996664,20.000816805,500 10.099996403,20.00084822,500 10.099996131,20.000879635,500 10.09999585,20.000911049,500 10.099995559,20.000942464,500 10.099995258,20.000973878,500 10.099994947,20.001005293,500 10.099994626,20.001036707,500 10.099994295,20.001068121,500 10.099993955,20.001099535,500 10.099993605,20.001130949,500 10.099993244,20.001162363,500 10.099992874,20.001193777,500 10.099992494,20.00122519,500 10.099992104,20.001256604,500 10.099991705,20.001288017,500 10.099991295,20.001319431,500 10.099990876,20.001350844,500 10.099990446,20.001382257,500 10.099990007,20.00141367,500 10.099989558,20.001445082,500 10.099989099,20.001476495,500 10.09998863,20.001507907,500 10.099988152,20.00153932,500 10.099987663,20.001570732,500 10.099987165,20.001602144,500 10.099986657,20.001633556,500 10.099986138,20.001664967,500 10.09998561,20.001696379,500 10.099985073,20.00172779,500 10.099984525,20.001759201,500 10.099983967,20.001790612,500 10.0999834,20.001822023,500 10.099982822,20.001853434,500 10.099982235,20.001884844,500 10.099981638,20.001916254,500 10.099981031,20.001947664,500 10.099980414,20.001979074,500 10.099979788,20.002010484,500 10.099979151,20.002041893,500 10.099978505,20.002073303,500 10.099977848,20.002104712,500 10.099977182,20.002136121,500 10.099976506,20.002167529,500 10.09997582,20.002198938,500 10.099975125,20.002230346,500 10.099974419,20.002261754,500 10.099973704,20.002293162,500 10.099972978,20.002324569,500 10.099972243,20.002355976,500 10.099971498,20.002387384,500 10.099970743,20.00241879,500 10.099969978,20.002450197,500 10.099969203,20.002481603,500 10.099968419,20.00251301,500 10.099967625,20.002544415,500 10.09996682,20.002575821,500 10.099966006,20.002607226,500 10.099965182,20.002638632,500 10.099964348,20.002670036,500 10.099963504,20.002701441,500 10.099962651,20.002732845,500 10.099961787,20.002764249,500 10.099960914,20.002795653,500 10.099960031,20.002827057,500 10.099959138,20.00285846,500 10.099958235,20.002889863,500 10.099957322,20.002921266,500 10.099956399,20.002952668,500 10.099955467,20.00298407,500 10.099954524,20.003015472,500 10.099953572,20.003046873,500 10.09995261,20.003078274,500 10.099951638,20.003109675,500 10.099950656,20.003141076,500 10.099949664,20.003172476,500 10.099948663,20.003203876,500 10.099947651,20.003235276,500 10.09994663,20.003266675,500 10.099945599,20.003298074,500 10.099944558,20.003329473,500 10.099943507,20.003360871,500 10.099942446,20.003392269,500 10.099941375,20.003423667,500 10.099940295,20.003455064,500 10.099939204,20.003486461,500 10.099938104,20.003517858,500 10.099936994,20.003549254,500 10.099935874,20.00358065,500 10.099934744,20.003612046,500 10.099933605,20.003643441,500 10.099932455,20.003674836,500 10.099931296,20.00370623,500 10.099930126,20.003737624,500 10.099928947,20.003769018,500 10.099927758,20.003800412,500 10.099926559,20.003831805,500 10.099925351,20.003863197,500 10.099924132,20.00389459,500 10.099922904,20.003925982,500 10.099921665,20.003957373,500 10.099920417,20.003988764,500 10.099919159,20.004020155,500 10.099917891,20.004051545,500 10.099916613,20.004082935,500 10.099915326,20.004114325,500 10.099914028,20.004145714,500 10.099912721,20.004177103,500 10.099911404,20.004208491,500 10.099910077,20.004239879,500 10.09990874,20.004271266,500 10.099907393,20.004302653,500 10.099906036,20.00433404,500 10.09990467,20.004365426,500 10.099903293,20.004396812,500 10.099901907,20.004428197,500 10.099900511,20.004459582,500 10.099899105,20.004490967,500 10.099897689,20.004522351,500 10.099896264,20.004553734,500 10.099894828,20.004585117,500 10.099893383,20.0046165,500 10.099891928,20.004647882,500 10.099890462,20.004679264,500 10.099888987,20.004710645,500 10.099887503,20.004742026,500 10.099886008,20.004773406,500 10.099884503,20.004804786,500 10.099882989,20.004836166,500 10.099881465,20.004867544,500 10.099879931,20.004898923,500 10.099878387,20.004930301,500 10.099876833,20.004961678,500 10.099875269,20.004993055,500 10.099873696,20.005024432,500 10.099872112,20.005055808,500 10.099870519,20.005087183,500 10.099868916,20.005118558,500 10.099867303,2Line truncated
|
||||
|
||||
</Document></kml>
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<GroundOverlay><name>near-pole</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-2</west><south>84</south><east>2</east><north>86</north><rotation>30</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>past-pole</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-10</west><south>86</south><east>10</east><north>89</north><rotation>45</rotation></LatLonBox></GroundOverlay>
|
||||
<Placemark><name>polar-ring</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>10,88,200 14,88,200 14,88.2,200 10,88.2,200</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
|
||||
|
||||
</Document></kml>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>relative-pin</name><Point><altitudeMode>relativeToGround</altitudeMode><coordinates>10,20,350</coordinates></Point></Placemark>
|
||||
<Placemark><name>relative-line-0</name><LineString><altitudeMode>relativeToGround</altitudeMode><tessellate>0</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>relative-line-1</name><LineString><altitudeMode>relativeToGround</altitudeMode><tessellate>1</tessellate><coordinates>10,20,150 11,20,300</coordinates></LineString></Placemark>
|
||||
<Placemark><name>relative-polygon</name><Polygon><altitudeMode>relativeToGround</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.5,0.5,100 0.5,1.5,100 1.5,1.5,100 1.5,0.5,100 0.5,0.5,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<GroundOverlay><name>relative-image</name><drawOrder>0</drawOrder><color>ffffffff</color><altitudeMode>relativeToGround</altitudeMode><altitude>200</altitude><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>10</west><south>20</south><east>12</east><north>22</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<GroundOverlay><name>rotation-0</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>rotation-45</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>45</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>rotation--30</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>-30</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>rotation-360</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>360</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>rotation--360</name><drawOrder>0</drawOrder><color>ffffffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-1</west><south>-1</south><east>1</east><north>1</north><rotation>-360</rotation></LatLonBox></GroundOverlay>
|
||||
<GroundOverlay><name>all-longitudes</name><drawOrder>0</drawOrder><color>ff80ffff</color><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><LatLonBox><west>-180</west><south>-1</south><east>180</east><north>1</north><rotation>0</rotation></LatLonBox></GroundOverlay>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Placemark><name>two-point-hole</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.4,0.4,100 0.8,0.4,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>repeated-hole</name><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs><innerBoundaryIs><LinearRing><coordinates>0.4,0.4,100 0.8,0.4,100 0.4,0.4,100</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
|
||||
|
||||
</Document></kml>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>
|
||||
<Style id="shared"><LineStyle><color>804020ff</color><width>5</width></LineStyle><PolyStyle><color>7f00ffff</color><outline>1</outline></PolyStyle><IconStyle><color>cc804020</color><scale>1.5</scale><heading>45</heading><Icon><href>data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==</href></Icon><hotSpot x="0.25" y="0.75" xunits="fraction" yunits="fraction"/></IconStyle></Style><StyleMap id="mapped"><Pair><key>normal</key><styleUrl>#shared</styleUrl></Pair></StyleMap><Placemark><name>styled-polygon</name><styleUrl>#mapped</styleUrl><Polygon><altitudeMode>absolute</altitudeMode><outerBoundaryIs><LinearRing><coordinates>0,0,100 2,0,100 2,2,100 0,2,100 0,0,100</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><name>styled-pin</name><styleUrl>#mapped</styleUrl><Point><coordinates>10,20</coordinates></Point></Placemark><Placemark><name>styled-line</name><styleUrl>#shared</styleUrl><LineString><coordinates>10,20 11,21</coordinates></LineString></Placemark>
|
||||
</Document></kml>
|
||||
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
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)) : '',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -181,18 +181,6 @@ export function answerMapillaryRequest({ method, url, images }) {
|
||||
: [];
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('lat') && q.has('lng')) {
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
const radius = Math.min(50, Number(q.get('radius')) || 50);
|
||||
const limit = Number(q.get('limit')) || 10;
|
||||
// In no particular order, as the real API answers.
|
||||
const data = photos
|
||||
.filter((image) => metresApart(at, image) <= radius)
|
||||
.reverse()
|
||||
.slice(0, limit)
|
||||
.map((image) => withFields(imageRecord(image), fields));
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||
return {
|
||||
...json(400, { error: 'unsupported images query' }),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,805 @@
|
||||
{
|
||||
"asset": {
|
||||
"version": "1.1"
|
||||
},
|
||||
"geometricError": 1000,
|
||||
"root": {
|
||||
"transform": [
|
||||
0.9908821299915158,
|
||||
-0.13473160157690048,
|
||||
0,
|
||||
0,
|
||||
0.06790921036869463,
|
||||
0.4994375649707324,
|
||||
0.8636842350322285,
|
||||
0,
|
||||
-0.11636556024261228,
|
||||
-0.8558092744488276,
|
||||
0.5040332748507723,
|
||||
0,
|
||||
-742827.4189515795,
|
||||
-5463116.347553584,
|
||||
3195991.10594888,
|
||||
1
|
||||
],
|
||||
"boundingVolume": {
|
||||
"box": [
|
||||
0,
|
||||
0,
|
||||
99,
|
||||
300,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
101
|
||||
]
|
||||
},
|
||||
"geometricError": 1000,
|
||||
"refine": "ADD",
|
||||
"children": [
|
||||
{
|
||||
"boundingVolume": {
|
||||
"box": [
|
||||
0,
|
||||
0,
|
||||
-1,
|
||||
300,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"geometricError": 0,
|
||||
"content": {
|
||||
"uri": "plate.glb"
|
||||
}
|
||||
},
|
||||
{
|
||||
"boundingVolume": {
|
||||
"box": [
|
||||
-160,
|
||||
0,
|
||||
25,
|
||||
50,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
60,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
25
|
||||
]
|
||||
},
|
||||
"geometricError": 0,
|
||||
"content": {
|
||||
"uri": "block50.glb"
|
||||
}
|
||||
},
|
||||
{
|
||||
"boundingVolume": {
|
||||
"box": [
|
||||
0,
|
||||
170,
|
||||
100,
|
||||
50,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
100
|
||||
]
|
||||
},
|
||||
"geometricError": 0,
|
||||
"content": {
|
||||
"uri": "block200.glb"
|
||||
}
|
||||
},
|
||||
{
|
||||
"boundingVolume": {
|
||||
"box": [
|
||||
160,
|
||||
0,
|
||||
50,
|
||||
50,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
60,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
50
|
||||
]
|
||||
},
|
||||
"geometricError": 0,
|
||||
"content": {
|
||||
"uri": "roof.glb"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"extras": {
|
||||
"origin": {
|
||||
"lon": -97.7431,
|
||||
"lat": 30.2672
|
||||
},
|
||||
"datum": "WGS84 ellipsoid; local ENU heights in metres",
|
||||
"probes": {
|
||||
"plate": {
|
||||
"position": {
|
||||
"x": -742838.9635173421,
|
||||
"y": -5463201.251939629,
|
||||
"z": 3195844.2796289246
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059389161060694,
|
||||
"latitude": 0.5282355305977933,
|
||||
"height": 0.002275014548483431
|
||||
},
|
||||
"lon": -97.7431,
|
||||
"lat": 30.265666492107215,
|
||||
"height": 0.002275014548483431
|
||||
},
|
||||
"block50": {
|
||||
"position": {
|
||||
"x": -742991.7783703903,
|
||||
"y": -5463137.580961054,
|
||||
"z": 3196016.307612622
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059679361541311,
|
||||
"latitude": 0.5282622951753985,
|
||||
"height": 50.00200513183283
|
||||
},
|
||||
"lon": -97.7447627262752,
|
||||
"lat": 30.267199989444443,
|
||||
"height": 50.00200513183283
|
||||
},
|
||||
"block200": {
|
||||
"position": {
|
||||
"x": -742839.1474978654,
|
||||
"y": -5463202.605022429,
|
||||
"z": 3196238.7389238053
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059389161060696,
|
||||
"latitude": 0.5282890592724478,
|
||||
"height": 200.0022749319483
|
||||
},
|
||||
"lon": -97.74310000000001,
|
||||
"lat": 30.26873345924785,
|
||||
"height": 200.0022749319483
|
||||
},
|
||||
"roof": {
|
||||
"position": {
|
||||
"x": -742658.8348752414,
|
||||
"y": -5463212.487220922,
|
||||
"z": 3196033.444743967
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059062687258868,
|
||||
"latitude": 0.528262295126465,
|
||||
"height": 84.00253773142123
|
||||
},
|
||||
"lon": -97.74122944290338,
|
||||
"lat": 30.267199986640765,
|
||||
"height": 84.00253773142123
|
||||
}
|
||||
},
|
||||
"points": [
|
||||
{
|
||||
"position": {
|
||||
"x": -742701.1865288558,
|
||||
"y": -5463227.075459457,
|
||||
"z": 3195998.897374566
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059135237921106,
|
||||
"latitude": 0.5282559977114081,
|
||||
"height": 84.00166112171651
|
||||
},
|
||||
"lon": -97.74164512757808,
|
||||
"lat": 30.26683917133616,
|
||||
"height": 84.00166112171651
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742696.6975804468,
|
||||
"y": -5463231.172354563,
|
||||
"z": 3196000.9135076655
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059126169402343,
|
||||
"latitude": 0.5282559977051193,
|
||||
"height": 88.00177273310982
|
||||
},
|
||||
"lon": -97.74159316879293,
|
||||
"lat": 30.26683917097584,
|
||||
"height": 88.00177273310982
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742692.2086320379,
|
||||
"y": -5463235.269249668,
|
||||
"z": 3196002.929640765
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059117100894945,
|
||||
"latitude": 0.528255997698471,
|
||||
"height": 92.00188826158782
|
||||
},
|
||||
"lon": -97.7415412100729,
|
||||
"lat": 30.266839170594917,
|
||||
"height": 92.00188826158782
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742687.7196836289,
|
||||
"y": -5463239.3661447745,
|
||||
"z": 3196004.945773864
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705910803239891,
|
||||
"latitude": 0.5282559976914627,
|
||||
"height": 96.00200770555026
|
||||
},
|
||||
"lon": -97.74148925141796,
|
||||
"lat": 30.26683917019337,
|
||||
"height": 96.00200770555026
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742683.2307352199,
|
||||
"y": -5463243.46303988,
|
||||
"z": 3196006.9619069635
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059098963914245,
|
||||
"latitude": 0.5282559976840947,
|
||||
"height": 100.00213106686036
|
||||
},
|
||||
"lon": -97.74143729282817,
|
||||
"lat": 30.266839169771217,
|
||||
"height": 100.00213106686036
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742677.810862329,
|
||||
"y": -5463240.71346079,
|
||||
"z": 3196004.945773864
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059089895065904,
|
||||
"latitude": 0.5282559976684348,
|
||||
"height": 96.00225834565195
|
||||
},
|
||||
"lon": -97.74138533215466,
|
||||
"lat": 30.266839168873968,
|
||||
"height": 96.00225834565195
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742672.390989438,
|
||||
"y": -5463237.9638817,
|
||||
"z": 3196002.929640765
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059080826206199,
|
||||
"latitude": 0.5282559976524149,
|
||||
"height": 92.002389541416
|
||||
},
|
||||
"lon": -97.74133337141606,
|
||||
"lat": 30.2668391679561,
|
||||
"height": 92.002389541416
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742666.9711165471,
|
||||
"y": -5463235.21430261,
|
||||
"z": 3196000.9135076655
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705907175733513,
|
||||
"latitude": 0.5282559976360354,
|
||||
"height": 88.0025246520816
|
||||
},
|
||||
"lon": -97.74128141061233,
|
||||
"lat": 30.26683916701762,
|
||||
"height": 88.0025246520816
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742699.8283446485,
|
||||
"y": -5463217.086708157,
|
||||
"z": 3196016.1710592667
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705913523745684,
|
||||
"latitude": 0.5282591464650368,
|
||||
"height": 84.0015666583481
|
||||
},
|
||||
"lon": -97.74164512491804,
|
||||
"lat": 30.26701958162981,
|
||||
"height": 84.0015666583481
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742695.3393962395,
|
||||
"y": -5463221.183603263,
|
||||
"z": 3196018.187192366
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059126168921497,
|
||||
"latitude": 0.5282591464567652,
|
||||
"height": 88.00167827023611
|
||||
},
|
||||
"lon": -97.74159316603789,
|
||||
"lat": 30.267019581155882,
|
||||
"height": 88.00167827023611
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742690.8504478304,
|
||||
"y": -5463225.280498369,
|
||||
"z": 3196020.2033254653
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059117100397518,
|
||||
"latitude": 0.5282591464481338,
|
||||
"height": 92.0017937984389
|
||||
},
|
||||
"lon": -97.74154120722285,
|
||||
"lat": 30.267019580661337,
|
||||
"height": 92.0017937984389
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742686.3614994214,
|
||||
"y": -5463229.377393475,
|
||||
"z": 3196022.2194585647
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059108031884906,
|
||||
"latitude": 0.5282591464391424,
|
||||
"height": 96.00191324267489
|
||||
},
|
||||
"lon": -97.74148924847293,
|
||||
"lat": 30.26701958014617,
|
||||
"height": 96.00191324267489
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742681.8725510124,
|
||||
"y": -5463233.47428858,
|
||||
"z": 3196024.2355916644
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059098963383656,
|
||||
"latitude": 0.5282591464297914,
|
||||
"height": 100.00203660347505
|
||||
},
|
||||
"lon": -97.74143728978812,
|
||||
"lat": 30.2670195796104,
|
||||
"height": 100.00203660347505
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742676.4526781215,
|
||||
"y": -5463230.72470949,
|
||||
"z": 3196022.2194585647
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059089894518735,
|
||||
"latitude": 0.5282591464161143,
|
||||
"height": 96.00216388302483
|
||||
},
|
||||
"lon": -97.74138532901962,
|
||||
"lat": 30.267019578826755,
|
||||
"height": 96.00216388302483
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742671.0328052306,
|
||||
"y": -5463227.975130401,
|
||||
"z": 3196020.2033254653
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705908082564245,
|
||||
"latitude": 0.5282591464020774,
|
||||
"height": 92.00229507980886
|
||||
},
|
||||
"lon": -97.74133336818602,
|
||||
"lat": 30.267019578022502,
|
||||
"height": 92.00229507980886
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742665.6129323397,
|
||||
"y": -5463225.225551311,
|
||||
"z": 3196018.187192366
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059071756754798,
|
||||
"latitude": 0.5282591463876805,
|
||||
"height": 88.00243019067032
|
||||
},
|
||||
"lon": -97.74128140728727,
|
||||
"lat": 30.26701957719762,
|
||||
"height": 88.00243019067032
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742698.470160441,
|
||||
"y": -5463207.097956859,
|
||||
"z": 3196033.444743967
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705913523699257,
|
||||
"latitude": 0.5282622952185787,
|
||||
"height": 84.00153517112447
|
||||
},
|
||||
"lon": -97.74164512225796,
|
||||
"lat": 30.26719999191849,
|
||||
"height": 84.00153517112447
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742693.981212032,
|
||||
"y": -5463211.194851964,
|
||||
"z": 3196035.460877067
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059126168440648,
|
||||
"latitude": 0.528262295208324,
|
||||
"height": 88.00164678309102
|
||||
},
|
||||
"lon": -97.74159316328283,
|
||||
"lat": 30.267199991330937,
|
||||
"height": 88.00164678309102
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742689.492263623,
|
||||
"y": -5463215.29174707,
|
||||
"z": 3196037.4770101663
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705911709990009,
|
||||
"latitude": 0.5282622951977096,
|
||||
"height": 92.0017623126523
|
||||
},
|
||||
"lon": -97.7415412043728,
|
||||
"lat": 30.267199990722776,
|
||||
"height": 92.0017623126523
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742685.0033152141,
|
||||
"y": -5463219.388642175,
|
||||
"z": 3196039.4931432656
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059108031370898,
|
||||
"latitude": 0.5282622951867355,
|
||||
"height": 96.00188175721505
|
||||
},
|
||||
"lon": -97.74148924552787,
|
||||
"lat": 30.267199990094007,
|
||||
"height": 96.00188175721505
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742680.5143668051,
|
||||
"y": -5463223.485537281,
|
||||
"z": 3196041.509276365
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705909896285307,
|
||||
"latitude": 0.5282622951754012,
|
||||
"height": 100.00200511623785
|
||||
},
|
||||
"lon": -97.74143728674808,
|
||||
"lat": 30.267199989444602,
|
||||
"height": 100.00200511623785
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742675.0944939142,
|
||||
"y": -5463220.735958191,
|
||||
"z": 3196039.4931432656
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059089893971566,
|
||||
"latitude": 0.5282622951637073,
|
||||
"height": 96.0021323949121
|
||||
},
|
||||
"lon": -97.74138532588458,
|
||||
"lat": 30.267199988774586,
|
||||
"height": 96.0021323949121
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742669.6746210232,
|
||||
"y": -5463217.986379101,
|
||||
"z": 3196037.4770101663
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059080825078698,
|
||||
"latitude": 0.5282622951516531,
|
||||
"height": 92.0022635916176
|
||||
},
|
||||
"lon": -97.74133336495596,
|
||||
"lat": 30.267199988083934,
|
||||
"height": 92.0022635916176
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742664.2547481323,
|
||||
"y": -5463215.236800011,
|
||||
"z": 3196035.460877067
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059071756174464,
|
||||
"latitude": 0.5282622951392388,
|
||||
"height": 88.00239870366701
|
||||
},
|
||||
"lon": -97.74128140396222,
|
||||
"lat": 30.26719998737265,
|
||||
"height": 88.00239870366701
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742697.1119762337,
|
||||
"y": -5463197.109205559,
|
||||
"z": 3196050.718428668
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059135236528302,
|
||||
"latitude": 0.5282654439720338,
|
||||
"height": 84.00156665806156
|
||||
},
|
||||
"lon": -97.7416451195979,
|
||||
"lat": 30.267380402202193,
|
||||
"height": 84.00156665806156
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742692.6230278247,
|
||||
"y": -5463201.206100664,
|
||||
"z": 3196052.7345617674
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.70591261679598,
|
||||
"latitude": 0.5282654439597962,
|
||||
"height": 88.00167827087749
|
||||
},
|
||||
"lon": -97.74159316052776,
|
||||
"lat": 30.267380401501033,
|
||||
"height": 88.00167827087749
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742688.1340794157,
|
||||
"y": -5463205.30299577,
|
||||
"z": 3196054.7506948668
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705911709940266,
|
||||
"latitude": 0.5282654439471988,
|
||||
"height": 92.00179379845281
|
||||
},
|
||||
"lon": -97.74154120152274,
|
||||
"lat": 30.26738040077925,
|
||||
"height": 92.00179379845281
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742683.6451310067,
|
||||
"y": -5463209.399890875,
|
||||
"z": 3196056.766827966
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059108030856887,
|
||||
"latitude": 0.5282654439342416,
|
||||
"height": 96.00191324280611
|
||||
},
|
||||
"lon": -97.74148924258282,
|
||||
"lat": 30.267380400036856,
|
||||
"height": 96.00191324280611
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742679.1561825977,
|
||||
"y": -5463213.496785982,
|
||||
"z": 3196058.7829610654
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059098962322479,
|
||||
"latitude": 0.5282654439209244,
|
||||
"height": 100.00203660378942
|
||||
},
|
||||
"lon": -97.74143728370802,
|
||||
"lat": 30.267380399273843,
|
||||
"height": 100.00203660378942
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742673.7363097068,
|
||||
"y": -5463210.747206892,
|
||||
"z": 3196056.766827966
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059089893424393,
|
||||
"latitude": 0.5282654439112129,
|
||||
"height": 96.00216388304962
|
||||
},
|
||||
"lon": -97.74138532274951,
|
||||
"lat": 30.26738039871741,
|
||||
"height": 96.00216388304962
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742668.3164368159,
|
||||
"y": -5463207.997627802,
|
||||
"z": 3196054.7506948668
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059080824514945,
|
||||
"latitude": 0.5282654439011418,
|
||||
"height": 92.00229507962341
|
||||
},
|
||||
"lon": -97.74133336172589,
|
||||
"lat": 30.267380398140382,
|
||||
"height": 92.00229507962341
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742662.896563925,
|
||||
"y": -5463205.248048712,
|
||||
"z": 3196052.7345617674
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059071755594128,
|
||||
"latitude": 0.5282654438907107,
|
||||
"height": 88.00243019027465
|
||||
},
|
||||
"lon": -97.74128140063713,
|
||||
"lat": 30.267380397542723,
|
||||
"height": 88.00243019027465
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742695.7537920263,
|
||||
"y": -5463187.120454259,
|
||||
"z": 3196067.9921133686
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059135236064031,
|
||||
"latitude": 0.528268592725402,
|
||||
"height": 84.00166112141882
|
||||
},
|
||||
"lon": -97.74164511693783,
|
||||
"lat": 30.26756081248092,
|
||||
"height": 84.00166112141882
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742691.2648436173,
|
||||
"y": -5463191.217349365,
|
||||
"z": 3196070.008246468
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059126167478948,
|
||||
"latitude": 0.5282685927111814,
|
||||
"height": 88.00177273414431
|
||||
},
|
||||
"lon": -97.74159315777268,
|
||||
"lat": 30.26756081166614,
|
||||
"height": 88.00177273414431
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742686.7758952084,
|
||||
"y": -5463195.314244471,
|
||||
"z": 3196072.0243795672
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705911709890523,
|
||||
"latitude": 0.5282685926966009,
|
||||
"height": 92.0018882615363
|
||||
},
|
||||
"lon": -97.74154119867266,
|
||||
"lat": 30.267560810830734,
|
||||
"height": 92.0018882615363
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742682.2869467994,
|
||||
"y": -5463199.411139577,
|
||||
"z": 3196074.040512667
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059108030342875,
|
||||
"latitude": 0.5282685926816608,
|
||||
"height": 96.0020077065962
|
||||
},
|
||||
"lon": -97.74148923963774,
|
||||
"lat": 30.267560809974732,
|
||||
"height": 96.0020077065962
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742677.7979983904,
|
||||
"y": -5463203.508034682,
|
||||
"z": 3196076.0566457664
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059098961791888,
|
||||
"latitude": 0.5282685926663608,
|
||||
"height": 100.00213106635087
|
||||
},
|
||||
"lon": -97.74143728066795,
|
||||
"lat": 30.267560809098107,
|
||||
"height": 100.00213106635087
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742672.3781254995,
|
||||
"y": -5463200.758455592,
|
||||
"z": 3196074.040512667
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705908989287722,
|
||||
"latitude": 0.5282685926586324,
|
||||
"height": 96.00225834543971
|
||||
},
|
||||
"lon": -97.74138531961444,
|
||||
"lat": 30.267560808655297,
|
||||
"height": 96.00225834543971
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742666.9582526085,
|
||||
"y": -5463198.0088765025,
|
||||
"z": 3196072.0243795672
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.705908082395119,
|
||||
"latitude": 0.5282685926505436,
|
||||
"height": 92.00238954127981
|
||||
},
|
||||
"lon": -97.74133335849581,
|
||||
"lat": 30.267560808191845,
|
||||
"height": 92.00238954127981
|
||||
},
|
||||
{
|
||||
"position": {
|
||||
"x": -742661.5383797176,
|
||||
"y": -5463195.259297413,
|
||||
"z": 3196070.008246468
|
||||
},
|
||||
"cartographic": {
|
||||
"longitude": -1.7059071755013793,
|
||||
"latitude": 0.5282685926420952,
|
||||
"height": 88.0025246543197
|
||||
},
|
||||
"lon": -97.74128139731205,
|
||||
"lat": 30.267560807707792,
|
||||
"height": 88.0025246543197
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
+369
-67
@@ -1,4 +1,7 @@
|
||||
[
|
||||
"src/data/sceneServices.js",
|
||||
"src/data/sceneServices.test.mjs",
|
||||
"src/data/trackingGesture.js",
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
@@ -56,11 +59,11 @@
|
||||
"src/app/application.test.mjs",
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/zoomOutGuard.test.mjs",
|
||||
"src/globe/cesium/viewer.js",
|
||||
"src/globe/cesium/zoomOutGuard.test.mjs",
|
||||
"src/app/controls.test.mjs",
|
||||
"scripts/qa-zoom-out-guard.mjs",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/globe/cesium/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.js",
|
||||
"src/standalone/data.js",
|
||||
@@ -377,8 +380,10 @@
|
||||
"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",
|
||||
@@ -484,17 +489,14 @@
|
||||
"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",
|
||||
@@ -530,20 +532,17 @@
|
||||
"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",
|
||||
@@ -605,9 +604,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",
|
||||
@@ -638,13 +637,8 @@
|
||||
"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",
|
||||
@@ -660,9 +654,9 @@
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/globe/cesium/mapImagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/globe/cesium/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
"src/maps/terrainRetry.test.mjs",
|
||||
"src/mapStackController.js",
|
||||
@@ -827,8 +821,6 @@
|
||||
"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",
|
||||
@@ -847,45 +839,6 @@
|
||||
"scripts/qa-transit-recovery.mjs",
|
||||
"scripts/format-runtime.json",
|
||||
"src/tooling/transitService.test.mjs",
|
||||
"src/tooling/railService.test.mjs",
|
||||
"src/data/railFeeds.test.mjs",
|
||||
"src/data/railProxy.test.mjs",
|
||||
"src/layers/rail/policy.test.mjs",
|
||||
"src/layers/rail/source.test.mjs",
|
||||
"src/layers/rail/pathBinding.test.mjs",
|
||||
"src/layers/rail/routeLines.test.mjs",
|
||||
"src/layers/rail/stopLabels.test.mjs",
|
||||
"src/layers/rail/schedules.test.mjs",
|
||||
"src/data/railPaths.test.mjs",
|
||||
"src/data/railSchedule.test.mjs",
|
||||
"src/data/railTripUpdates.test.mjs",
|
||||
"src/data/irishRail.test.mjs",
|
||||
"src/tooling/buildRailPaths.test.mjs",
|
||||
"src/tooling/buildRailPathsRoutes.test.mjs",
|
||||
"scripts/build-rail-paths.mjs",
|
||||
"src/tooling/buildRailPathsUnlocated.test.mjs",
|
||||
"src/layers/rail/follow.test.mjs",
|
||||
"src/layers/rail/groundHeight.test.mjs",
|
||||
"src/layers/rail/liveOnly.test.mjs",
|
||||
"src/testSupport/railLayerHarness.mjs",
|
||||
"src/layers/rail/dedupe.test.mjs",
|
||||
"src/data/railIreland.test.mjs",
|
||||
"src/data/railNoPositions.test.mjs",
|
||||
"src/data/railFeedsNational.test.mjs",
|
||||
"src/data/railSiriVm.test.mjs",
|
||||
"src/tooling/buildRailPathsTripEnds.test.mjs",
|
||||
"src/data/railFeedsUs.test.mjs",
|
||||
"src/data/railFeedsAsia.test.mjs",
|
||||
"src/data/railFeedsFrance.test.mjs",
|
||||
"src/data/railFeedsUsNoTerms.test.mjs",
|
||||
"src/data/railFeedsUsUrban.test.mjs",
|
||||
"src/tooling/buildRailPathsLocal.test.mjs",
|
||||
"src/tooling/buildRailPathsWrite.test.mjs",
|
||||
"src/tooling/railServiceFaults.test.mjs",
|
||||
"src/tooling/railCatalogPacks.test.mjs",
|
||||
"src/tooling/railServicePacks.test.mjs",
|
||||
"src/tooling/buildRailPathsCli.test.mjs",
|
||||
"src/data/railFeedsDocs.test.mjs",
|
||||
"src/sources/overpassFeatures.test.mjs",
|
||||
"src/data/cctvHlsStream.test.mjs",
|
||||
"src/layers/cctv/videoPlayback.test.mjs",
|
||||
@@ -894,7 +847,6 @@
|
||||
"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",
|
||||
@@ -903,7 +855,6 @@
|
||||
"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",
|
||||
@@ -929,8 +880,6 @@
|
||||
"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",
|
||||
@@ -963,7 +912,6 @@
|
||||
"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",
|
||||
@@ -972,7 +920,6 @@
|
||||
"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",
|
||||
@@ -997,8 +944,6 @@
|
||||
"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",
|
||||
@@ -1117,6 +1062,7 @@
|
||||
"src/layers/traffic/roadModes.test.mjs",
|
||||
"scripts/qa-traffic-motion.mjs",
|
||||
"src/layers/traffic/motion.test.mjs",
|
||||
"src/layers/traffic/handoff.test.mjs",
|
||||
"src/layers/traffic/lineRebuild.test.mjs",
|
||||
"scripts/qa-journey-recorder.mjs",
|
||||
"scripts/qa-alpr-journey.mjs",
|
||||
@@ -1166,7 +1112,7 @@
|
||||
"scripts/build-bright-stars.mjs",
|
||||
"scripts/build-milky-way.mjs",
|
||||
"src/sky/stars.test.mjs",
|
||||
"src/sky/starrySky.test.mjs",
|
||||
"src/globe/cesium/starrySky.test.mjs",
|
||||
"src/tooling/skyAssets.test.mjs",
|
||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||
"src/tooling/responseCompression.test.mjs",
|
||||
@@ -1267,6 +1213,198 @@
|
||||
"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",
|
||||
@@ -1418,6 +1556,170 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"gzipBytes": 2424291,
|
||||
"allowanceBytes": 24243,
|
||||
"measuredAt": "Keyless production build at the merge of main 14db73f20, whose own main chunk measures 2 351 884 bytes (main grew 232 KB since the last baseline: the News layer and the Swedish catalog); this branch adds 72 KB, as before",
|
||||
"allowance": "1% of the baseline; growth past it fails npm run check:main-chunk until the baseline is raised on purpose"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parsers } from 'prettier/plugins/babel.mjs';
|
||||
import { vueScripts } from './vue-scripts.mjs';
|
||||
|
||||
const browserGlobals = new Set([
|
||||
'window',
|
||||
@@ -15,9 +16,14 @@ const browserGlobals = new Set([
|
||||
'getComputedStyle',
|
||||
]);
|
||||
|
||||
/** Inspect literal module dependencies and browser-platform references without executing code. */
|
||||
export function analyzeModule(source) {
|
||||
/**
|
||||
* 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 = '' } = {}) {
|
||||
const imports = [];
|
||||
const staticImports = [];
|
||||
const browser = new Set();
|
||||
function walk(node, parent, field) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
@@ -35,6 +41,8 @@ export function analyzeModule(source) {
|
||||
'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');
|
||||
@@ -70,8 +78,15 @@ export function analyzeModule(source) {
|
||||
else if (value && typeof value === 'object') walk(value, node, key);
|
||||
}
|
||||
}
|
||||
walk(parsers.babel.parse(source));
|
||||
return { imports, browser: [...browser] };
|
||||
for (const script of filename.endsWith('.vue')
|
||||
? vueScripts(source, filename)
|
||||
: [source])
|
||||
walk(parsers.babel.parse(script));
|
||||
return {
|
||||
imports,
|
||||
browser: [...browser],
|
||||
...(dynamic && { staticImports }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return static imports, literal dynamic imports and re-exports. */
|
||||
|
||||
+741
-1940
File diff suppressed because it is too large.
Load diff
@@ -6,12 +6,13 @@
|
||||
* native dispatch/disabled/teardown without providers or world calibration writes.
|
||||
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
|
||||
*/
|
||||
import { whenSettled } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
args: [],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
@@ -34,11 +35,10 @@ try {
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
() => window.__godsEyeView?.styleManager?._cctvControls,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await whenSettled(page);
|
||||
const inventory = await page.evaluate(() => {
|
||||
const ids = [
|
||||
'scene-new-btn',
|
||||
@@ -304,11 +304,10 @@ try {
|
||||
localized.searchParams.set('lang', 'ru');
|
||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
() => window.__godsEyeView?.styleManager?._cctvControls,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await whenSettled(page);
|
||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||
await page.evaluate((panel) => {
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||
@@ -329,7 +328,7 @@ try {
|
||||
}
|
||||
check('no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// Cesium-only probes: DataSource and ground-primitive readiness validate the native outline adapter.
|
||||
/**
|
||||
* qa-admin-outlines.mjs — bundled state/province/county outlines, driven like a
|
||||
* user would drive them.
|
||||
@@ -23,11 +24,12 @@
|
||||
* timings, frame-time p50/p95 and still-camera frame-difference statistics.
|
||||
* Exits nonzero on any failed assertion.
|
||||
*/
|
||||
import { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -63,12 +65,11 @@ const result = {
|
||||
video: null,
|
||||
};
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: !args.includes('--headful'),
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
@@ -145,14 +146,10 @@ try {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.annotations,
|
||||
() => window.__godsEyeView?.engine && window.__godsEyeView?.annotations,
|
||||
{ timeout: 90_000, polling: 500 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000, polling: 250 },
|
||||
);
|
||||
await whenSettled(page);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
@@ -162,7 +159,7 @@ try {
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
result.renderer = await page.evaluate(() => {
|
||||
const gl = window.__godsEyeView.viewer.scene.context._gl;
|
||||
const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2');
|
||||
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return debug
|
||||
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
|
||||
@@ -177,7 +174,7 @@ try {
|
||||
window.__qaOutlines = {
|
||||
fly(view, duration) {
|
||||
return new Promise((resolve) => {
|
||||
viewer.camera.cancelFlight();
|
||||
window.__godsEyeView.engine.camera.cancelFlight();
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
view.lon,
|
||||
@@ -264,20 +261,7 @@ try {
|
||||
});
|
||||
|
||||
const settle = () =>
|
||||
page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
for (let i = 0; i < scene.primitives.length; i++) {
|
||||
const p = scene.primitives.get(i);
|
||||
if (p?.show && typeof p.tilesLoaded === 'boolean' && !p.tilesLoaded)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 60_000, polling: 500 },
|
||||
)
|
||||
.catch(() => {});
|
||||
whenTilesLoaded(page, { timeoutMs: 60_000 }).catch(() => {});
|
||||
|
||||
async function shot(name) {
|
||||
await page.evaluate(
|
||||
@@ -317,11 +301,14 @@ try {
|
||||
// built the mark's primitives (each tick updates them before render).
|
||||
const { viewer } = window.__godsEyeView;
|
||||
await new Promise((resolve) => {
|
||||
const off = viewer.scene.postRender.addEventListener(() => {
|
||||
if (!viewer.dataSourceDisplay.ready) return;
|
||||
off();
|
||||
resolve();
|
||||
});
|
||||
const off =
|
||||
window.__godsEyeView.engine.frames.postRender.addEventListener(
|
||||
() => {
|
||||
if (!viewer.dataSourceDisplay.ready) return;
|
||||
off();
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
const drawnMs = performance.now() - started;
|
||||
const mark = window.__godsEyeView.annotations
|
||||
@@ -343,7 +330,7 @@ try {
|
||||
target,
|
||||
flyTo,
|
||||
);
|
||||
if (flyTo) await new Promise((r) => setTimeout(r, 2200));
|
||||
if (flyTo) await whenSettled(page);
|
||||
await settle();
|
||||
// Hold still: the drawn mark must not rebuild or grow.
|
||||
marker = `still:${name}`;
|
||||
@@ -551,7 +538,7 @@ try {
|
||||
.screenshot({ path: path.join(shots, 'admin-outlines-failure.png') })
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
|
||||
if (recordVideo && frames.length > 1) {
|
||||
|
||||
+15
-21
@@ -1,3 +1,6 @@
|
||||
import { evaluateWithGeo } from './qa/geometry.mjs';
|
||||
import { whenSettled } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
/**
|
||||
* qa-air-quality.mjs — headless proof for the Air quality layer.
|
||||
*
|
||||
@@ -12,7 +15,6 @@
|
||||
* Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
@@ -27,19 +29,13 @@ function record(name, ok, detail) {
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
let exitCode = 0;
|
||||
const consoleErrors = [];
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--use-gl=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
],
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
args: ['--enable-unsafe-swiftshader', '--no-sandbox', '--disable-dev-shm-usage'],
|
||||
defaultViewport: { width: 1400, height: 900 },
|
||||
});
|
||||
|
||||
@@ -52,10 +48,10 @@ async function main() {
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
() => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
await whenSettled(page);
|
||||
|
||||
const stats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
@@ -106,26 +102,24 @@ async function main() {
|
||||
// The card's whole point is "highest readings in view", so put the camera
|
||||
// over Canada before asserting on it — the default view is over Austin,
|
||||
// where an empty in-view list is correct and proves nothing.
|
||||
await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
await evaluateWithGeo(page, async (qaGeo) => {
|
||||
const d2r = Math.PI / 180;
|
||||
try {
|
||||
gev.viewer.camera.cancelFlight();
|
||||
window.__godsEyeView.engine.camera.cancelFlight();
|
||||
} catch {
|
||||
/* no flight in progress */
|
||||
}
|
||||
gev.viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: qaGeo.cartographic.toCartesian({
|
||||
longitude: -100 * d2r,
|
||||
latitude: 55 * d2r,
|
||||
height: 4_000_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.scene.requestRender?.();
|
||||
window.__godsEyeView.engine.render.request?.();
|
||||
});
|
||||
await sleep(2500);
|
||||
await whenSettled(page);
|
||||
|
||||
const controls = await page.evaluate(
|
||||
() =>
|
||||
@@ -154,7 +148,7 @@ async function main() {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Cesium-only probes: tracked entities and native model axes validate Cesium aircraft heading.
|
||||
/**
|
||||
* qa-heading-b3.mjs — throwaway QA harness for skylight Task 4
|
||||
* qa-aircraft-heading.mjs — aircraft display-heading checks
|
||||
* (path-derived, rate-limited display course; Batch 3 overnight run).
|
||||
*
|
||||
* The permanent track-regression harness flies planes STRAIGHT, so it cannot
|
||||
@@ -19,8 +20,9 @@
|
||||
* add order and the app registered first), i.e. at the exact wall-clock the
|
||||
* rate limiter advanced — postRender sampling skews apparent rates by the
|
||||
* variable render-pass duration (~40% at SwiftShader's ~3 fps). The course
|
||||
* is derived from the tracked standalone Cesium.Model's modelMatrix (the
|
||||
* actually-rendered transform), inverting _modelMatrix: with pitch=roll=0
|
||||
* is derived from the tracked model's heading-pitch-roll frame (the layer's
|
||||
* model handle, read through the QA probe's layers.drawn on both engines;
|
||||
* the drawn Cesium Model adds the glTF axis turn), inverting it: with pitch=roll=0
|
||||
* the matrix is ENU·Rz(−h), so local x in ENU = (cos h, −sin h, 0) →
|
||||
* h = atan2(−x·north, x·east), course = norm360(deg(h) − MODEL_HEADING_OFFSET).
|
||||
* The 15 s fix cadence × 3°/s turn = 45° course step per interpolation
|
||||
@@ -44,7 +46,7 @@
|
||||
* normalized nose -X, offset 180°).
|
||||
*
|
||||
* VISUAL — with the turning flights plane tracked in 3D, capture screenshots
|
||||
* (two zoom levels × two orbit angles) into qa-shots/b3/ for human review:
|
||||
* (two zoom levels × two orbit angles) into qa-shots/aircraft-heading/ for human review:
|
||||
* the nose must lead the curved trail, never sideways/backwards.
|
||||
*
|
||||
* LOW-SPEED (heading-v2, 2026-07-03 field-test regression) — two extra
|
||||
@@ -92,13 +94,15 @@
|
||||
* exceeds 15°/s (truth is 2°/s; the pre-fix snap replayed ~120° of frozen
|
||||
* divergence at the 60°/s slew cap).
|
||||
*
|
||||
* Run: node scripts/qa-heading-b3.mjs --url http://localhost:4300
|
||||
* Run: node scripts/qa-aircraft-heading.mjs --url http://localhost:4300
|
||||
* Exits non-zero if any assertion fails. Never commits anything.
|
||||
*/
|
||||
|
||||
import { whenSettled, whenFramesDrawn } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { installNavaraHeadingProbe } from './qa-heading-navara.mjs';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -111,31 +115,18 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
|
||||
if (requestedEngine) {
|
||||
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
|
||||
appUrl.searchParams.set('engine', requestedEngine);
|
||||
}
|
||||
const APP_URL = appUrl.href;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/b3');
|
||||
const SHOT_DIR = path.resolve('qa-shots/aircraft-heading');
|
||||
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
await puppeteer.executablePath().catch(() => null),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PASS/FAIL reporting
|
||||
@@ -149,7 +140,7 @@ function record(name, ok, detail) {
|
||||
|
||||
async function observeTrackedHostPaint(page, timeoutMs = 5000) {
|
||||
return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => {
|
||||
const viewer = window.__godsEyeView?.viewer;
|
||||
const viewer = (window.__headingView ?? window.__godsEyeView?.engine);
|
||||
const scene = viewer?.scene;
|
||||
const history = [];
|
||||
const startedAt = performance.now();
|
||||
@@ -447,20 +438,9 @@ async function main() {
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
fs.mkdirSync(SHOT_DIR_V2, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(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 browser = await launchBrowser({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
@@ -482,6 +462,7 @@ async function main() {
|
||||
|
||||
// ---- Turning-plane fetch shim (installed before any app code runs) ------
|
||||
await page.evaluateOnNewDocument((turn) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__TURN = turn;
|
||||
window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time
|
||||
window.__TURN_HITS = { opensky: 0, mil: 0 };
|
||||
@@ -576,58 +557,43 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
await whenSettled(page);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await whenSettled(page);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- Model finder + course derivation, installed once ------------------
|
||||
await page.evaluate(() => {
|
||||
// Every sampler in this harness targets a KNOWN aircraft, and both the
|
||||
// fleet models and the tracked standalone model carry id=icao — so the
|
||||
// finder selects by id when given one. (The old nearest-to-tracked
|
||||
// fallback could mis-pick ANOTHER plane's model for a frame whenever
|
||||
// the tracked display position read null, which injected a one-frame
|
||||
// ~90° course spike into the sampled series — a measurement flake, not
|
||||
// a product regression.)
|
||||
// fleet models and the tracked model carry id=icao — so the finder
|
||||
// selects by id. The models draw through the globe contract (engine
|
||||
// model movers), on Cesium as on Navara; the layer's drawn rows
|
||||
// (window.__godsEyeView.layers.drawn, src/layers/aircraft/qaDrawn.js)
|
||||
// give each model's heading-pitch-roll frame, the matrix the course
|
||||
// derivation below inverts. The Cesium Model a mover set draws carries
|
||||
// that frame times the layer's glTF axis turn (GLTF_AXIS in
|
||||
// src/layers/aircraft/render/display.js), so its own modelMatrix reads
|
||||
// 90° off; the handle's ready/show say whether the engine draws it.
|
||||
window.__findTrackedModel = function (icao) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = coll.get(i);
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.modelMatrix && typeof p.ready !== 'undefined') {
|
||||
if (icao && p.id !== icao) continue;
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
const shown = out.filter((m) => m.show && m.ready);
|
||||
if (icao) return shown[0] || null; // deterministic: the plane's own model or nothing
|
||||
const pool = shown.length ? shown : out;
|
||||
if (!pool.length) return null;
|
||||
const ent = v.trackedEntity;
|
||||
let tracked = null;
|
||||
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
|
||||
if (!tracked) return pool[0];
|
||||
let best = null, bestD = Infinity;
|
||||
for (const m of pool) {
|
||||
const mm = m.modelMatrix;
|
||||
const dx = mm[12] - tracked.x, dy = mm[13] - tracked.y, dz = mm[14] - tracked.z;
|
||||
const d = dx * dx + dy * dy + dz * dz;
|
||||
if (d < bestD) { bestD = d; best = m; }
|
||||
}
|
||||
return best;
|
||||
const rows = ['flights', 'military']
|
||||
.flatMap((layer) => window.__godsEyeView.layers.drawn(layer) ?? [])
|
||||
.filter((row) => row.id === icao);
|
||||
const row =
|
||||
rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ??
|
||||
rows.find((r) => r.model?.ready && r.model.show);
|
||||
return row
|
||||
? { id: icao, ready: row.model.ready, show: row.model.show, modelMatrix: row.model.matrix }
|
||||
: null;
|
||||
};
|
||||
|
||||
// Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0)
|
||||
// → h = atan2(−x·north, x·east); world course = h − headingOffsetDeg.
|
||||
window.__courseFromModelMatrix = function (mm, headingOffsetDeg) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const C3 = v.camera.position.constructor;
|
||||
const Carto = v.camera.positionCartographic.constructor;
|
||||
const carto = Carto.fromCartesian(new C3(mm[12], mm[13], mm[14]));
|
||||
@@ -648,7 +614,7 @@ async function main() {
|
||||
// preUpdate/preRender listeners and reads the rendered model matrix, but
|
||||
// does not depend on ambient render-loop scheduling for sample count.
|
||||
window.__sampleModelCourse = async function (icao, headingOffsetDeg, windowMs, stepMs = 100) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const tileset = window.__godsEyeView.tileset;
|
||||
const out = [];
|
||||
const start = performance.now();
|
||||
@@ -687,13 +653,15 @@ async function main() {
|
||||
};
|
||||
});
|
||||
|
||||
await installNavaraHeadingProbe(page);
|
||||
|
||||
// ---- Enable layers with fixes back-dated 32 s, then prime forward ------
|
||||
// First update() fires inside setEnabled, so the FIRST fix must already be
|
||||
// the oldest (history appends only monotonically-newer fix times).
|
||||
console.log('Priming turning history through the render delay (30 s / 15 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
window.__TURN.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
await dm.setEnabled('military', true);
|
||||
@@ -728,11 +696,14 @@ async function main() {
|
||||
|
||||
const flModelUp = await page.waitForFunction((icao) => {
|
||||
const m = window.__findTrackedModel(icao);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === icao && following);
|
||||
}, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false);
|
||||
record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready');
|
||||
if (!flModelUp) { finish(); return; }
|
||||
await sleep(1500); // let the follow camera + reconciliation settle
|
||||
await whenFramesDrawn(page); // let the follow camera + reconciliation settle
|
||||
|
||||
const readoutObservation = await observeTrackedHostPaint(page);
|
||||
const readoutHost = await page.evaluate(() => ({
|
||||
@@ -778,25 +749,25 @@ async function main() {
|
||||
}
|
||||
|
||||
// ---- Screenshots: two zoom levels × two orbit angles --------------------
|
||||
console.log('\n Screenshots (turning plane tracked in 3D) → qa-shots/b3/');
|
||||
console.log('\n Screenshots (turning plane tracked in 3D) → qa-shots/aircraft-heading/');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
|
||||
});
|
||||
await sleep(800);
|
||||
await whenFramesDrawn(page);
|
||||
const shots = [
|
||||
['flights-far-orbitA.png', async () => { /* default follow framing */ }],
|
||||
['flights-far-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.9); })],
|
||||
['flights-far-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.9); })],
|
||||
['flights-near-orbitA.png', () => page.evaluate(() => {
|
||||
const cam = window.__godsEyeView.viewer.camera;
|
||||
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
|
||||
// Synthetic cruise frame settles ~5.6 km out; leave ~430 m so model,
|
||||
// readout anchor, tracking line, and trail can be judged close-up.
|
||||
cam.rotateRight(-1.9); cam.zoomIn(5200);
|
||||
})],
|
||||
['flights-near-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateLeft(1.2); })],
|
||||
['flights-near-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateLeft(1.2); })],
|
||||
];
|
||||
for (const [name, move] of shots) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
await sleep(1200); // let a few frames render at the new pose
|
||||
await whenFramesDrawn(page); // let a few frames render at the new pose
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, name) });
|
||||
console.log(` saved ${name}`);
|
||||
}
|
||||
@@ -814,11 +785,14 @@ async function main() {
|
||||
|
||||
const milModelUp = await page.waitForFunction((hex) => {
|
||||
const m = window.__findTrackedModel(hex);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('military').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === hex && following);
|
||||
}, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false);
|
||||
record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready');
|
||||
if (milModelUp) {
|
||||
await sleep(1500);
|
||||
await whenFramesDrawn(page);
|
||||
const milSamples = await page.evaluate(
|
||||
(icao) => window.__sampleModelCourse(icao, 180, 40000),
|
||||
TURN.military[0].hex,
|
||||
@@ -840,21 +814,21 @@ async function main() {
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
|
||||
});
|
||||
await sleep(800);
|
||||
await whenFramesDrawn(page);
|
||||
for (const [name, move] of [
|
||||
['military-orbitA.png', async () => { }],
|
||||
['military-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.6); })],
|
||||
['military-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.6); })],
|
||||
['military-near-orbitA.png', () => page.evaluate(() => {
|
||||
const cam = window.__godsEyeView.viewer.camera;
|
||||
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
|
||||
// 12,000 ft synthetic frame settles ~6.3 km out; leave ~630 m.
|
||||
cam.rotateLeft(1.6); cam.zoomIn(5700);
|
||||
})],
|
||||
['military-near-orbitB.png', () => page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.camera.rotateRight(1.2);
|
||||
(window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.2);
|
||||
})],
|
||||
]) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
await sleep(1200);
|
||||
await whenFramesDrawn(page);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, name) });
|
||||
console.log(` saved ${name}`);
|
||||
}
|
||||
@@ -879,7 +853,10 @@ async function main() {
|
||||
}, icao);
|
||||
const up = await page.waitForFunction((id) => {
|
||||
const m = window.__findTrackedModel(id);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === id && following);
|
||||
}, { timeout: 40000, polling: 250 }, icao).then(() => true).catch(() => false);
|
||||
record(`${label}: tracked 3D model rendered (ready+shown)`, up, up ? 'model up' : 'model never became ready');
|
||||
return up;
|
||||
@@ -888,7 +865,7 @@ async function main() {
|
||||
// ---- Phase 3 — hovering helicopter --------------------------------------
|
||||
console.log('\nPhase 3 — hovering helicopter (klass=helicopter, drift ~4 m, track flips ±45°)');
|
||||
if (await trackFlightsAndWaitModel(TURN.hover.icao, 'hover-heli')) {
|
||||
await sleep(1500);
|
||||
await whenFramesDrawn(page);
|
||||
const heliSamples = await sampleCourse(180, 35000, TURN.hover.icao);
|
||||
analyzeHover('hover-heli', heliSamples, {});
|
||||
for (const [name, waitMs] of [['hover-heli-T0.png', 0], ['hover-heli-T8s.png', 8000]]) {
|
||||
@@ -901,7 +878,7 @@ async function main() {
|
||||
// ---- Phase 4 — 25 kt plane in a continuous 4°/s right turn --------------
|
||||
console.log('\nPhase 4 — slow plane in a tight turn (25 kt, 4°/s, R=184 m)');
|
||||
if (await trackFlightsAndWaitModel(TURN.slow.icao, 'slow-turn')) {
|
||||
await sleep(1500);
|
||||
await whenFramesDrawn(page);
|
||||
const slowSamples = await sampleCourse(180, 40000, TURN.slow.icao);
|
||||
analyzeSlowTurn('slow-turn', slowSamples, { scoreProgress: false });
|
||||
// Exercise the production dt clamp with render gaps above
|
||||
@@ -916,10 +893,10 @@ async function main() {
|
||||
});
|
||||
for (const [name, move] of [
|
||||
['slowturn-far.png', async () => { /* default follow framing */ }],
|
||||
['slowturn-near.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.zoomIn(2500); })],
|
||||
['slowturn-near.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.zoomIn(2500); })],
|
||||
]) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
await sleep(1200);
|
||||
await whenFramesDrawn(page);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR_V2, name) });
|
||||
console.log(` saved ${name}`);
|
||||
}
|
||||
@@ -932,22 +909,7 @@ async function main() {
|
||||
// ========================================================================
|
||||
console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)');
|
||||
await page.evaluate(() => {
|
||||
window.__findModelByIcao = function (icao) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === icao && p.show && p.ready) out.push(p);
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out[0] || null;
|
||||
};
|
||||
window.__findModelByIcao = window.__findTrackedModel;
|
||||
});
|
||||
|
||||
// Activate the heli in the feed now (kept out of phases 1–4), ingest it,
|
||||
@@ -959,7 +921,7 @@ async function main() {
|
||||
window.__TURN.heli65.active = true;
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
fl.setParams({ models3d: false });
|
||||
await fl.update(window.__godsEyeView.viewer); // ingest the first heli fix
|
||||
await fl.update((window.__headingView ?? window.__godsEyeView.viewer)); // ingest the first heli fix
|
||||
});
|
||||
await sleep(4000); // several fleet ticks establish the heli's fleet course
|
||||
await page.evaluate((icao) => {
|
||||
@@ -968,7 +930,7 @@ async function main() {
|
||||
const heliTracked = await page.waitForFunction((icao) => {
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const ti = fl.getTrackedInfo();
|
||||
return !!(ti && ti.icao24 === icao && window.__godsEyeView.viewer.trackedEntity);
|
||||
return !!(ti && ti.icao24 === icao && (window.__headingView ?? window.__godsEyeView.viewer).trackedEntity);
|
||||
}, { timeout: 15000, polling: 250 }, TURN.heli65.icao).then(() => true).catch(() => false);
|
||||
record('heli65: tracking engaged (2D billboard mode)', heliTracked,
|
||||
heliTracked ? 'tracked' : 'tracking never engaged');
|
||||
@@ -985,9 +947,10 @@ async function main() {
|
||||
// rotation after. The release-in-place keeps the camera static, so
|
||||
// rotation deltas across the boundary are course deltas.
|
||||
const c1 = await page.evaluate(async ({ icao, actionAtMs, windowMs }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const findFleetBillboard = (id) => {
|
||||
if (window.__headingView) return window.__godsEyeView.layers.drawn('flights').find((row) => row.id === id && row.show);
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -1039,7 +1002,16 @@ async function main() {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true });
|
||||
});
|
||||
const fleetModelUp = await page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (window.__headingView) {
|
||||
const deadline = performance.now() + 40000;
|
||||
while (performance.now() < deadline) {
|
||||
if (window.__findModelByIcao(icao)) return true;
|
||||
window.__godsEyeView.engine.render.request('heading-probe');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const tileset = window.__godsEyeView.tileset;
|
||||
const priorDefaultLoop = v.useDefaultRenderLoop;
|
||||
const priorTilesetShow = tileset?.show;
|
||||
@@ -1063,7 +1035,7 @@ async function main() {
|
||||
record('heli65 C2 (click): fleet model rendered before re-track', false, 'fleet model never became ready');
|
||||
} else {
|
||||
const c2 = await page.evaluate(async ({ icao, offsetDeg, actionAtMs, windowMs }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const out = [];
|
||||
const start = performance.now();
|
||||
@@ -1098,7 +1070,7 @@ async function main() {
|
||||
|
||||
finish();
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
|
||||
function finish() {
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-sprites-b5.mjs — throwaway QA harness for skylight Tasks 8+9
|
||||
* qa-aircraft-sprites.mjs — aircraft sprite checks
|
||||
* (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5).
|
||||
*
|
||||
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
|
||||
* classes — the flights layer via OpenSky extended categories (state[17]),
|
||||
* the military layer via adsb.lol type codes — using the same fetch-shim +
|
||||
* history-priming pattern as scripts/qa-heading-b3.mjs (positions analytic at
|
||||
* history-priming pattern as scripts/qa-aircraft-heading.mjs (positions analytic at
|
||||
* serve time so every fix is self-consistent through the render delay).
|
||||
*
|
||||
* Machine assertions:
|
||||
@@ -15,17 +15,17 @@
|
||||
* per-class glyph (image data-URI === aircraftIcon(klass),
|
||||
* imported from src) and per-class scale (flights:
|
||||
* CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D)
|
||||
* B3 models : with 3D on, fleet Cesium.Model.scale === the layer's
|
||||
* Model scales : with 3D on, fleet Cesium.Model.scale === the layer's
|
||||
* MODEL_SCALE (both 1; legacy scale is baked into the GLB)
|
||||
* × CLASS_SCALE_3D[klass] per icao; tracked too
|
||||
* B4 clean : no console errors
|
||||
*
|
||||
* Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows,
|
||||
* Visual output (qa-shots/aircraft-sprites/, untracked): top-down 2D spreads of both rows,
|
||||
* per-row close-ups, a tracked-cyan tint shot, and 3D cluster shots at two
|
||||
* zooms per layer showing per-class model scale differences. The PNGs are the
|
||||
* acceptance test — read them.
|
||||
*
|
||||
* Run: node scripts/qa-sprites-b5.mjs --url http://localhost:4300
|
||||
* Run: node scripts/qa-aircraft-sprites.mjs --url http://localhost:4300
|
||||
* Exits non-zero if any assertion fails. Never commits anything.
|
||||
*/
|
||||
|
||||
@@ -45,7 +45,7 @@ const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1
|
||||
import { aircraftIcon } from '../src/data/aircraftIcons.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as qa-heading-b3.mjs)
|
||||
// Args (same shape as qa-aircraft-heading.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
@@ -54,9 +54,15 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const BASE_URL = getOpt('--url', 'http://localhost:4173');
|
||||
// --engine=cesium|navara: on both engines the layers are read through the QA
|
||||
// probe (window.__godsEyeView.layers.drawn, the dev server's); the military
|
||||
// checks are skipped where the engine has no military layer.
|
||||
const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
|
||||
const APP_URL =
|
||||
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/b5');
|
||||
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/aircraft-sprites' : `qa-shots/aircraft-sprites-${ENGINE}`);
|
||||
|
||||
const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale)
|
||||
const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb
|
||||
@@ -132,7 +138,7 @@ const SPRITES = {
|
||||
{ hex: 'dda006', flight: 'ML-QUAD', t: 'B744', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 13000 },
|
||||
{ hex: 'dda007', flight: 'ML-HEL', t: 'H60', lon0: ROW_LON0 + 6 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 3000 },
|
||||
// course 0 like the rest — keeps the row a clean size-comparison line (2D
|
||||
// rotation is demoed by caa004 and gated by qa-heading-b3 anyway).
|
||||
// rotation is demoed by caa004 and gated by qa-aircraft-heading anyway).
|
||||
{ hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 },
|
||||
// 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a
|
||||
// real-world-scale asset (native bounding radius ~29.8) rendered at
|
||||
@@ -174,7 +180,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -202,6 +208,7 @@ async function main() {
|
||||
|
||||
// ---- Straight-flight fetch shim (installed before any app code runs) ----
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__SPR = spec;
|
||||
window.__SPR.epochMs = Date.now();
|
||||
window.__SPR_HITS = { opensky: 0, mil: 0 };
|
||||
@@ -277,52 +284,33 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
|
||||
// A cold dev server compiles the Navara chunks on first load.
|
||||
{ timeout: 180000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page probes: billboard walk, model walk, tracked-model finder ---
|
||||
await page.evaluate(() => {
|
||||
// Every billboard (image + alignedAxis + id) in every collection.
|
||||
// Every fleet glyph the flights and military layers draw, through the
|
||||
// engine-neutral probe (layers.drawn: the layers' own sprite handles),
|
||||
// on either engine. The layers no longer own Cesium billboards: on
|
||||
// Cesium the contract's batched billboards carry atlas keys, not the
|
||||
// glyph data-URIs.
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||
.filter((row) => !row.trackedModel)
|
||||
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
|
||||
};
|
||||
// Every glTF model primitive (modelMatrix + ready flag), with id + scale.
|
||||
// Every fleet and tracked 3D model, with id + scale (the probe's model
|
||||
// handles). The world bounding radius is not on the contract: null.
|
||||
window.__collectModels = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.image === undefined) {
|
||||
let radius = null;
|
||||
try { radius = p.ready && p.boundingSphere ? p.boundingSphere.radius : null; } catch { /* not ready */ }
|
||||
out.push({ id: p.id, scale: p.scale, ready: p.ready, show: p.show, radius });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||
.filter((row) => row.model && (row.trackedModel || !row.tracked))
|
||||
.map(({ id, model }) => ({ id, ...model, radius: null }));
|
||||
};
|
||||
// Displayed (render-delayed) latitude of a synthetic plane right now.
|
||||
window.__displayedState = function (layer, idx, renderDelaySec) {
|
||||
@@ -333,43 +321,46 @@ async function main() {
|
||||
};
|
||||
// Canvas coordinates of every synthetic plane at its displayed position —
|
||||
// ground truth for identifying which glyph is which in a screenshot.
|
||||
window.__planeCanvasCoords = function (layer, renderDelaySec, altOf) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
window.__planeCanvasCoords = async function (layer, renderDelaySec, altOf) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { engine } = window.__godsEyeView;
|
||||
const S = window.__SPR;
|
||||
const C3 = v.camera.position.constructor;
|
||||
return S[layer].map((p, i) => {
|
||||
const s = window.__displayedState(layer, i, renderDelaySec);
|
||||
const pos = C3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
||||
const c = v.scene.cartesianToCanvasCoordinates(pos);
|
||||
const pos = geo.cartesian3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
||||
const c = engine.projector.worldToWindow(pos);
|
||||
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
|
||||
}).join(' ');
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Prime history through the render delay (same pattern as b3) -------
|
||||
// ---- Prime history through the render delay (same pattern as the heading checks) -------
|
||||
console.log('Priming straight-flight history through the render delay (30 s / 15 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
const hasMil = dm.layers.has('military');
|
||||
window.__SPR.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
await dm.setEnabled('military', true);
|
||||
if (hasMil) await dm.setEnabled('military', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
const mil = dm.layers.get('military').module;
|
||||
const mil = hasMil ? dm.layers.get('military').module : null;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
window.__SPR.timeOffsetSec = off;
|
||||
await fl.update(v);
|
||||
await mil.update(v);
|
||||
await mil?.update(v);
|
||||
}
|
||||
window.__SPR.timeOffsetSec = 0;
|
||||
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil.update(v); }, 15000);
|
||||
return { fl: fl.getStats().count, mil: mil.getStats().count, hits: window.__SPR_HITS };
|
||||
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil?.update(v); }, 15000);
|
||||
return { fl: fl.getStats().count, mil: mil ? mil.getStats().count : null, hits: window.__SPR_HITS };
|
||||
});
|
||||
console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`);
|
||||
const hasMilitary = primed.mil !== null;
|
||||
if (!hasMilitary) console.log(` [SKIP] the military rows — no military layer on ${ENGINE}`);
|
||||
record('B1 ingest: synthetic planes in both layers',
|
||||
primed.fl === SPRITES.flights.length && primed.mil === SPRITES.military.length,
|
||||
`flights=${primed.fl}/${SPRITES.flights.length} military=${primed.mil}/${SPRITES.military.length}`);
|
||||
if (!(primed.fl > 0 && primed.mil > 0)) { finish(); return; }
|
||||
primed.fl === SPRITES.flights.length && (!hasMilitary || primed.mil === SPRITES.military.length),
|
||||
`flights=${primed.fl}/${SPRITES.flights.length} military=${hasMilitary ? `${primed.mil}/${SPRITES.military.length}` : 'n/a'}`);
|
||||
if (!(primed.fl > 0 && (!hasMilitary || primed.mil > 0))) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
// B2 — billboard glyph + scale per class (machine check)
|
||||
@@ -381,37 +372,38 @@ async function main() {
|
||||
let scaleBad = [];
|
||||
for (const [id, klass] of expectedFlights) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (!bb || !wantImg.includes(bb.image)) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
for (const [id, klass] of expectedMilitary) {
|
||||
for (const [id, klass] of hasMilitary ? expectedMilitary : []) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
|
||||
const wantScale = MIL_BILLBOARD_SCALE * (CLASS_SCALE_2D[klass] || 1);
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (!bb || !wantImg.includes(bb.image)) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('B2a: every synthetic billboard has its class glyph (data-URI match)',
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + expectedMilitary.size} billboards matched`);
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + (hasMilitary ? expectedMilitary.size : 0)} billboards matched`);
|
||||
record('B2b: every synthetic billboard has its class scale',
|
||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact');
|
||||
const distinctGlyphs = new Set([...expectedFlights.values(), ...expectedMilitary.values()].map((k) => aircraftIcon(k)));
|
||||
record('B2c: all 8 classes exercised with 8 distinct glyphs',
|
||||
distinctGlyphs.size === 8, `${distinctGlyphs.size} distinct data URIs`);
|
||||
const distinctGlyphs = new Set([...expectedFlights.values(), ...(hasMilitary ? expectedMilitary.values() : [])].map((k) => aircraftIcon(k)));
|
||||
const wantGlyphs = hasMilitary ? 8 : new Set(expectedFlights.values()).size;
|
||||
record(`B2c: all ${wantGlyphs} classes exercised with ${wantGlyphs} distinct glyphs`,
|
||||
distinctGlyphs.size === wantGlyphs, `${distinctGlyphs.size} distinct data URIs`);
|
||||
|
||||
// ========================================================================
|
||||
// 2D screenshots
|
||||
// ========================================================================
|
||||
console.log('\n2D screenshots → qa-shots/b5/');
|
||||
console.log('\n2D screenshots → qa-shots/aircraft-sprites/');
|
||||
const topDown = async (lat, lon, height) => {
|
||||
await page.evaluate(({ lat, lon, height }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const C3 = v.camera.position.constructor;
|
||||
v.camera.setView({
|
||||
destination: C3.fromDegrees(lon, lat, height),
|
||||
await page.evaluate(async ({ lat, lon, height }) => {
|
||||
const camera = window.__godsEyeView.engine.camera;
|
||||
camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const geo = await import('/src/geo/index.js');
|
||||
camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat, lon, height });
|
||||
@@ -424,6 +416,7 @@ async function main() {
|
||||
const zoomToRange = async (want) => {
|
||||
await page.evaluate((want) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v) return; // the engine follow keeps its own range (screenshots only)
|
||||
const p = v.camera.position;
|
||||
const d = Math.hypot(p.x, p.y, p.z);
|
||||
if (d > 1e6) return; // not in a tracked reference frame — leave it alone
|
||||
@@ -438,19 +431,21 @@ async function main() {
|
||||
const flRowLat = await rowLat('flights', 2, 30);
|
||||
const milRowLat = await rowLat('military', 4, 15);
|
||||
await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-spread-bothrows.png') });
|
||||
console.log(' saved b5-2d-spread-bothrows.png (flights row top, military row bottom)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-spread-bothrows.png') });
|
||||
console.log(' saved aircraft-sprites-2d-spread-bothrows.png (flights row top, military row bottom)');
|
||||
|
||||
// Row widths: military spans 7 steps (~8.1 km) → frame ~13.9 km at 12 km up;
|
||||
// flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up.
|
||||
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-military-row.png') });
|
||||
console.log(' saved b5-2d-military-row.png (8 classes, amber)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
|
||||
if (hasMilitary) {
|
||||
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-military-row.png') });
|
||||
console.log(' saved aircraft-sprites-2d-military-row.png (8 classes, amber)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
|
||||
}
|
||||
|
||||
await topDown(await rowLat('flights', 2, 30), ROW_LON0 + 2.5 * ROW_STEP_DEG, 12000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-flights-row.png') });
|
||||
console.log(' saved b5-2d-flights-row.png (6 category classes, white)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-flights-row.png') });
|
||||
console.log(' saved aircraft-sprites-2d-flights-row.png (6 category classes, white)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`);
|
||||
|
||||
// Tracked tint: commercial tracked plane goes cyan; neighbors stay white.
|
||||
@@ -458,11 +453,11 @@ async function main() {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
|
||||
}, 'caa002');
|
||||
await sleep(3000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-tracked-cyan.png') });
|
||||
console.log(' saved b5-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-tracked-cyan.png') });
|
||||
console.log(' saved aircraft-sprites-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
|
||||
|
||||
// ========================================================================
|
||||
// B3 + 3D screenshots — flights cluster
|
||||
// Model scale and 3D screenshots — flights cluster
|
||||
// ========================================================================
|
||||
console.log('\n3D — flights cluster (airplane.glb, per-class scale)');
|
||||
await page.evaluate(() => {
|
||||
@@ -499,16 +494,19 @@ async function main() {
|
||||
|
||||
await sleep(1500);
|
||||
await zoomToRange(900);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-mid.png') });
|
||||
console.log(' saved b5-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-mid.png') });
|
||||
console.log(' saved aircraft-sprites-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
|
||||
await zoomToRange(450);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-close.png') });
|
||||
console.log(' saved b5-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-close.png') });
|
||||
console.log(' saved aircraft-sprites-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// B3 + 3D screenshots — military cluster
|
||||
// Model scale and 3D screenshots — military cluster
|
||||
// ========================================================================
|
||||
if (!hasMilitary) {
|
||||
console.log(` [SKIP] 3D military — no military layer on ${ENGINE}`);
|
||||
} else {
|
||||
console.log('\n3D — military cluster (jet.glb, per-class scale)');
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
@@ -547,11 +545,12 @@ async function main() {
|
||||
// jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the
|
||||
// flights cluster (close zoom no longer puts the camera inside a model).
|
||||
await zoomToRange(900);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') });
|
||||
console.log(' saved b5-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-far.png') });
|
||||
console.log(' saved aircraft-sprites-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
|
||||
await zoomToRange(450);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-close.png') });
|
||||
console.log(' saved b5-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-close.png') });
|
||||
console.log(' saved aircraft-sprites-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
|
||||
}
|
||||
}
|
||||
|
||||
record('B4: no console errors during QA run', consoleErrors.length === 0,
|
||||
@@ -0,0 +1,814 @@
|
||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
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';
|
||||
import { closeBrowser, exitAfterFlush } from './qa/browser.mjs';
|
||||
import { runScenario } from './qa/process.mjs';
|
||||
import {
|
||||
classifyAttempt,
|
||||
historyLine,
|
||||
retryStatus,
|
||||
statusCounts,
|
||||
} from './qa/acceptance.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. A timed-out pass is hung; a retry that passes is flaky and does
|
||||
// not fail the run. QA_EXIT_DIAGNOSTICS=1 preserves Chrome resources and
|
||||
// reports them five seconds after strict completion instead of forcing exit.
|
||||
// Pages start at the 18:30 UTC nearest to the real time unless createStrict's
|
||||
// time overrides it.
|
||||
//
|
||||
// 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
|
||||
// [--no-retry] disable the one retry of a non-passing scenario
|
||||
// [--history=FILE] append a JSONL run summary; inspect it with
|
||||
// node scripts/report-acceptance-flake.mjs FILE
|
||||
// [--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 startedAt = new Date().toISOString();
|
||||
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 closeBrowser(browser);
|
||||
} 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 = [
|
||||
[
|
||||
'switch-equals-cold-start--markers',
|
||||
'switch-equals-cold-start',
|
||||
['news', 'shipwrecks'],
|
||||
['--group=markers'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--aircraft',
|
||||
'switch-equals-cold-start',
|
||||
['flights', 'military'],
|
||||
['--group=aircraft'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--local',
|
||||
'switch-equals-cold-start',
|
||||
['local-adsb', 'local-dams', 'local-datacenters'],
|
||||
['--group=local'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--ground',
|
||||
'switch-equals-cold-start',
|
||||
['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'],
|
||||
['--group=ground'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--contacts',
|
||||
'switch-equals-cold-start',
|
||||
['military-awareness', 'military-installations'],
|
||||
['--group=contacts'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--open',
|
||||
'switch-equals-cold-start',
|
||||
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'],
|
||||
['--group=open'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--weather',
|
||||
'switch-equals-cold-start',
|
||||
[
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
],
|
||||
['--group=weather'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--wind',
|
||||
'switch-equals-cold-start',
|
||||
['wind'],
|
||||
['--group=wind'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--ghostmaps',
|
||||
'switch-equals-cold-start',
|
||||
['s2-ghostmaps'],
|
||||
['--group=ghostmaps'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--wildlife',
|
||||
'switch-equals-cold-start',
|
||||
['wildlife-sightings'],
|
||||
['--group=wildlife'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--urban',
|
||||
'switch-equals-cold-start',
|
||||
['bikeshare', 'transit', 'earthquakes'],
|
||||
['--group=urban'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--cameras',
|
||||
'switch-equals-cold-start',
|
||||
['cctv', 'alpr-cameras'],
|
||||
['--group=cameras'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--radio',
|
||||
'switch-equals-cold-start',
|
||||
['radio'],
|
||||
['--group=radio'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--space',
|
||||
'switch-equals-cold-start',
|
||||
['satellites', 'rocket-launches'],
|
||||
['--group=space'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--traffic',
|
||||
'switch-equals-cold-start',
|
||||
['traffic'],
|
||||
['--group=traffic'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--street',
|
||||
'switch-equals-cold-start',
|
||||
['street-level'],
|
||||
['--group=street'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--imagery',
|
||||
'switch-equals-cold-start',
|
||||
['recent-imagery'],
|
||||
['--group=imagery'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--nepal',
|
||||
'switch-equals-cold-start',
|
||||
['bhote-koshi-locator', 'bhote-koshi-2026'],
|
||||
['--group=nepal'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--cables',
|
||||
'switch-equals-cold-start',
|
||||
['telegeography-submarine-cables', 'directions'],
|
||||
['--group=cables'],
|
||||
],
|
||||
[
|
||||
'switch-equals-cold-start--fires',
|
||||
'switch-equals-cold-start',
|
||||
['local-firms'],
|
||||
['--group=fires'],
|
||||
],
|
||||
['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']],
|
||||
['tiles-fixture', 'tiles-fixture', ['osm-places']],
|
||||
['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 closeBrowser(browser);
|
||||
}
|
||||
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,
|
||||
finishedAt: null,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
counts: statusCounts([]),
|
||||
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');
|
||||
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 attempts = [];
|
||||
for (let attempt = 1; attempt <= (flag('no-retry') ? 1 : 2); attempt++) {
|
||||
rmSync(evidence, { force: true });
|
||||
const attemptStart = Date.now();
|
||||
const run = await runScenario(
|
||||
process.execPath,
|
||||
[join(scripts, script), ...args],
|
||||
{
|
||||
timeout: seconds * 1000,
|
||||
env: scrubbed(),
|
||||
},
|
||||
);
|
||||
const log = machinePaths(
|
||||
(run.stdout || '') + (run.stderr || '') + (run.error?.message || ''),
|
||||
);
|
||||
let manifest = null;
|
||||
let evidenceError = null;
|
||||
if (existsSync(evidence)) {
|
||||
try {
|
||||
manifest = JSON.parse(machinePaths(readFileSync(evidence, 'utf8')));
|
||||
} catch (error) {
|
||||
evidenceError = 'Invalid evidence: ' + error.message;
|
||||
}
|
||||
}
|
||||
const status =
|
||||
evidenceError && !run.error
|
||||
? 'fail'
|
||||
: classifyAttempt(run, manifest, { external });
|
||||
const logFile = `${label}.attempt-${attempt}.log`;
|
||||
const evidenceFile = manifest
|
||||
? `evidence/${label}.attempt-${attempt}.json`
|
||||
: null;
|
||||
writeFileSync(join(output, logFile), log);
|
||||
if (manifest)
|
||||
writeFileSync(
|
||||
join(output, evidenceFile),
|
||||
JSON.stringify(manifest, null, 2) + '\n',
|
||||
);
|
||||
attempts.push({
|
||||
status,
|
||||
exitCode: run.status,
|
||||
seconds: Math.round((Date.now() - attemptStart) / 100) / 10,
|
||||
log: logFile,
|
||||
logSha256: createHash('sha256').update(log).digest('hex'),
|
||||
evidence: evidenceFile,
|
||||
error: evidenceError ?? manifest?.error ?? run.error?.message ?? null,
|
||||
});
|
||||
writeFileSync(join(output, label + '.log'), log);
|
||||
if (manifest)
|
||||
writeFileSync(evidence, JSON.stringify(manifest, null, 2) + '\n');
|
||||
console.log(
|
||||
status.toUpperCase(),
|
||||
label,
|
||||
attempts.at(-1).seconds,
|
||||
`attempt ${attempt}`,
|
||||
);
|
||||
if (status === 'pass' || attempt === 2 || flag('no-retry')) break;
|
||||
}
|
||||
const last = attempts.at(-1);
|
||||
const status = retryStatus(attempts[0].status, attempts[1]?.status);
|
||||
const manifest = last.evidence
|
||||
? JSON.parse(readFileSync(join(output, last.evidence), 'utf8'))
|
||||
: null;
|
||||
scenarios.push({
|
||||
label,
|
||||
script,
|
||||
args: args.map((arg) =>
|
||||
arg.includes(output) ? arg.replace(output, '<output>') : arg,
|
||||
),
|
||||
layers,
|
||||
external: Boolean(external),
|
||||
status,
|
||||
exitCode: last.exitCode,
|
||||
seconds: Math.round((Date.now() - start) / 100) / 10,
|
||||
log: label + '.log',
|
||||
logSha256: last.logSha256,
|
||||
attempts,
|
||||
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: last.error,
|
||||
});
|
||||
if (manifest?.soak) scenarios.at(-1).soak = manifest.soak;
|
||||
report.counts = statusCounts(scenarios);
|
||||
report.passed = report.counts.pass + report.counts.flaky;
|
||||
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();
|
||||
if (flag('history'))
|
||||
appendFileSync(
|
||||
resolve(String(flag('history'))),
|
||||
JSON.stringify(historyLine(report)) + '\n',
|
||||
);
|
||||
console.log(
|
||||
`${catalogIds.length} catalog layers, ${report.uncovered.length} engine slots without an assertion, ` +
|
||||
`${report.networkViolations} network violations, ${report.passed}/${scenarios.length} scenarios, ` +
|
||||
Object.entries(report.counts)
|
||||
.map(([status, count]) => `${count} ${status}`)
|
||||
.join(', ') +
|
||||
', ' +
|
||||
`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();
|
||||
await exitAfterFlush();
|
||||
+103
-47
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
// Cesium-only probes: requestRender instrumentation measures Cesium request-mode cost.
|
||||
/**
|
||||
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
|
||||
*
|
||||
* Run: node scripts/qa-alpr-journey.mjs http://localhost:4173 [--headful]
|
||||
* Run: node scripts/qa-alpr-journey.mjs <dev-server-url> [--fixture]
|
||||
* [--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 +15,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
|
||||
* Cesium render CPU time (mean / p50 / p95) while moving and while holding, and
|
||||
* engine 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
|
||||
@@ -36,10 +37,14 @@
|
||||
* Writes <out>/result.json, <out>/clip.mp4 (CDP screencast with real frame
|
||||
* timing) and per-stop screenshots. Needs ffmpeg on PATH.
|
||||
*/
|
||||
import { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
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,
|
||||
@@ -60,12 +65,20 @@ 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)) || 'http://localhost:4173';
|
||||
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 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) => {
|
||||
@@ -152,14 +165,12 @@ const STOPS = [
|
||||
},
|
||||
];
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: !argv.includes('--headful'),
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
@@ -170,6 +181,8 @@ const result = {
|
||||
startedAt: new Date().toISOString(),
|
||||
passes: [],
|
||||
consoleErrors: [],
|
||||
expectedPreviewErrors: [],
|
||||
failedRequests: [],
|
||||
failures: [],
|
||||
};
|
||||
const fail = (message) => {
|
||||
@@ -179,23 +192,51 @@ 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 (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||
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,
|
||||
});
|
||||
});
|
||||
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 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await whenSettled(page);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
@@ -210,23 +251,17 @@ 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 rendered entity ids and their visibility.
|
||||
// hash the layer's mover sets on the globe contract (ids and counts).
|
||||
let hash = 0;
|
||||
let shown = 0;
|
||||
if (Number.isFinite(stats.renderRevision)) {
|
||||
hash = stats.renderRevision;
|
||||
shown = stats.shown ?? stats.count ?? 0;
|
||||
} 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);
|
||||
} 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}`;
|
||||
for (let i = 0; i < id.length; i++)
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
}
|
||||
@@ -249,20 +284,7 @@ try {
|
||||
await recorder.start();
|
||||
|
||||
async function waitTiles(maxMs = 20_000) {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
if (scene.globe.show) return scene.globe.tilesLoaded;
|
||||
for (let i = 0; i < scene.primitives.length; i++) {
|
||||
const p = scene.primitives.get(i);
|
||||
if (p.show && p.tilesLoaded === false) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: maxMs, polling: 250 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await whenTilesLoaded(page, { timeoutMs: maxMs }).catch(() => {});
|
||||
}
|
||||
|
||||
for (const [passIndex, mode] of passes.entries()) {
|
||||
@@ -308,8 +330,31 @@ try {
|
||||
// changed state without asking for a render.
|
||||
let staleFrame = null;
|
||||
if (alpr) {
|
||||
const shown = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
const shownImage = await page.screenshot({ type: 'png' });
|
||||
const shown = await cyanCount(shownImage);
|
||||
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;
|
||||
@@ -317,12 +362,23 @@ try {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
scene.requestRenderMode = mode;
|
||||
});
|
||||
const forced = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
const forcedImage = await page.screenshot({ type: 'png' });
|
||||
const forced = await cyanCount(forcedImage);
|
||||
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));
|
||||
@@ -580,7 +636,7 @@ try {
|
||||
path.join(outDir, 'result.json'),
|
||||
JSON.stringify(result, null, 1),
|
||||
);
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
console.log(
|
||||
`${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`,
|
||||
|
||||
+13
-13
@@ -1,4 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Cesium-only probes: sky commands, the Julian clock and post-process stages validate the Cesium sky.
|
||||
import { whenSettled } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
/**
|
||||
* qa-anime-sky — the anime style must leave the sky without bands.
|
||||
*
|
||||
@@ -13,14 +16,13 @@
|
||||
* Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/]
|
||||
* Exit: 0 pass, 1 fail, 2 the anime style was not reached.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const i = process.argv.indexOf('--url');
|
||||
const URL_ =
|
||||
i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
try {
|
||||
@@ -41,17 +43,15 @@ try {
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, {
|
||||
timeout: 120000,
|
||||
});
|
||||
await whenSettled(page);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const sky = window.__godsEyeView.viewer.scene.skyBox;
|
||||
return (
|
||||
sky?._milkyWayCubeMap &&
|
||||
sky?._starCommand &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden')
|
||||
);
|
||||
return sky?._milkyWayCubeMap && sky?._starCommand;
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await whenSettled(page);
|
||||
const r = await page.evaluate(async () => {
|
||||
const entry = performance
|
||||
.getEntriesByType('resource')
|
||||
@@ -94,22 +94,22 @@ try {
|
||||
C.Cartesian3.cross(look, north, new C.Cartesian3()),
|
||||
new C.Cartesian3(),
|
||||
);
|
||||
s.camera.cancelFlight();
|
||||
window.__godsEyeView.engine.camera.cancelFlight();
|
||||
s.tweens.removeAll();
|
||||
s.camera.setView({
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()),
|
||||
orientation: {
|
||||
direction: look,
|
||||
up: C.Cartesian3.cross(right, look, new C.Cartesian3()),
|
||||
},
|
||||
});
|
||||
const gl = s.context._gl,
|
||||
const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'),
|
||||
w = gl.drawingBufferWidth,
|
||||
h = gl.drawingBufferHeight,
|
||||
px = new Uint8Array(w * h * 4);
|
||||
const measure = async () => {
|
||||
await new Promise((res) => setTimeout(res, 2500));
|
||||
s.requestRender();
|
||||
window.__godsEyeView.engine.render.request();
|
||||
s.render();
|
||||
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
||||
let banded = 0;
|
||||
@@ -156,5 +156,5 @@ try {
|
||||
console.error(`UNREACHED: ${error.message}`);
|
||||
process.exitCode = 2;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { whenSettled } from './qa/ready.mjs';
|
||||
import { closeBrowser, exitAfterFlush } from './qa/browser.mjs';
|
||||
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 whenSettled(page);
|
||||
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 whenSettled(page);
|
||||
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 whenSettled(page);
|
||||
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 whenSettled(page);
|
||||
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');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
await exitAfterFlush();
|
||||
@@ -1,16 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
import { whenSettled } from './qa/ready.mjs';
|
||||
/** Prove standalone startup and terminal resource ownership in a real browser. */
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const urlIndex = args.indexOf('--url');
|
||||
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
||||
const browser = await puppeteer.launch({
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
|
||||
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
args: ['--no-sandbox'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
@@ -28,6 +27,7 @@ try {
|
||||
await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await whenSettled(page);
|
||||
const before = await page.evaluate(async () => {
|
||||
const entry = [...document.scripts].find((script) =>
|
||||
/\/src\/main\.js(?:\?|$)/.test(script.src),
|
||||
@@ -52,7 +52,7 @@ try {
|
||||
enabled,
|
||||
layers: app.dataManager.layers.size,
|
||||
annotationCount: annotation.drawn,
|
||||
viewerAlive: !app.viewer.isDestroyed(),
|
||||
viewerAlive: !window.__godsEyeView.engine.isDestroyed(),
|
||||
credits: Boolean(document.querySelector('#cesium-credits')),
|
||||
};
|
||||
});
|
||||
@@ -83,7 +83,7 @@ try {
|
||||
return {
|
||||
status: application.getState().status,
|
||||
samePromise,
|
||||
viewerDestroyed: app.viewer.isDestroyed(),
|
||||
viewerDestroyed: app.engine.isDestroyed(),
|
||||
layers: app.dataManager.layers.size,
|
||||
annotations: app.annotations.count(),
|
||||
governor: app.getRenderGovernorDiagnostics(),
|
||||
@@ -112,5 +112,5 @@ try {
|
||||
'PASS: terminal teardown releases runtime owners without late browser errors',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Cesium-only probes: the native credit registry and lightbox are the attribution oracle.
|
||||
import { whenSettled, whenUiSettled } from './qa/ready.mjs';
|
||||
/**
|
||||
* qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution).
|
||||
*
|
||||
@@ -16,7 +18,7 @@
|
||||
*
|
||||
* Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
@@ -32,19 +34,7 @@ const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12');
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
await puppeteer.executablePath().catch(() => null),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => {
|
||||
try { return existsSync(candidate); } catch { return false; }
|
||||
}) || null;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -70,10 +60,8 @@ const REQUIRED_CREDIT_SUBSTRINGS = [
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
@@ -122,8 +110,9 @@ async function main() {
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await whenSettled(page);
|
||||
// Give Cesium a few frames to render the on-screen credit line (logo + link).
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
await whenUiSettled(page);
|
||||
|
||||
// ── (i) H11: static credits registered ────────────────────────────
|
||||
console.log('H11 — per-layer credits registered in viewer.creditDisplay');
|
||||
@@ -147,11 +136,11 @@ async function main() {
|
||||
// Force a render frame so the credit display flushes, then open the lightbox.
|
||||
const lightboxState = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.scene.requestRender();
|
||||
window.__godsEyeView.engine.render.request();
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const cd = viewer.creditDisplay;
|
||||
cd.showLightbox();
|
||||
viewer.scene.requestRender();
|
||||
window.__godsEyeView.engine.render.request();
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const box = document.querySelector('.cesium-credit-lightbox');
|
||||
const list = box?.querySelector(':scope > ul');
|
||||
@@ -263,7 +252,7 @@ async function main() {
|
||||
await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.resize();
|
||||
viewer.scene.requestRender();
|
||||
window.__godsEyeView.engine.render.request();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const mobileLightbox = await page.evaluate(() => {
|
||||
@@ -311,7 +300,7 @@ async function main() {
|
||||
check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`);
|
||||
if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId);
|
||||
if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
await whenUiSettled(page);
|
||||
const afterEnableHtml = await page.evaluate(() =>
|
||||
(window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html),
|
||||
);
|
||||
@@ -348,7 +337,7 @@ async function main() {
|
||||
// ── (iii) clean-view: credit line STILL visible ───────────────────
|
||||
console.log('\nH10 — clean-view keeps the credit line visible');
|
||||
await page.evaluate(() => document.body.classList.add('ui-clean-view'));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
await whenUiSettled(page);
|
||||
const cleanVis = await creditVisibility();
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') });
|
||||
check(
|
||||
@@ -366,7 +355,7 @@ async function main() {
|
||||
// ── (iv) recording-mode: credit line STILL visible ────────────────
|
||||
console.log('\nH10 — recording-mode keeps the credit line visible');
|
||||
await page.evaluate(() => document.body.classList.add('recording-mode'));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
await whenUiSettled(page);
|
||||
const recVis = await creditVisibility();
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') });
|
||||
check(
|
||||
@@ -377,7 +366,7 @@ async function main() {
|
||||
await page.evaluate(() => document.body.classList.remove('recording-mode'));
|
||||
|
||||
// baseline (normal) screenshot for comparison
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await whenUiSettled(page);
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') });
|
||||
|
||||
check(
|
||||
@@ -391,7 +380,7 @@ async function main() {
|
||||
failedResponses.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
|
||||
console.log('\n────────────────────────────────────────────────────────────');
|
||||
console.log(` RESULT: ${passed} passed, ${failed} failed`);
|
||||
|
||||
@@ -23,6 +23,74 @@ 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)
|
||||
@@ -37,6 +105,8 @@ export function hookRenderErrors(page) {
|
||||
return page.evaluateOnNewDocument(() => {
|
||||
window.__qaRenderErrors = [];
|
||||
const hook = () => {
|
||||
// Cesium-only: its render loop catches errors before pageerror sees them.
|
||||
if (window.__godsEyeView?.engine?.kind === 'navara') return true;
|
||||
const scene = window.__godsEyeView?.viewer?.scene;
|
||||
if (!scene?.renderError) return false;
|
||||
scene.renderError.addEventListener((_, error) =>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
// Cesium-only probes: native DataSource counts and scene-render timing measure the Cesium cable adapter.
|
||||
import { evaluateWithGeo } from './qa/geometry.mjs';
|
||||
import { whenSettled } from './qa/ready.mjs';
|
||||
import { measureIdleRender } from './qa/idle.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
/**
|
||||
* qa-cables-overlay — submarine-cable label path measurement + contract gate.
|
||||
*
|
||||
@@ -8,10 +13,11 @@
|
||||
* 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. entity / native-label / host-entry counts,
|
||||
* c. contract set (ground paths, mover tips, stems) / host-entry counts,
|
||||
* d. parked-idle postRender fires over 5 s (render-governor honesty).
|
||||
* 2. GATE (unified build only): assert the migration contract —
|
||||
* zero native `LabelGraphics` on cable reference entities, host entries
|
||||
* no layer-owned Cesium data source (paths, pins and tips are globe
|
||||
* contract sets), host entries
|
||||
* present for the source, idle stays near-zero, and a clean OFF→ON cycle
|
||||
* (no orphan host entries after disable).
|
||||
*
|
||||
@@ -19,7 +25,6 @@
|
||||
* Requires a running dev server. Headless; rAF throttling disabled so the
|
||||
* frame clock is honest (hidden-pane gotcha).
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
||||
@@ -39,35 +44,26 @@ function report(name, detail) {
|
||||
console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 });
|
||||
await whenSettled(page); // boot flyTo + deferred init
|
||||
|
||||
// Park mid-Atlantic (many cables + both coasts' landings in range) and
|
||||
// disable every layer so the cables layer is measured in isolation.
|
||||
await page.evaluate(async () => {
|
||||
await evaluateWithGeo(page, async (qaGeo) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight();
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
window.__godsEyeView.engine.camera.cancelFlight();
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: qaGeo.cartographic.toCartesian({
|
||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
@@ -76,7 +72,7 @@ try {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } }
|
||||
}
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 6_000)); // tiles + fades settle
|
||||
await whenSettled(page); // tiles + fades settle
|
||||
|
||||
// ── b. toggle-ON → labels visible ─────────────────────────────────────
|
||||
if (!control) {
|
||||
@@ -95,45 +91,58 @@ try {
|
||||
};
|
||||
while (performance.now() < deadline) {
|
||||
if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false };
|
||||
gev.viewer.scene.requestRender?.();
|
||||
window.__godsEyeView.engine.render.request?.();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return { ms: 60_000, timedOut: true };
|
||||
}, LAYER_ID, legacy);
|
||||
report('toggle-ON -> labels visible (ms)', labelLatency);
|
||||
check('labels became visible after enable', labelLatency.timedOut === false, labelLatency);
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle
|
||||
await whenSettled(page); // let load/labels settle
|
||||
}
|
||||
|
||||
// ── c. entity / label / host counts ───────────────────────────────────
|
||||
const counts = await page.evaluate((layerId) => {
|
||||
// ── 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) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const engine = gev.engine;
|
||||
const out = {
|
||||
dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0,
|
||||
referenceEntities: 0, hostEntries: 0, hostPainted: 0,
|
||||
layerDataSources: 0, layerEntities: 0,
|
||||
pathSets: 0, pathItems: 0, markers: 0, tipSets: 0, tips: 0, stems: 0,
|
||||
hostEntries: 0, hostPainted: 0,
|
||||
};
|
||||
for (let i = 0; i < v.dataSources.length; i++) {
|
||||
for (let i = 0; i < (v?.dataSources?.length ?? 0); i++) {
|
||||
const ds = v.dataSources.get(i);
|
||||
if (!/TeleGeography/.test(ds.name || '')) continue;
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}, LAYER_ID, legacy);
|
||||
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
|
||||
|
||||
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
|
||||
@@ -176,21 +185,18 @@ try {
|
||||
: 'scene.render cost, cables ON, 10s orbit', frameCost);
|
||||
|
||||
// ── d. parked idle honesty (labels must not force continuous render) ──
|
||||
await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle
|
||||
const idle = await page.evaluate(() => new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let renders = 0;
|
||||
const remove = scene.postRender.addEventListener(() => { renders += 1; });
|
||||
setTimeout(() => { remove(); resolve({ renders }); }, 5_000);
|
||||
}));
|
||||
await whenSettled(page);
|
||||
const idle = await measureIdleRender(page);
|
||||
report(control
|
||||
? 'parked idle, control (postRender fires / 5s)'
|
||||
: 'parked idle with cables ON (postRender fires / 5s)', idle);
|
||||
|
||||
if (!legacy && !control) {
|
||||
// ── unified-build contract gates ────────────────────────────────────
|
||||
check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels);
|
||||
check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities);
|
||||
// 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('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
|
||||
@@ -208,7 +214,7 @@ try {
|
||||
const cycle = await page.evaluate(async (layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled(layerId, false, { origin: 'user' });
|
||||
gev.viewer.scene.requestRender?.();
|
||||
window.__godsEyeView.engine.render.request?.();
|
||||
await new Promise((r) => setTimeout(r, 1_200));
|
||||
const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
const offEntries = offDiag.entriesBySource?.[layerId] || 0;
|
||||
@@ -220,7 +226,7 @@ try {
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
onPainted = diag.paintedBySource?.[layerId] || 0;
|
||||
if (onPainted > 0) break;
|
||||
gev.viewer.scene.requestRender?.();
|
||||
window.__godsEyeView.engine.render.request?.();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return { offEntries, offPainted, onPainted };
|
||||
@@ -229,7 +235,7 @@ try {
|
||||
check('re-enable restores painted labels', cycle.onPainted > 0, cycle);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.pass).length;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
// Cesium-only probes: manual native renders isolate Cesium cable render cost.
|
||||
import { evaluateWithGeo } from './qa/geometry.mjs';
|
||||
import { whenSettled } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
/**
|
||||
* qa-cables-render-probe — replicates the owner-side perf probe methodology
|
||||
* for the cables layer (relative numbers; SwiftShader when run headless):
|
||||
@@ -11,35 +15,29 @@
|
||||
*
|
||||
* Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216';
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 });
|
||||
await whenSettled(page);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await evaluateWithGeo(page, async (qaGeo) => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.viewer.camera.cancelFlight();
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
window.__godsEyeView.engine.camera.cancelFlight();
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: qaGeo.cartographic.toCartesian({
|
||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
@@ -48,7 +46,7 @@ try {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* probe */ } }
|
||||
}
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
await whenSettled(page);
|
||||
|
||||
const stats = (values) => ({
|
||||
mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2),
|
||||
@@ -60,7 +58,7 @@ try {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled(id, on, { origin: 'user' });
|
||||
}, LAYER_ID, enabled);
|
||||
await new Promise((r) => setTimeout(r, enabled ? 6_000 : 3_000)); // load/teardown + settle
|
||||
await whenSettled(page); // load/teardown + settle
|
||||
|
||||
// 1. PARKED timed renders.
|
||||
const parked = await page.evaluate(() => {
|
||||
@@ -77,18 +75,17 @@ try {
|
||||
// 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders.
|
||||
const cycles = [];
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
const frames = await page.evaluate(async (step) => {
|
||||
const frames = await evaluateWithGeo(page, async (qaGeo, step) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: qaGeo.cartographic.toCartesian({
|
||||
longitude: (-40 + step * 3) * Math.PI / 180,
|
||||
latitude: 35 * Math.PI / 180,
|
||||
height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
window.__godsEyeView.engine.camera.moveEnd.raiseEvent();
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const durations = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
@@ -112,5 +109,5 @@ try {
|
||||
console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`);
|
||||
console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
+15
-20
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
import { evaluateWithGeo } from './qa/geometry.mjs';
|
||||
import { whenSettled } from './qa/ready.mjs';
|
||||
/**
|
||||
* qa-cables-shot — capture the cables layer at two fixed cameras for
|
||||
* before/after visual-identity comparison. Writes to gitignored qa-shots/.
|
||||
* Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -14,24 +16,20 @@ const tag = argv.includes('--tag') ? argv[argv.indexOf('--tag') + 1] : 'shot';
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 });
|
||||
await whenSettled(page);
|
||||
await page.evaluate(async (layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.viewer.camera.cancelFlight();
|
||||
window.__godsEyeView.engine.camera.cancelFlight();
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled && id !== layerId) {
|
||||
try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ }
|
||||
@@ -45,11 +43,9 @@ try {
|
||||
{ name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 },
|
||||
];
|
||||
for (const view of views) {
|
||||
await page.evaluate((v) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const ell = viewer.scene.globe.ellipsoid;
|
||||
viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
await evaluateWithGeo(page, async (qaGeo, v) => {
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: qaGeo.cartographic.toCartesian({
|
||||
longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
@@ -57,15 +53,14 @@ try {
|
||||
}, view);
|
||||
// Let the sweep + labels settle with frames flowing.
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
v.scene.requestRender?.();
|
||||
window.__godsEyeView.engine.render.request?.();
|
||||
if (++ticks < 240) requestAnimationFrame(tick); else resolve();
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
await whenSettled(page);
|
||||
const path = fileURLToPath(
|
||||
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
|
||||
);
|
||||
@@ -73,5 +68,5 @@ try {
|
||||
console.log(`saved ${path}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/** Camera-panel browser acceptance with controlled sources, images and terrain. */
|
||||
import { whenSettled, whenUiSettled } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
const sources = [
|
||||
@@ -31,13 +32,11 @@ const sources = [
|
||||
rangeM: 300,
|
||||
mountHeightM: 8,
|
||||
}));
|
||||
const browser = await puppeteer.launch({
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
@@ -108,10 +107,10 @@ try {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
window.__godsEyeView?.dataManager?.layers?.has('cctv') &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
window.__godsEyeView?.dataManager?.layers?.has('cctv'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await whenSettled(page);
|
||||
await page.$eval('[data-collapse-target="cctv-panel"]', (button) =>
|
||||
button.click(),
|
||||
);
|
||||
@@ -280,6 +279,8 @@ try {
|
||||
.classList.contains('has-frame'),
|
||||
);
|
||||
await page.setCacheEnabled(false);
|
||||
// Keep both options while selecting A flies the view away from B.
|
||||
await page.focus('#cctv-camera-select');
|
||||
delayCameraA = true;
|
||||
await page.select('#cctv-camera-select', 'qa-camera-a');
|
||||
await page.waitForFunction(
|
||||
@@ -357,22 +358,23 @@ try {
|
||||
fs.mkdirSync('qa-shots/camera-controls', { recursive: true });
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' });
|
||||
await page.evaluate(() => {
|
||||
const camera = window.__godsEyeView.viewer.camera;
|
||||
camera.setView({
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: window.__godsEyeView.engine.camera.pose().position,
|
||||
orientation: {
|
||||
heading: camera.heading + Math.PI / 3,
|
||||
heading:
|
||||
window.__godsEyeView.engine.camera.pose().heading + Math.PI / 3,
|
||||
pitch: -0.6,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
await whenUiSettled(page);
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
await whenUiSettled(page);
|
||||
// Phones present CCTV in the Cameras tab's sheet.
|
||||
await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await whenUiSettled(page);
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
|
||||
check(
|
||||
'narrow camera panel remains within the viewport',
|
||||
@@ -411,6 +413,6 @@ try {
|
||||
);
|
||||
} finally {
|
||||
await Promise.all([...delayedFrames].map((respond) => respond()));
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
@@ -0,0 +1,107 @@
|
||||
import '../src/testSupport/registerDom.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { CctvControls } from '../src/ui/cctvControls.js';
|
||||
import { useCctvStore } from '../src/ui/stores/cctv.js';
|
||||
import CctvPanel from '../src/ui/views/panels/CctvPanel.vue';
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./qa-camera-controls.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('the delayed-frame scenario keeps camera B selectable after the flight to A', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CctvPanel).use(pinia);
|
||||
app.mount(host);
|
||||
const select = document.getElementById('cctv-camera-select');
|
||||
const controls = new CctvControls({
|
||||
elements: {
|
||||
_cctvPanel: document.getElementById('cctv-panel'),
|
||||
_cctvSelect: select,
|
||||
},
|
||||
cctv: {},
|
||||
actions: {
|
||||
isEnabled: () => true,
|
||||
setPanelCollapsed() {},
|
||||
syncViewport() {},
|
||||
},
|
||||
store: useCctvStore(pinia),
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
app.unmount();
|
||||
});
|
||||
const [a, b] = ['a', 'b'].map((id) => ({
|
||||
id: `qa-camera-${id}`,
|
||||
name: id,
|
||||
city: 'Austin',
|
||||
}));
|
||||
const render = async (activeCamera, cameraIndex) => {
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
activeCameraId: activeCamera.id,
|
||||
activeCamera,
|
||||
cameraIndex,
|
||||
});
|
||||
await nextTick();
|
||||
};
|
||||
await render(b, [a, b]);
|
||||
const prepare = source.slice(
|
||||
source.indexOf(' await page.setCacheEnabled(false);'),
|
||||
source.indexOf(' delayCameraA = true;'),
|
||||
);
|
||||
await new Function('page', `return (async () => { ${prepare} })();`)({
|
||||
setCacheEnabled: async () => {},
|
||||
focus: async (selector) => document.querySelector(selector).focus(),
|
||||
});
|
||||
await render(a, [a]);
|
||||
assert.ok([...select.options].some((option) => option.value === b.id));
|
||||
select.value = b.id;
|
||||
assert.equal(select.value, b.id);
|
||||
select.blur();
|
||||
await nextTick();
|
||||
assert.ok(
|
||||
![...select.options].some((option) => option.value === b.id),
|
||||
'an unfocused picker drops B once the flight takes it out of view',
|
||||
);
|
||||
});
|
||||
|
||||
test('camera B fixtures respond while camera A frames remain held', async () => {
|
||||
let route;
|
||||
const delayedFrames = new Set();
|
||||
const routing = source.slice(
|
||||
source.indexOf(" page.on('request',"),
|
||||
source.indexOf(' await page.goto('),
|
||||
);
|
||||
new Function('page', 'origin', 'delayCameraA', 'delayedFrames', routing)(
|
||||
{ on: (event, handler) => (route = handler) },
|
||||
'http://localhost:4321',
|
||||
true,
|
||||
delayedFrames,
|
||||
);
|
||||
const replies = [];
|
||||
for (const id of ['a', 'b']) {
|
||||
route({
|
||||
url: () => `http://localhost:4321/api/cctv/frame/qa-camera-${id}?ts=1`,
|
||||
respond: async (response) => replies.push({ id, response }),
|
||||
});
|
||||
}
|
||||
assert.equal(delayedFrames.size, 1);
|
||||
assert.deepEqual(
|
||||
replies.map(({ id }) => id),
|
||||
['b'],
|
||||
);
|
||||
assert.match(replies[0].response.body, /QA CAMERA B/);
|
||||
await Promise.all([...delayedFrames].map((respond) => respond()));
|
||||
assert.deepEqual(
|
||||
replies.map(({ id }) => id),
|
||||
['b', 'a'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { waitForEngine } from './qa-strict-harness.mjs';
|
||||
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 waitForEngine(p, 'navara');
|
||||
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');
|
||||
});
|
||||
+444
-280
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { evaluateWithGeo } from './qa/geometry.mjs';
|
||||
/**
|
||||
* Rendered proof for backdrop-selective callout plates at GROUND LEVEL.
|
||||
*
|
||||
@@ -46,13 +47,14 @@
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth
|
||||
*
|
||||
* Defaults to ANGLE/Metal on macOS and software GL elsewhere. `--swiftshader` selects software GL,
|
||||
* QA_ANGLE selects the renderer (Metal by default); use swiftshader for software GL,
|
||||
* which is deterministic but is NOT real-GPU evidence; the banner says which
|
||||
* backend actually ran and the run records it alongside the results.
|
||||
*/
|
||||
import { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback) => {
|
||||
@@ -64,7 +66,7 @@ const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const TAG = getOpt('--tag', 'after');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const TEETH = argv.includes('--teeth');
|
||||
const SWIFTSHADER = argv.includes('--swiftshader') || process.platform !== 'darwin';
|
||||
const SWIFTSHADER = /swiftshader|software/i.test(process.env.QA_ANGLE || 'metal');
|
||||
const SHOT_DIR = path.resolve('qa-shots/cockpitplates');
|
||||
|
||||
/** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */
|
||||
@@ -134,14 +136,6 @@ const POSES = [
|
||||
},
|
||||
];
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome:
|
||||
// its software-GL behavior shifts across majors and has produced false
|
||||
// negatives in this repo's harnesses before.
|
||||
await puppeteer.executablePath().catch(() => null),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean);
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
@@ -213,25 +207,9 @@ async function main() {
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = CHROME_CANDIDATES.find((candidate) => {
|
||||
try { return fs.existsSync(candidate); } catch { return false; }
|
||||
});
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
// Metal is a macOS-only ANGLE backend; off-Mac the GPU branch has to fall
|
||||
// back to the software rasterizer or WebGL init fails outright.
|
||||
...(SWIFTSHADER
|
||||
? ['--use-gl=angle', '--use-angle=swiftshader']
|
||||
: ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', ...(SWIFTSHADER ? [] : ['--enable-gpu', '--ignore-gpu-blocklist']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1280,800'],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
@@ -276,10 +254,7 @@ async function main() {
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
await whenSettled(page);
|
||||
|
||||
renderer = await page.evaluate(() => {
|
||||
const gl = document.createElement('canvas').getContext('webgl2')
|
||||
@@ -302,10 +277,10 @@ async function main() {
|
||||
|
||||
// Inject the field. The layers keep their real pipelines; only the source
|
||||
// of observations is replaced, exactly as scripts/qa-labels.mjs does.
|
||||
await page.evaluate((payload) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
await evaluateWithGeo(page, async (qaGeo, payload) => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
window.__godsEyeView.engine.camera.cancelFlight();
|
||||
const Cartesian3 = qaGeo.cartesian3;
|
||||
window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({
|
||||
sourceId: contact.id.toLowerCase(),
|
||||
id: contact.id,
|
||||
@@ -330,17 +305,16 @@ async function main() {
|
||||
|
||||
for (const pose of POSES) {
|
||||
console.log(`\n — pose "${pose.name}" (${pose.note})`);
|
||||
await page.evaluate((payload) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
viewer.camera.setView({
|
||||
await evaluateWithGeo(page, async (qaGeo, payload) => {
|
||||
const Cartesian3 = qaGeo.cartesian3;
|
||||
window.__godsEyeView.engine.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height),
|
||||
orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 },
|
||||
});
|
||||
}, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg });
|
||||
|
||||
// Let the tiles settle and the label solve run before the tap window.
|
||||
await new Promise((resolve) => setTimeout(resolve, 6_000));
|
||||
await whenTilesLoaded(page);
|
||||
await page.evaluate(() => {
|
||||
window.__PLATE_TAP.events = [];
|
||||
window.__PLATE_TAP.on = true;
|
||||
@@ -356,10 +330,9 @@ async function main() {
|
||||
// OUTPUT has to ask for its own renders. Production is unaffected: real
|
||||
// Cockpit holds, and a parked map keeps showing the last correct frame
|
||||
// precisely because nothing changed.
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const pump = () => {
|
||||
if (!window.__PLATE_TAP.on) return;
|
||||
scene.requestRender();
|
||||
window.__godsEyeView.engine.render.request();
|
||||
requestAnimationFrame(pump);
|
||||
};
|
||||
pump();
|
||||
@@ -522,7 +495,7 @@ async function main() {
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
// Cesium-only probes: tracked-entity and local-frame coordinates verify the native cockpit handoff.
|
||||
/** Focused rendered proof for adaptive Cockpit Display/Radio layout. */
|
||||
import { whenSettled, whenUiSettled } from './qa/ready.mjs';
|
||||
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||
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)), '..');
|
||||
@@ -12,24 +15,12 @@ const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const headful = process.argv.includes('--headful');
|
||||
fs.mkdirSync(shotsDir, { recursive: true });
|
||||
|
||||
const chromeCandidates = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
await puppeteer.executablePath().catch(() => null),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean);
|
||||
const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
const browser = await puppeteer.launch({
|
||||
headless: headful ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
|
||||
args: [
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--no-sandbox',
|
||||
],
|
||||
const browser = await launchBrowser({
|
||||
headless: true,
|
||||
args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await blockPaidQaMaps(page);
|
||||
await stubPlanespotters(page);
|
||||
const failures = [];
|
||||
const consoleErrors = [];
|
||||
@@ -169,10 +160,7 @@ try {
|
||||
page.on('request', routeQaRequest);
|
||||
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await whenSettled(page);
|
||||
await page.waitForFunction(() => (
|
||||
typeof window.__gevQaRegisterLayer === 'function'
|
||||
&& typeof window.__gevQaUnregisterLayer === 'function'
|
||||
@@ -673,6 +661,7 @@ try {
|
||||
() => Boolean(window.__godsEyeView.viewer.trackedEntity?.position),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await whenSettled(page);
|
||||
const preselectedContactAdoption = await page.evaluate(async () => {
|
||||
const { styleManager, dataManager, viewer } = window.__godsEyeView;
|
||||
const flights = dataManager.layers.get('flights')?.module;
|
||||
@@ -765,7 +754,7 @@ try {
|
||||
} : null,
|
||||
navigation: snapshot?.navigation || null,
|
||||
afterId: trackedInfo?.icao24 || null,
|
||||
viewerTrackedId: viewer.trackedEntity?.gevTrackedId || null,
|
||||
viewerTrackedId: window.__godsEyeView.engine.camera.following()?.label?.id || null,
|
||||
contextVisible: !document.getElementById('military-awareness-panel')?.hidden,
|
||||
entryAvailableAfterSettlement: Boolean(entry && !entry.hidden),
|
||||
blockerStillRegistered: dataManager.layers.has(blockerId),
|
||||
@@ -830,6 +819,7 @@ try {
|
||||
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);
|
||||
@@ -851,8 +841,8 @@ try {
|
||||
const readinessUrl = new URL(appUrl);
|
||||
readinessUrl.searchParams.set('welcome', '0');
|
||||
await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' });
|
||||
await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager
|
||||
&& document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 });
|
||||
await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60000 });
|
||||
await whenSettled(readinessPage);
|
||||
await readinessPage.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||
@@ -1097,7 +1087,7 @@ try {
|
||||
const awareness = dataManager.layers.get('military-awareness')?.module;
|
||||
const before = awareness?.getContextSnapshot?.()?.subject || null;
|
||||
const entityBefore = viewer.trackedEntity;
|
||||
viewer.camera.zoomOut(1_500_000);
|
||||
window.__godsEyeView.engine.camera.zoomOut(1_500_000);
|
||||
const cameraRange = () => Math.hypot(
|
||||
viewer.camera.position.x,
|
||||
viewer.camera.position.y,
|
||||
@@ -1153,6 +1143,14 @@ 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;
|
||||
@@ -1454,7 +1452,7 @@ try {
|
||||
remove();
|
||||
reject(new Error('Cockpit camera ownership check did not render a frame'));
|
||||
}, 5000);
|
||||
const remove = viewer.scene.postRender.addEventListener(() => {
|
||||
const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
@@ -1462,10 +1460,10 @@ try {
|
||||
});
|
||||
// Let revoked listeners retire before comparing ownership across exit.
|
||||
await nextFrame();
|
||||
const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners;
|
||||
const listenersBeforeExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners;
|
||||
const exited = styleManager.cockpitView.exit() === true;
|
||||
await nextFrame();
|
||||
const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners;
|
||||
const listenersAfterExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners;
|
||||
const refocused = awareness?.focusCurrent?.() === true;
|
||||
await nextFrame();
|
||||
return {
|
||||
@@ -1474,7 +1472,7 @@ try {
|
||||
sameEntity: viewer.trackedEntity === entity,
|
||||
listenersBeforeExit,
|
||||
listenersAfterExit,
|
||||
listenersAfterFocus: viewer.scene.preUpdate.numberOfListeners,
|
||||
listenersAfterFocus: window.__godsEyeView.engine.frames.preUpdate.numberOfListeners,
|
||||
};
|
||||
});
|
||||
check(
|
||||
@@ -1514,6 +1512,9 @@ 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)));
|
||||
@@ -1534,6 +1535,7 @@ try {
|
||||
before,
|
||||
during,
|
||||
after: read(),
|
||||
startsClear,
|
||||
exited,
|
||||
clearancePx,
|
||||
contactAutoCollapsed,
|
||||
@@ -1543,7 +1545,7 @@ try {
|
||||
});
|
||||
check(
|
||||
'Cockpit temporarily collapses standard panels and restores the exact map layout on exit',
|
||||
cockpitPanelRoundTrip.exited
|
||||
cockpitPanelRoundTrip.exited && cockpitPanelRoundTrip.startsClear
|
||||
&& Object.values(cockpitPanelRoundTrip.during).every(Boolean)
|
||||
&& JSON.stringify(cockpitPanelRoundTrip.after)
|
||||
=== JSON.stringify(cockpitPanelRoundTrip.before)
|
||||
@@ -1608,6 +1610,7 @@ try {
|
||||
const states = [read()];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
current.click();
|
||||
await Promise.resolve();
|
||||
states.push(read());
|
||||
}
|
||||
const originalStyle = manager.activeStyle;
|
||||
@@ -1619,8 +1622,10 @@ 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();
|
||||
@@ -1695,6 +1700,9 @@ 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');
|
||||
@@ -1880,7 +1888,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 = (wantTop) => {
|
||||
const moveSignalTopTo = async (wantTop) => {
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const rect = signal.getBoundingClientRect();
|
||||
const delta = wantTop - rect.top;
|
||||
@@ -1893,9 +1901,11 @@ 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,
|
||||
@@ -1906,6 +1916,7 @@ 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;
|
||||
@@ -1930,11 +1941,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 = moveSignalTopTo(exactSignalTop + 80);
|
||||
const roomySeed = await moveSignalTopTo(exactSignalTop + 80);
|
||||
const roomySeedExpanded = !utility.classList.contains('layout-primary-only')
|
||||
&& roomySeed.firstPassPrimaryOnly === false;
|
||||
const exact = {
|
||||
...moveSignalTopTo(exactSignalTop),
|
||||
...await moveSignalTopTo(exactSignalTop),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||
@@ -1944,7 +1955,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.
|
||||
...moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
|
||||
...await moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingDisplay: getComputedStyle(radioControl).display,
|
||||
@@ -1955,7 +1966,7 @@ try {
|
||||
<= signal.getBoundingClientRect().top - 7,
|
||||
};
|
||||
const restored = {
|
||||
...moveSignalTopTo(exactSignalTop + 80),
|
||||
...await moveSignalTopTo(exactSignalTop + 80),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||
@@ -2064,7 +2075,7 @@ try {
|
||||
JSON.stringify(cyberStyleStates));
|
||||
for (const height of [987, 720, 640, 900]) {
|
||||
await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await whenUiSettled(page);
|
||||
const geometry = await page.evaluate(() => {
|
||||
const signal = document.getElementById('cockpit-signal-stream');
|
||||
const utility = document.getElementById('cockpit-utility-controls');
|
||||
@@ -2355,7 +2366,7 @@ try {
|
||||
await page.waitForFunction(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
if (!viewer) return false;
|
||||
const height = viewer.camera.positionCartographic?.height;
|
||||
const height = window.__godsEyeView.engine.camera.pose().cartographic?.height;
|
||||
return Math.abs(height - 18_000_000) < 150_000;
|
||||
}, { timeout: 6_000 });
|
||||
const resetState = await page.evaluate(() => {
|
||||
@@ -2363,7 +2374,7 @@ try {
|
||||
const qa = window.__qaCockpitReset;
|
||||
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
|
||||
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
|
||||
const height = gev.viewer.camera.positionCartographic?.height;
|
||||
const height = window.__godsEyeView.engine.camera.pose().cartographic?.height;
|
||||
gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
|
||||
delete window.__qaCockpitReset;
|
||||
return {
|
||||
@@ -2416,7 +2427,7 @@ try {
|
||||
else hud.style.removeProperty('--cockpit-utility-top');
|
||||
if (!prior.cockpit) document.body.classList.remove('cockpit-mode');
|
||||
}).catch(() => {});
|
||||
await browser.close();
|
||||
await closeBrowser(browser);
|
||||
}
|
||||
|
||||
console.log(`RESULT: ${failures.length ? 'NOT_READY' : 'READY'} (${failures.length} failures)`);
|
||||
|
||||
Loaded 100 of 1723 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user