mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +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
@@ -59,6 +59,10 @@ jobs:
|
|||||||
- name: Build production bundle
|
- name: Build production bundle
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Main chunk size budget
|
||||||
|
if: matrix.node == '24.22.2'
|
||||||
|
run: npm run check:main-chunk
|
||||||
|
|
||||||
street-level-browser:
|
street-level-browser:
|
||||||
# Hermetic: every Mapillary request is answered from fixtures, so the
|
# Hermetic: every Mapillary request is answered from fixtures, so the
|
||||||
# dummy token below never leaves the page.
|
# dummy token below never leaves the page.
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,7 @@ God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the
|
|||||||
How to read this:
|
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.
|
- **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.
|
- **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.
|
- **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) |
|
| **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" |
|
| **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) |
|
| **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) |
|
| **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" |
|
| **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" |
|
| **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" |
|
||||||
|
|||||||
@@ -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."_
|
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
|
||||||
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
|
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
|
||||||
|
|
||||||
|
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.
|
**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.
|
- **🎬 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.
|
- **🔒 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:
|
**🎥 Direct it** — drone-operator camera verbs:
|
||||||
|
|
||||||
|
|||||||
@@ -132,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.
|
- **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`.
|
- **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.
|
- **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
|
## Scope & expectations
|
||||||
|
|
||||||
|
|||||||
+22
-2
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
The project depends on third-party npm packages whose own licenses apply. This
|
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
|
file records the packages added for the browser-local SDR feature and the
|
||||||
Street Level viewer; the complete resolved dependency inventory remains in
|
Street Level viewer, and the CesiumJS code ported into `src/geo/`; the complete
|
||||||
`package-lock.json`.
|
resolved dependency inventory remains in `package-lock.json`.
|
||||||
|
|
||||||
## Web RTL-SDR
|
## Web RTL-SDR
|
||||||
|
|
||||||
@@ -45,3 +45,23 @@ Street Level viewer; the complete resolved dependency inventory remains in
|
|||||||
- Source: <https://github.com/mrdoob/three.js>
|
- Source: <https://github.com/mrdoob/three.js>
|
||||||
- License: MIT
|
- License: MIT
|
||||||
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
|
- 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.
|
* 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
|
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
|
||||||
// two in step.
|
// two in step.
|
||||||
export const PANEL_BASE = '/panel/';
|
export const PANEL_BASE = '/panel/';
|
||||||
@@ -18,6 +21,27 @@ export function panelBuildConfig(config) {
|
|||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
base: PANEL_BASE,
|
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: {
|
build: {
|
||||||
...config.build,
|
...config.build,
|
||||||
outDir: PANEL_OUT_DIR,
|
outDir: PANEL_OUT_DIR,
|
||||||
@@ -26,6 +50,10 @@ export function panelBuildConfig(config) {
|
|||||||
cssCodeSplit: false,
|
cssCodeSplit: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
...config.build?.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 },
|
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 { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||||
import { embedFramingPlugin } from './embed-framing.js';
|
import { embedFramingPlugin } from './embed-framing.js';
|
||||||
import { panelBuildPlugin } from './panel.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
|
* The repository root (this file lives at `<root>/build/vite.js`), as a
|
||||||
@@ -183,7 +186,9 @@ export function createBrowserViteConfig({
|
|||||||
apiResponseHeadersPlugin(),
|
apiResponseHeadersPlugin(),
|
||||||
// Next, so no provider route answers a Host the server does not allow.
|
// Next, so no provider route answers a Host the server does not allow.
|
||||||
hostCheckPlugin(),
|
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.
|
// Application views are Vue single-file components.
|
||||||
vue(VUE_PLUGIN_OPTIONS),
|
vue(VUE_PLUGIN_OPTIONS),
|
||||||
...plugins,
|
...plugins,
|
||||||
@@ -194,6 +199,20 @@ export function createBrowserViteConfig({
|
|||||||
// A production build must not clean the dependency cache a running dev
|
// A production build must not clean the dependency cache a running dev
|
||||||
// server is still serving optimized module URLs from.
|
// server is still serving optimized module URLs from.
|
||||||
...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}),
|
...(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: {
|
optimizeDeps: {
|
||||||
// First reached through the SDR worker or a dynamic import. Pre-bundle
|
// First reached through the SDR worker or a dynamic import. Pre-bundle
|
||||||
// them at startup so first use cannot invalidate already-transformed
|
// 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.
|
// Loaded on demand when the first street-level image opens.
|
||||||
'mapillary-js',
|
'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: {
|
server: {
|
||||||
host: host || 'localhost',
|
host: host || 'localhost',
|
||||||
@@ -258,6 +289,23 @@ export function createBrowserViteConfig({
|
|||||||
mapillaryToken ?? '',
|
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).
|
|
||||||
@@ -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,6 +0,0 @@
|
|||||||
- When the globe stops drawing after a render error or a lost WebGL context,
|
|
||||||
an alert dialog says so instead of leaving a frozen or black globe behind
|
|
||||||
working controls. Retry restarts drawing (Reload after a lost context), and
|
|
||||||
Reset view reopens the app without the view restored from the address bar.
|
|
||||||
Cesium's "Rendering has stopped" panel no longer appears in the app
|
|
||||||
(German, @gekh, #1579).
|
|
||||||
@@ -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
|
`ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested
|
||||||
size, which the layer draws once and closes. Without them the layers read the
|
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`).
|
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
|
`application/services` accepts boundary, terrain, regional-context, weather and
|
||||||
summary services; its weather service supplies cockpit local conditions, not the
|
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.
|
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
|
## Current component ownership
|
||||||
|
|
||||||
Package imports use `gods-eye-view`; `package.json` is the authoritative
|
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/standalone/` | Select the default catalog, local sources and setup controls |
|
||||||
| `src/ui/` | Navigation generations, restoration, visual state, panel snapshots and their subscriptions |
|
| `src/ui/` | Navigation generations, restoration, visual state, panel snapshots and their subscriptions |
|
||||||
| `src/data/` | Lifecycle, context and feed state; legacy default-layer facades |
|
| `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/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/services/` | Supplied application operations; scene construction owns caches and cancellation |
|
||||||
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
|
| `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 |
|
| `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
|
2. `scripts/check-package-boundaries.mjs` builds every declared export without
|
||||||
app Vite configuration or environment files. `scripts/package-boundaries.json`
|
app Vite configuration or environment files. `scripts/package-boundaries.json`
|
||||||
assigns each export exactly once and lists its owned modules and external
|
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.
|
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
|
Portable source graphs cannot reach application/rendering, Node, Cesium or
|
||||||
browser globals. This includes `sources/*`, dedicated `layers/*/source` exports,
|
browser globals. This includes `sources/*`, dedicated `layers/*/source` exports,
|
||||||
flight/military/vessel record and ingestion exports, action schemas, the session
|
flight/military/vessel record and ingestion exports, action schemas, the session
|
||||||
interface, lifecycle and feed state. The browser-global rule reserves platform
|
interface, lifecycle and feed state, and every runtime file in `src/geo/` (the
|
||||||
names such as `document` and `window` in these modules; it is an architectural
|
engine-neutral geometry ported from Cesium). The browser-global rule reserves
|
||||||
check, not a JavaScript sandbox. Common voice controls cannot depend on a
|
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,
|
Realtime protocol implementation. Negative fixtures cover indirect helpers,
|
||||||
self-package imports, symlinks and unreachable files.
|
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
|
Source-only factories use dedicated `layers/<family>/source` exports where
|
||||||
available, preserving the same factory implementation without loading rendering.
|
available, preserving the same factory implementation without loading rendering.
|
||||||
Record normalization and endpoint policies have plain owners separate from
|
Record normalization and endpoint policies have plain owners separate from
|
||||||
@@ -391,10 +456,12 @@ the caller.
|
|||||||
## Visual effects
|
## Visual effects
|
||||||
|
|
||||||
`ui/effects` exports the effects controller and existing preset definitions.
|
`ui/effects` exports the effects controller and existing preset definitions.
|
||||||
It owns shader stages and their clock, with explicit render ownership callbacks.
|
It owns transitions and their clock, with explicit render ownership callbacks,
|
||||||
Construction installs no stages or frame callbacks. Stop animation before
|
and writes styles, bloom and sharpening through `engine.render`. Construction
|
||||||
releasing UI consumers, then destroy to remove owned stages and restore the
|
installs no stages or frame callbacks. Stop animation before releasing UI
|
||||||
borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers
|
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
|
without loading the renderer. UI presentation and product-action coordination
|
||||||
remain in their callers.
|
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/controller` coordinates scene changes and lifetimes. `maps/imagery`,
|
||||||
`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the
|
`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the
|
||||||
standard sources and their setup/fallback policy. The standalone facade wires
|
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`.
|
A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`.
|
||||||
Each source has a user-facing `descriptor`, availability/reason, and either an
|
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
|
`src/standalone/layerSources.js`. The construction export has its own checked
|
||||||
dependency graph, excluding standalone setup and compatibility layer instances.
|
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
|
Both aircraft layers share the catalog's classification registry; launches use
|
||||||
its satellites and Contacts uses its aircraft, vessels and installations. Data
|
its satellites and Contacts uses its aircraft, vessels and installations. Data
|
||||||
registration, controls and voice actions read those same instances. Destruction
|
registration, controls and voice actions read those same instances. Destruction
|
||||||
remains the manager's responsibility; classification also observes application
|
remains the manager's responsibility; classification also observes application
|
||||||
abort when startup has not reached registration. Scene engines remain page-owned,
|
abort when startup has not reached registration. The session retains models while
|
||||||
so this change does not introduce multiple simultaneous viewers.
|
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
|
Direct `src/data` compatibility entries retain their old defaults and testing
|
||||||
exports. Browser regression probes use the registered instance's testing surface
|
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
|
`data/manager` is the compatibility facade for direct callers. The ordinary
|
||||||
state subscriptions retain their existing event contract.
|
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 state owners
|
||||||
|
|
||||||
- `ui/navigation` owns navigation generations, pending search presentation and
|
- `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
|
frame/source and calibration rules and their data attributes, while Weather
|
||||||
owns coverage labels. The rail card keeps node identity, header focus,
|
owns coverage labels. The rail card keeps node identity, header focus,
|
||||||
disclosure and teardown, and does not announce every card update.
|
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.
|
||||||
+368
-226
@@ -167,6 +167,305 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
|
|||||||
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
|
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
|
||||||
implementations.
|
implementations.
|
||||||
|
|
||||||
|
## Navara globe engine — November 5, 2026
|
||||||
|
|
||||||
|
Navara, an experimental three.js/WASM globe (`@navaramap/three` 0.2.1), runs
|
||||||
|
next to Cesium. It is picked under VISUAL PRESETS → Globe Engine or with
|
||||||
|
`?engine=navara`; Cesium stays the default. Switching is live: the page keeps
|
||||||
|
one application, and only the globe-bound parts move to the next engine.
|
||||||
|
|
||||||
|
- **Two Navara choices** (`src/app/engineChoice.js`). **Navara** draws at CSS
|
||||||
|
pixels without MSAA, as Cesium does; SMAA still smooths edges. **Navara HD**
|
||||||
|
(`?engine=navara-hd`) draws at the device pixel ratio with MSAA 4. Both are
|
||||||
|
the `navara` engine to the layers; a pick between them builds the other
|
||||||
|
variant cold and releases the first, so only one Navara holds memory.
|
||||||
|
Measured in headless Chrome (Dubai, 1280×800): Navara 1.6 GiB at DPR 1 and
|
||||||
|
1.8 GiB at DPR 2, Navara HD 1.8 and 2.7 GiB, Cesium 0.93 and 1.08 GiB
|
||||||
|
(Navara used 1.95 and 3.0 GiB before).
|
||||||
|
- **Choosing the engine.** A choice read from storage (no `?engine=`) that
|
||||||
|
fails to start opens Cesium instead, stores Cesium in `gev.engine` and the
|
||||||
|
address, and says so in a toast ("Globe engine failed to start. Running
|
||||||
|
Cesium: …") once the shared view is restored; the toast stays 6 s. An
|
||||||
|
explicit `?engine=` that fails keeps the loading screen with the error
|
||||||
|
naming the engine ("Could not start Navara: …") and an engine picker
|
||||||
|
labelled "Choose an engine to try again.", focused on its first choice, so
|
||||||
|
another choice retries without a reload; a retry that starts keeps the
|
||||||
|
loading screen until its view is restored. The loading screen lifts only
|
||||||
|
after the start-up share-link restore (the camera has landed) and at least
|
||||||
|
1 s. Back and Forward to an address without `?engine=` use the engine
|
||||||
|
the page started with, not a `gev.engine` another tab has changed since.
|
||||||
|
A switch that fails and leaves the previous engine running names that
|
||||||
|
engine in its toast. The Globe Engine picker is a compact field that keeps
|
||||||
|
keyboard focus while a switch runs (`aria-disabled`, `aria-busy`); a
|
||||||
|
hidden status region announces "Initializing systems..." and the new
|
||||||
|
engine.
|
||||||
|
- **Memory.** The canvas context has no antialias (Navara draws into its own
|
||||||
|
targets and copies the result), the cloud shadow maps use the low preset's
|
||||||
|
256² and 2 cascades, and the tile cache budget is the screen-sized targets
|
||||||
|
plus 768 MiB instead of Navara's default of up to 2 GiB, which a long session
|
||||||
|
filled. A released Navara keeps no JS: catalog closures capture the
|
||||||
|
operations, not the scene; a released globe aborts with a reason made at
|
||||||
|
module load (a fresh one keeps the stack, and with it the scene); and
|
||||||
|
`Pass.fullscreenGeometry` from postprocessing is disposed after the view,
|
||||||
|
which otherwise pins the canvas through a three listener.
|
||||||
|
|
||||||
|
- **The engine contract** is `src/globe/contract.js`; its drawing primitives
|
||||||
|
(movers, lines, ground, imagery, meshes), scene services and how to add an
|
||||||
|
engine are in [Globe primitives](GLOBE-PRIMITIVES.md). `src/globe/cesium/*`
|
||||||
|
and `src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js`
|
||||||
|
stands in for tests, and `src/globe/testing/contractSuite.mjs` holds the
|
||||||
|
checks every engine must pass. `npm test` runs them on the fake engine;
|
||||||
|
`node scripts/qa-contract-suite.mjs <origin>` runs them in headless Chrome
|
||||||
|
on the real Cesium and Navara of a keyless dev server, with a real pointer
|
||||||
|
and real canvases (`--only=ground:` and `--only=imagery:` check ground and
|
||||||
|
imagery pixels); checks of state the app drives itself (contact sonar) are
|
||||||
|
skipped and named. Among its members: `surface.sampleTerrainMostDetailed`
|
||||||
|
(terrain heights at the finest level, fetched where not loaded),
|
||||||
|
`maps.terrainRevision()` (changes when the terrain shown is replaced),
|
||||||
|
`render.maxTextureSize()`, `MoverSet.failed()` (a model or atlas that
|
||||||
|
will not load) and `camera.completeFlight()` (a running flight lands at
|
||||||
|
once, as Cesium's). Navara also has Cesium's `contactSonarDiagnostics()`,
|
||||||
|
outside the contract.
|
||||||
|
- **Navara's ground and imagery passes.** Navara draws ground fills and
|
||||||
|
corridors in one screen pass classified on the scene depth
|
||||||
|
(`src/globe/navara/ground/`): a corridor set is tiles of binned segments
|
||||||
|
(`corridors.js`), and 10,000 corridors draw in about 12 ms still and 14 ms
|
||||||
|
moving, where Cesium takes 23 ms (the chunks before took 151 and 196 ms).
|
||||||
|
Imagery layers draw in one screen pass after tone mapping, classified on
|
||||||
|
the scene depth (`src/globe/navara/imagery/`); the Active Fires heatmap is
|
||||||
|
mipmapped at orbit scale. Sea Marks and Ski Pistes keep Navara's native
|
||||||
|
raster path for tile detail and use `engine.imagery` on engines without
|
||||||
|
`maps.addOverlay`.
|
||||||
|
- **Ground and input parity.** Space Missions draws its 500 m launch-pad
|
||||||
|
zone with the radial ground material: faint fill, bright soft rim and
|
||||||
|
emission, classified on terrain and tiles. Transit draws its age-banded
|
||||||
|
trail and dark backing as ground corridors. The active segment uses a
|
||||||
|
shader reveal, retaining its geometry between segment crossings. Navara
|
||||||
|
composes both in the ground pass after tone mapping. Street Level's 9 px
|
||||||
|
pick window and CCTV's custom window and drill limit go through input
|
||||||
|
subscriptions. Street Level's lowered horizon ellipsoid is a projector
|
||||||
|
option on every engine; default callers retain the WGS84 horizon.
|
||||||
|
- **Shared follow and Draw.** Share links read the follow target, identity and
|
||||||
|
camera pose through the engine; their saved offset uses `camera.follow`'s
|
||||||
|
`swingTo` option on both engines. Cesium keeps its guarded 60-degree frame
|
||||||
|
swing. Draw uses one contract surface with lines, point movers and an
|
||||||
|
exclusive `engine.input` pointer claim. Cesium's stock select/track actions
|
||||||
|
are suspended by the engine while drawing and restored on release. Scene
|
||||||
|
detach ends the sketch and removes the preview, retaining shape and colour.
|
||||||
|
- **Engine-owned effects and camera input.** Cesium's viewer, atmosphere
|
||||||
|
compatibility, zoom guard, starry sky and map implementation live in
|
||||||
|
`src/globe/cesium/`. Styles, bloom and contact sonar use `engine.render`.
|
||||||
|
Camera input suspension is counted on every engine; the last resume restores
|
||||||
|
the state before the first suspension. Cockpit, imagery-box drawing, CCTV
|
||||||
|
calibration and infrastructure flights use that shared ownership API.
|
||||||
|
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
|
||||||
|
engine-specific code outside the engines: renderer lines per engine, Cesium
|
||||||
|
imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara
|
||||||
|
ground passes, Cesium SDK loads and `ScreenSpaceEventHandler` users.
|
||||||
|
`scripts/engineCoupling.test.mjs` (in `npm test`) fails when a count grows
|
||||||
|
past `scripts/engine-coupling-baseline.json`.
|
||||||
|
- **Display rehearsal.** `src/app/layers/displayRehearsal.test.mjs` loads all
|
||||||
|
35 display entries from the manifest on a fake engine with an unknown kind
|
||||||
|
and no native extensions, draws fixtures, hides or releases them, and checks
|
||||||
|
primitives, subscriptions and service records before engine destruction.
|
||||||
|
The three self-drawing entries are named skips. This is a display-level
|
||||||
|
rehearsal; application kind registration and native debug access remain
|
||||||
|
work.
|
||||||
|
- **Scene transition ownership.** `src/app/sceneHost.js` serializes engine
|
||||||
|
requests, application attachment and direct shell calls. One transaction
|
||||||
|
covers handoff, renderer release, candidate acquisition, attachment, view
|
||||||
|
restoration and the single recovery attempt. Nested parts receive that
|
||||||
|
transaction explicitly; unrelated calls wait for it. Picker/history ordering,
|
||||||
|
hidden-tab settling and parked-renderer reuse retain their existing behavior.
|
||||||
|
A replacement application registers fresh parts on the same transition owner;
|
||||||
|
a shell used alone creates its own scene host.
|
||||||
|
- **Scene services per attachment.** Each attachment owns its picking,
|
||||||
|
selection, render demand, world overlay, tracked readout and context
|
||||||
|
services (`src/data/sceneServices.js`; described under Input ownership,
|
||||||
|
Render demand and World overlays in [Globe primitives](GLOBE-PRIMITIVES.md)).
|
||||||
|
On a switch, detach releases them before the renderer is parked and a
|
||||||
|
reattachment, parked engines included, creates fresh ones; retained layers
|
||||||
|
register their picks again and resolve their attached engine, an old
|
||||||
|
service cannot release a replacement's render hold, and destroyed services
|
||||||
|
stay inert. The shell reconnects its overlay action subscription; shared
|
||||||
|
font measurements live until the last overlay host detaches. Engine handoff
|
||||||
|
carries the retained page selection into the incoming context, and readout
|
||||||
|
initialization leaves it intact while the outgoing display is hidden and
|
||||||
|
the incoming layer waits for camera restoration. Label text, live visual
|
||||||
|
anchors and follow-camera anti-jitter are unchanged.
|
||||||
|
- **The session owns the state.** Catalog, layer models, sources, selection,
|
||||||
|
camera mode and URL state outlive a switch. A picker switch hands the
|
||||||
|
outgoing camera's exact pose to the incoming one (the address rounds it to
|
||||||
|
1e-4°, whole metres and whole degrees); a pick made during a camera flight
|
||||||
|
first lands it (`camera.completeFlight`, Cesium's completeFlight), so the
|
||||||
|
incoming engine stands at its destination. A history visit takes the
|
||||||
|
entry's view. A layer that is a session model
|
||||||
|
gets `attachRenderer(viewer, { engine })` / `detachRenderer()` instead of being rebuilt,
|
||||||
|
so its data is not fetched again. The outgoing globe is parked
|
||||||
|
(`src/standalone/rendererCache.js`), so switching back within three minutes
|
||||||
|
is warm (60-90 ms). After that the parked globe is disposed and its WebGL
|
||||||
|
context lost, which frees about 1.1 GiB for a parked Navara; switching back
|
||||||
|
then builds it cold (1.5-2 s). `?parkedMs=` shortens the delay for QA
|
||||||
|
(`scripts/qa-strict-parked-release.mjs`).
|
||||||
|
- **Navara camera and drawing parity.** A render requested during a frame
|
||||||
|
draws the next frame, so the wind animates under a still camera. A flight
|
||||||
|
turns camera input off until it ends; a gesture no longer interrupts it.
|
||||||
|
`setView` cancels a running flight on both engines. Heading and roll
|
||||||
|
changes raise `camera.changed`, as on Cesium. 3D models take the Cyber
|
||||||
|
sonar, which draws at its cadence only while contacts are on screen. Ground
|
||||||
|
items are picked at the shown 3D tiles under Google 3D, else at the
|
||||||
|
terrain, by a CPU ray (Navara's depth read answers with the near plane
|
||||||
|
right after a click). Ground corridor and outline widths are CSS pixels at any device
|
||||||
|
pixel ratio (Navara HD). Tiled imagery draws across the antimeridian and
|
||||||
|
replans its tiles on a resize. A follow takes a two-finger pinch. Navara
|
||||||
|
does not yet refresh an ion terrain token on a 401, nor fall back to
|
||||||
|
OpenStreetMap when base imagery tiles fail after the source was built
|
||||||
|
(Cesium does both); its package exposes no hook for either.
|
||||||
|
- **Layer heights and recovery after a switch.** S2 GhostMaps icons are
|
||||||
|
sprite movers without depth test that stand on the surface: the terrain on
|
||||||
|
the globe, a sample of the 3D tiles under Google 3D. Only icons on screen
|
||||||
|
are measured, at most 16 and 4 ms a pass, again after the camera moves and
|
||||||
|
tiles load; a height that holds is not written again, so a parked camera
|
||||||
|
asks for no frames. Submarine cable landing markers stand on the surface by
|
||||||
|
the same rule and draw over the scene within 2.5 km of the camera. OSM
|
||||||
|
Places (2 m over the surface) and Street Level's cones and position marker
|
||||||
|
follow the same rule; a point not measured yet is screen-tested on the
|
||||||
|
loaded terrain (under Google 3D on Cesium, whose hidden globe loads none,
|
||||||
|
at the ground under the camera), and a Street Level sprite waits on the
|
||||||
|
loaded terrain until it is measured. Switching Google 3D on or off measures
|
||||||
|
these points again. Directions' A/B markers move again once tiles load,
|
||||||
|
under Google 3D too. Traffic's road heights are
|
||||||
|
sampled again when the terrain changes (`maps.terrainRevision`), and cars
|
||||||
|
on roads below the ellipsoid are not culled by the WGS84 horizon. The
|
||||||
|
weather shell halves its images (to 1024 px wide) when the GPU's texture
|
||||||
|
limit is under 4096 (`render.maxTextureSize`). A CCTV layer that was off
|
||||||
|
stays hidden after a switch, and the active camera's obstruction probe runs
|
||||||
|
again once the new engine's surface is ready. A tracked aircraft's glTF
|
||||||
|
that the engine did not load is loaded again in a new mover set after a
|
||||||
|
backoff and given up after a failure budget; a fleet model of Flights,
|
||||||
|
Military or Local ADS-B that failed gives its slot back at once (the
|
||||||
|
contact stays a glyph), and its glTF is fetched again in a new mover set
|
||||||
|
no sooner than 1.5 s later, when the next aircraft of that class is
|
||||||
|
admitted; far contacts in Cockpit draw
|
||||||
|
as dots from their own sprite set.
|
||||||
|
- **Layer state and cards after a switch.** A history visit across engines
|
||||||
|
applies the visited entry's layer options that differ from the live
|
||||||
|
layer (Wind's model, say); equal options are left alone, so a plain switch
|
||||||
|
writes no layer parameters. The Bhote Koshi evidence cards are published
|
||||||
|
to the incoming engine's overlay. On Navara their anchors stand on the
|
||||||
|
terrain sampled at its finest level; where that fails they start at the
|
||||||
|
pack's elevations and follow the terrain loaded under a camera within
|
||||||
|
25 km. A followed aircraft's card anchors to the model drawn
|
||||||
|
(`gevVisualPosition`), on both engines. Turning a layer off ends its camera
|
||||||
|
arrival correction on every engine. North-up and tilt during a follow work
|
||||||
|
on both engines and keep the follow: Cesium sets the frame through
|
||||||
|
`lookAtTransform` in its EntityView frame, Navara through the follow's
|
||||||
|
`retarget`.
|
||||||
|
- **Shared layer displays.** Every manifest display draws through the globe
|
||||||
|
contract from `render/display.js`; the aircraft family shares its display
|
||||||
|
implementation. There are 35 display entries (including the three weather
|
||||||
|
products and two infrastructure datasets). Wildlife Sightings draws through
|
||||||
|
the contract inside its layer; Sea Marks and Ski Pistes use their overlay
|
||||||
|
controller. Wind streamlines retain backend adapters in
|
||||||
|
`src/globe/cesium/windFlow.js` and `src/globe/navara/windFlow.js`;
|
||||||
|
the fills of the ground layers, installation outlines and the selection fill
|
||||||
|
are `engine.ground` sets. Global Context's range pulses use
|
||||||
|
`engine.ground.createPulseField`: the same fronts, halos, inward bands,
|
||||||
|
wake/rest timing and raised ellipsoid on every engine. Cesium retains its
|
||||||
|
material disc; Navara's shader is owned by `src/globe/navara/ground` and
|
||||||
|
draws after tone mapping. Bhote Koshi's event and locator share
|
||||||
|
`src/layers/bhoteKoshi/render/story.js`, using ground polygons and corridors
|
||||||
|
with Cesium's dashed halo and glowing core, plus point movers with animated
|
||||||
|
outline opacity. Their authored property records remain in the story model.
|
||||||
|
The Active Fires far-zoom heatmap is an `engine.imagery` layer, and Recent
|
||||||
|
Imagery draws its days through `engine.imagery` from its own
|
||||||
|
`render/display.js`. CCTV's monitor plane and viewshed volume use
|
||||||
|
`engine.meshes` through `src/layers/cctv/render/meshes.js`, with no layer
|
||||||
|
backend per engine. Data packs share `src/scenes/dataPacks/display.js`:
|
||||||
|
movers, lines and world meshes retain authored heights, polygon holes,
|
||||||
|
images and picking across scene detach and reattach. Annotation drapes
|
||||||
|
remain on their existing renderers pending bounded classification support;
|
||||||
|
the Cesium implementations live in `src/globe/cesium/annotations/`.
|
||||||
|
- **One layer manifest.** `LAYER_MANIFEST` in `src/app/layers/renderers.js`
|
||||||
|
says how each directory layer draws: its shared `display` (with an optional
|
||||||
|
engine-specific `backends` option keyed by engine kind: wind streamlines), or
|
||||||
|
a layer that draws itself through the contract (Wildlife Sightings). Every
|
||||||
|
engine implements all five drawing primitives, so the manifest lists no
|
||||||
|
primitives a display needs and registers no supported engine kinds; Sea
|
||||||
|
Marks and Ski Pistes drape themselves through their overlay controller.
|
||||||
|
Every layer shows on both engines. The displays and wind backend load
|
||||||
|
through `loadLayerRenderers(kind)`, awaited before the engine's catalog.
|
||||||
|
`rendererFor(id, engine, options)` answers synchronously after that.
|
||||||
|
- **One tools tree.** `src/app/tools.js` attaches the same tools to every
|
||||||
|
engine (`attachEngineTools`): the annotation board, Draw, Import, the
|
||||||
|
Recent Imagery box and panel, the render governor, the scope mask,
|
||||||
|
hidden-tab handling and voice. Each engine kind's tool backend says where
|
||||||
|
they draw; Cesium's also holds the tools that still drive its viewer
|
||||||
|
(release trim, tracked-entity hold, the render-loop suspension, the
|
||||||
|
embedded views), which read it through `engine.extensions.cesium?.viewer`
|
||||||
|
and so stay absent on Navara. Navara's backend (`navaraToolBackend` in
|
||||||
|
`src/app/navaraMode.js`, loaded with its mode) adds the daylight of a
|
||||||
|
restored view and the engine notice.
|
||||||
|
- **Cesium only on demand.** The production build loads `/cesium/Cesium.js`
|
||||||
|
when a Cesium globe starts or a Navara user turns on a feature that still
|
||||||
|
uses Cesium's maths or entity model (the Bhote Koshi event and locator;
|
||||||
|
`src/app/loadCesium.js`). Imported files use plain records and load no SDK. App code does its maths with `src/geo`, a Cesium-free
|
||||||
|
port of the vector, ellipsoid, transform and time functions that
|
||||||
|
`bitExact.test.mjs` compares with Cesium bit for bit.
|
||||||
|
**Invariant: no module reads Cesium while it loads**
|
||||||
|
(`scripts/check-cesium-load-time.mjs`, run by `npm test`).
|
||||||
|
- **Boundaries.** The import-direction gate keeps Cesium packages in
|
||||||
|
`src/globe/cesium/` and three, postprocessing and Navara packages in
|
||||||
|
`src/globe/navara/`, which loads only through `import()`; the rules are in
|
||||||
|
[Code boundaries](CODE-BOUNDARIES.md). The measured temporary exceptions in
|
||||||
|
`scripts/package-boundaries.json` are the three Bhote Koshi model modules
|
||||||
|
(event, locator and embedded media still build Cesium property records),
|
||||||
|
and the gate rejects unused exceptions. `build/initialChunkGuard.js` fails
|
||||||
|
the build when one of them reaches an initial chunk, and
|
||||||
|
`npm run check:main-chunk` holds the main chunk to its gzip budget
|
||||||
|
(`scripts/main-chunk-baseline.json`). Only `src/globe/navara/internals.js`
|
||||||
|
touches Navara's private members.
|
||||||
|
- **Navara 0.2.1 workaround.** The SDK keeps feature-change events of deleted
|
||||||
|
vector layers in its queue, so the view never reaches idle. They are dropped
|
||||||
|
each frame (`src/globe/navara/featureQueue.js`).
|
||||||
|
- **Strict acceptance.** `node scripts/qa-all-layer-switches.mjs <origin> <out>
|
||||||
|
--serve` builds the commit keyless in a clean worktree and runs every layer
|
||||||
|
and switch scenario under a deny-by-default network policy, with free map
|
||||||
|
tiles from a disk cache and a trap that fails any read of Cesium before it
|
||||||
|
has loaded. Every catalog layer must be asserted on both engines.
|
||||||
|
Readiness uses the restored view, enabled camera inputs and an unchanged
|
||||||
|
camera pose over three drawn frames (`scripts/qa/ready.mjs`); timeouts name
|
||||||
|
the unmet conditions. The dev probe exposes the same state through
|
||||||
|
`layers.settled()`. Production checks inject their observers through the
|
||||||
|
existing debug handle, without shipping the probe. Evidence also records
|
||||||
|
the five drawing registries before and after picker switches and contract
|
||||||
|
violations for each observed engine; both remain diagnostic-only. Engine-only
|
||||||
|
registry ids need a reason in `scripts/qa-ledger-allow.json`. Hidden switches
|
||||||
|
report the actual first-frame, hidden-tab or timeout uncover path.
|
||||||
|
Page clocks start at the 18:30 UTC nearest to the real time, so the sun is
|
||||||
|
up over the test cities whenever the run happens, unless a scenario supplies
|
||||||
|
`time` (an ISO instant or `'real'`); `QA_EXIT_DIAGNOSTICS=1` preserves
|
||||||
|
shutdown resources and prints them five seconds after completion.
|
||||||
|
A timed-out scenario with passing evidence is `hung`; non-passing scenarios
|
||||||
|
retry once unless `--no-retry` is set, and a passing retry is reported as
|
||||||
|
`flaky` without failing the run.
|
||||||
|
`--history=FILE` appends run summaries, and
|
||||||
|
`node scripts/report-acceptance-flake.mjs FILE` reports each scenario's
|
||||||
|
share of non-pass runs, including flaky runs. Point
|
||||||
|
`PUPPETEER_EXECUTABLE_PATH` at an installed Chrome if Puppeteer's pinned
|
||||||
|
build is missing. Navara lights the globe by the real sun; every base map,
|
||||||
|
street maps included, takes the night fill after dusk, so a scenario over
|
||||||
|
Austin or San Francisco at local night still sees a legible map, as on
|
||||||
|
Cesium (`src/globe/navara/look.js`).
|
||||||
|
- **Navara 0.2.1 workaround.** The SDK never frees pick batch ids, and the
|
||||||
|
mover banks rebuild their instanced meshes on each attach, so every switch
|
||||||
|
leaked one id per instance of capacity (about 260 KiB of WebAssembly memory).
|
||||||
|
A per-view pool hands released ids back out
|
||||||
|
(`src/globe/navara/movers/batchIds.js`); the long soak asserts both JS
|
||||||
|
objects and native memory stay flat.
|
||||||
|
- **Ground visibility.** Polygon displays show and hide engine ground sets
|
||||||
|
through the screen pass on Navara.
|
||||||
|
|
||||||
## Shipwrecks — EMODnet and Wikidata
|
## Shipwrecks — EMODnet and Wikidata
|
||||||
|
|
||||||
The Events group includes **Shipwrecks** (`shipwrecks`, share-link token `01`),
|
The Events group includes **Shipwrecks** (`shipwrecks`, share-link token `01`),
|
||||||
@@ -248,17 +547,25 @@ snapshot, including one whose rows all fall outside coverage or the species
|
|||||||
checks, is a fresh success that clears earlier rows; only a malformed or failed
|
checks, is a fresh success that clears earlier rows; only a malformed or failed
|
||||||
upstream reply serves stale and starts a retry cooldown. WHALES accepts only
|
upstream reply serves stale and starts a retry cooldown. WHALES accepts only
|
||||||
recognized cetaceans; other identified marine mammals appear under ALL or
|
recognized cetaceans; other identified marine mammals appear under ALL or
|
||||||
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia
|
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells, drawn as
|
||||||
positions are rounded to 0.01° on the server. Acartia comments are never retained.
|
taxon-accent fills at 25% opacity with 2 px outlines at 80% opacity through
|
||||||
|
`engine.ground`, draped on terrain and tiles. Cells are not pickable and
|
||||||
|
identical bounds and accents share one item. Acartia positions are rounded to
|
||||||
|
0.01° on the server. Acartia comments are never retained.
|
||||||
Observer identity is retained only for photo attribution.
|
Observer identity is retained only for photo attribution.
|
||||||
|
|
||||||
`/api/inaturalist/grid` supplies a dedicated, nonpickable billboard density glow,
|
The layer draws on both engines through one display
|
||||||
|
(`src/layers/wildlifeSightings/display.js`): taxon badges and the density glow
|
||||||
|
are `engine.movers` sprite sets.
|
||||||
|
|
||||||
|
`/api/inaturalist/grid` supplies a dedicated, nonpickable sprite density glow,
|
||||||
with a view-relative density ramp from cool teal to warm amber. Overlapping
|
with a view-relative density ramp from cool teal to warm amber. Overlapping
|
||||||
Gaussian splats span four logical cell widths; mono and CRT styles follow map
|
Gaussian splats span four logical cell widths; mono and CRT styles follow map
|
||||||
and cockpit vision changes. Empty snapshots paint nothing. Full footprints
|
and cockpit vision changes. Empty snapshots paint nothing. Full footprints
|
||||||
stay inside the globe limb with a 5% radius margin; opacity fades from full at
|
stay inside the globe limb with a 5% radius margin; opacity fades from full at
|
||||||
3,000 km to zero at 8,000 km camera altitude. Culling and cached coordinates
|
3,000 km to zero at 8,000 km camera altitude. Splats are sized in metres on
|
||||||
add no per-frame work and leave sighting counts intact.
|
the CPU, only on frames where the camera moved; culling and cached coordinates
|
||||||
|
leave sighting counts intact.
|
||||||
|
|
||||||
Only real sampled observation rows produce taxon badges at their public
|
Only real sampled observation rows produce taxon badges at their public
|
||||||
positions. The badges use 28 px screen buckets, a 22 px overlap check and a
|
positions. The badges use 28 px screen buckets, a 22 px overlap check and a
|
||||||
@@ -308,7 +615,6 @@ Disable/destroy releases the glow collection, texture atlas, timers, queues and
|
|||||||
style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
|
style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
|
||||||
selection and card placement. Deterministic operation checks run in `npm test`;
|
selection and card placement. Deterministic operation checks run in `npm test`;
|
||||||
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
|
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
|
||||||
|
|
||||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||||
|
|
||||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||||
@@ -411,7 +717,10 @@ style, map, marks and an aircraft or satellite to follow, written in the
|
|||||||
share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live
|
share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live
|
||||||
God's Eye View inside clients that display MCP Apps, and as a link everywhere.
|
God's Eye View inside clients that display MCP Apps, and as a link everywhere.
|
||||||
The panel runs the app's panel build (`npm run build:panel`, served at
|
The panel runs the app's panel build (`npm run build:panel`, served at
|
||||||
`/panel/`) and loads it, with its data, through the MCP server, so it works
|
`/panel/`), a Cesium-only build: its HTML loads `Cesium.js` and
|
||||||
|
`widgets.css`, and stubs keep Navara and three out of its scripts
|
||||||
|
(`build/panelNavara.js`); its size after that change has not been measured.
|
||||||
|
The MCP server loads it, with its data, so it works
|
||||||
in Claude Desktop and in the Codex and ChatGPT desktop apps with a local
|
in Claude Desktop and in the Codex and ChatGPT desktop apps with a local
|
||||||
server and no HTTPS. `?embed=1` shows the app as the globe alone and takes
|
server and no HTTPS. `?embed=1` shows the app as the globe alone and takes
|
||||||
new views from the page that frames it; framing is off unless
|
new views from the page that frames it; framing is off unless
|
||||||
@@ -795,7 +1104,7 @@ forecast lead-hour points and cones render only when all source parts match the
|
|||||||
status advisory; a newer status shows its position with geometry pending rather
|
status advisory; a newer status shows its position with geometry pending rather
|
||||||
than relabeling older geometry. The cone represents forecast center uncertainty,
|
than relabeling older geometry. The cone represents forecast center uncertainty,
|
||||||
not storm size or the full hazard area. Successful empty and unavailable states
|
not storm size or the full hazard area. Successful empty and unavailable states
|
||||||
are distinct. One owned native Cesium data source preserves polygon holes and
|
are distinct. The display draws centres and forecast points as point movers and each storm's track and cone as ground sets, keeping polygon holes and
|
||||||
geographic seams and releases on disable; forecast animation is not implied.
|
geographic seams and releases on disable; forecast animation is not implied.
|
||||||
NHC movement speed is converted from miles per hour to rounded knots by the
|
NHC movement speed is converted from miles per hour to rounded knots by the
|
||||||
proxy; advisory wind intensity is already in knots and is passed through.
|
proxy; advisory wind intensity is already in knots and is passed through.
|
||||||
@@ -816,7 +1125,7 @@ Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches
|
|||||||
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
|
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
|
||||||
proxy in this repository sees those requests (`/api/terrain/heights` is the
|
proxy in this repository sees those requests (`/api/terrain/heights` is the
|
||||||
separate point-height endpoint), and the upstream edge answers zoom-out bursts
|
separate point-height endpoint), and the upstream edge answers zoom-out bursts
|
||||||
with HTTP 429 under load. `src/maps/terrain.js` hands Cesium a `Resource`
|
with HTTP 429 under load. `src/globe/cesium/terrain.js` hands Cesium a `Resource`
|
||||||
whose retry policy (`src/maps/terrainRetry.js`) every derived tile fetch
|
whose retry policy (`src/maps/terrainRetry.js`) every derived tile fetch
|
||||||
inherits: 429, 502, 503 and 504 replies retry up to three times, 750 ms
|
inherits: 429, 502, 503 and 504 replies retry up to three times, 750 ms
|
||||||
doubling to a 15 s cap, with every retry waiting behind one shared cooldown
|
doubling to a 15 s cap, with every retry waiting behind one shared cooldown
|
||||||
@@ -1137,7 +1446,7 @@ Voice controls bind to a protocol-independent session factory. The default WebRT
|
|||||||
|
|
||||||
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. With no Gemini key the token route answers `503` with code `API_KEY_MISSING`, and the voice control says `Add a Gemini API key in Provider Settings` without the microphone-permission hint. Voice status and error messages keep the message as given on the session's `state` event (`sourceDetail`, English catalog text when it is the app's own, with `sourceParams` for its placeholders), beside the worded `detail`, so the voice control words them again after a language change. Typed-chat errors and the chat panel are worded once. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. With no Gemini key the token route answers `503` with code `API_KEY_MISSING`, and the voice control says `Add a Gemini API key in Provider Settings` without the microphone-permission hint. Voice status and error messages keep the message as given on the session's `state` event (`sourceDetail`, English catalog text when it is the app's own, with `sourceParams` for its placeholders), beside the worded `detail`, so the voice control words them again after a language change. Typed-chat errors and the chat panel are worded once. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||||
|
|
||||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 35 tools using separate description-only metadata; a session is offered 34 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
|
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
|
||||||
|
|
||||||
Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
|
Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
|
||||||
|
|
||||||
@@ -1281,13 +1590,14 @@ screen. Cyan camera badges use Manjunath's camera artwork; selection switches to
|
|||||||
larger coral badge with corner brackets and an animated tactical card. Numeric
|
larger coral badge with corner brackets and an animated tactical card. Numeric
|
||||||
mapped bearings draw illustrative 90 m direction wedges (not measured coverage).
|
mapped bearings draw illustrative 90 m direction wedges (not measured coverage).
|
||||||
The shared overlay paints gradients for at most 64 nearby cameras, promoting the
|
The shared overlay paints gradients for at most 64 nearby cameras, promoting the
|
||||||
selected camera; farther cameras keep native Cesium badges without wedges.
|
selected camera; farther cameras keep native badges without wedges.
|
||||||
Native ground-clamped wedges (a ground polyline plus a classification polygon per
|
Native ground-clamped wedges (a ground polyline plus a classification polygon per
|
||||||
camera) cost about 40 ms per frame for 200 downtown cameras on photoreal tiles
|
camera) cost about 40 ms per frame for 200 downtown cameras on photoreal tiles
|
||||||
and were removed. Badges sit on the cached ground floor (validated mesh or DEM,
|
and were removed. Badges sit on the cached ground floor (validated mesh or DEM,
|
||||||
+1.5 m) rather than ground clamping, which re-sampled every badge whenever a
|
+1.5 m) rather than ground clamping, which re-sampled every badge whenever a
|
||||||
tile streamed in; floors resolve before new badges appear (bounded wait), and a
|
tile streamed in; floors resolve before new badges appear (bounded wait), and a
|
||||||
badge whose cell has not resolved clamps until it does, then moves in place.
|
badge whose cell has not resolved sits on the terrain (hidden while none is
|
||||||
|
known) until it does, then moves in place.
|
||||||
Overlay anchors start on the same floor and write marker properties only on
|
Overlay anchors start on the same floor and write marker properties only on
|
||||||
change. Once the camera is still and tiles have settled, each overlay anchor is
|
change. Once the camera is still and tiles have settled, each overlay anchor is
|
||||||
replaced once by the rendered surface under it (two depth samples per paint,
|
replaced once by the rendered surface under it (two depth samples per paint,
|
||||||
@@ -1494,8 +1804,9 @@ waypoint with no altitude; a ground waypoint is drawn on the ground. The head jo
|
|||||||
aircraft only within 150 km. Each trail keeps up to 2,000 points, a full day of history
|
aircraft only within 150 km. Each trail keeps up to 2,000 points, a full day of history
|
||||||
at one fix per minute plus the live session; past that the oldest recorded points go
|
at one fix per minute plus the live session; past that the oldest recorded points go
|
||||||
first, and the oldest reports only once none are left. The renderer
|
first, and the oldest reports only once none are left. The renderer
|
||||||
(`src/data/trailRenderer.js`) freezes every chunk of 128 points except the newest into
|
(`src/data/trailRenderer.js`) draws through `engine.lines`, keeping every chunk
|
||||||
static geometry, so a long trail costs about as much per frame as a short one;
|
of 128 points except the newest unchanged, so a long trail costs about as much
|
||||||
|
per frame as a short one;
|
||||||
`scripts/qa-trail-freeze.mjs` checks that a freeze never shows a blank frame.
|
`scripts/qa-trail-freeze.mjs` checks that a freeze never shows a blank frame.
|
||||||
While an aircraft is tracked, its trail between reports is the path its icon was
|
While an aircraft is tracked, its trail between reports is the path its icon was
|
||||||
drawn along: the displayed position is recorded about once a second or every 150 m
|
drawn along: the displayed position is recorded about once a second or every 150 m
|
||||||
@@ -2182,7 +2493,7 @@ provides its confirmed toggle, taxa presets and fixed-order, nonempty legend.
|
|||||||
|
|
||||||
## Sky background
|
## Sky background
|
||||||
|
|
||||||
`src/sky/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale
|
`src/globe/cesium/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale
|
||||||
Bright Star Catalogue stars and a soft ESO/S. Brunier Milky Way. Both layers
|
Bright Star Catalogue stars and a soft ESO/S. Brunier Milky Way. Both layers
|
||||||
rotate from ICRF/J2000 into the Earth-fixed frame at the scene clock, with
|
rotate from ICRF/J2000 into the Earth-fixed frame at the scene clock, with
|
||||||
a TEME fallback while Cesium's IAU 2006 tables load. Bundled assets and
|
a TEME fallback while Cesium's IAU 2006 tables load. Bundled assets and
|
||||||
@@ -2217,70 +2528,6 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform
|
|||||||
detection is the backstop for when no probe context can be created. Applied
|
detection is the backstop for when no probe context can be created. Applied
|
||||||
during viewer construction, before any tile builds a draw command.
|
during viewer construction, before any tile builds a draw command.
|
||||||
|
|
||||||
## Render failure notice
|
|
||||||
|
|
||||||
When the globe stops drawing, the scene chrome says so instead of leaving a
|
|
||||||
black globe behind controls that still respond. `installRenderFailureNotice()`
|
|
||||||
(`src/ui/renderFailure.js`) listens for Cesium's `renderError`, after which
|
|
||||||
Cesium stops its render loop, and for the canvas' `webglcontextlost`. It logs
|
|
||||||
the error and publishes a notice to the status store, which
|
|
||||||
`RenderFailure.vue` renders as an alert dialog that takes focus. When the
|
|
||||||
globe draws again, focus goes back to the element that had it, or, if that
|
|
||||||
element is gone or cannot take focus, to the globe's canvas, which the map's
|
|
||||||
keys (Enter to finish a drawing, Space to talk) read as the map. The canvas
|
|
||||||
gets `tabindex="-1"` only until focus moves on or the globe is pressed, and a
|
|
||||||
press leaves focus on the page, as Cesium has it. Focus the user moved
|
|
||||||
meanwhile stays put. The
|
|
||||||
application scene creates its viewer with `showRenderLoopErrors: false`, so
|
|
||||||
Cesium's own "Rendering has stopped" panel, which offers no way back, no longer
|
|
||||||
appears; `applicationViewerOptions()` and `createApplicationViewer()` keep
|
|
||||||
Cesium's panel by default for other callers. An error thrown outside Scene's
|
|
||||||
own error handling (a `preRender` or `postRender` listener, the clock's
|
|
||||||
`onTick`, a resize) also stops the loop without a `renderError`, so the notice
|
|
||||||
wraps the widget's `render()` and `resize()` and raises such an error as a
|
|
||||||
render error before Cesium's loop handles it as before; one Scene has raised
|
|
||||||
already and throws on (`rethrowRenderErrors`) is not raised again. A lost
|
|
||||||
context fails every later frame, and those errors are only logged, so the
|
|
||||||
notice keeps saying why. The notice sits above floating panels such as the
|
|
||||||
Québec camera viewer, and its buttons are 44 px tall on touch screens.
|
|
||||||
|
|
||||||
- **Retry** after a render error restarts the render loop. The notice stays
|
|
||||||
until a frame starts and ends without a render error (Cesium raises
|
|
||||||
`postRender` after a failed frame too), and a loop that fails again keeps it.
|
|
||||||
A listener that throws leaves its Cesium event mid-raise, with listeners
|
|
||||||
added or removed since only queued, so the `render()` and `resize()` wrapper
|
|
||||||
finishes the events it cut short (`finishRaise()`) before it raises the
|
|
||||||
render error. These are the events Cesium raises outside Scene's error
|
|
||||||
handling (`frameEvents()`): Scene's frame events, the camera's and the
|
|
||||||
clock's, the globe's tile load progress, and the load events of the scene's
|
|
||||||
top-level tilesets. So the notice's frame watchers start with the next
|
|
||||||
frame, a listener removed while the loop is stopped, for a layer turned off,
|
|
||||||
is not called again, and one turned off and on again keeps running.
|
|
||||||
- **Reload** after a lost context reloads the page: Cesium cannot draw into a
|
|
||||||
context it has lost, and the address keeps the view.
|
|
||||||
- **Reset view** opens the app's root address (`appRootUrl()`), without the
|
|
||||||
hash or `/share/<code>` path the view was restored from; the query, such as
|
|
||||||
`?lang=`, is kept.
|
|
||||||
|
|
||||||
An inline conversation panel never reloads or navigates its host's page, so it
|
|
||||||
offers Retry after a render error only, and nothing after a lost context, where
|
|
||||||
the panel runtime's own notice applies. The panel's timer
|
|
||||||
(`keepPanelRendering()`) draws only while Cesium's render loop is on, so it
|
|
||||||
stops after a render error and draws again after Retry. An error a frame of
|
|
||||||
the timer throws stops there: it has been raised as a render error, and
|
|
||||||
thrown on it would only reach the host page.
|
|
||||||
|
|
||||||
A render error marks Cesium's loop stopped, but a frame drawn while the loop
|
|
||||||
ran leaves its next animation frame queued: an error Scene catches itself
|
|
||||||
returns to the loop, which queues the frame, and a frame the panel's timer
|
|
||||||
draws leaves it queued. The `render()` and `resize()` wrapper keeps the loop
|
|
||||||
marked running after such a call, so a Retry before that frame, or a tab shown
|
|
||||||
again (`src/app/tools.js`), does not start a second loop that would draw every
|
|
||||||
frame twice. The queued frame stops the loop itself if nothing restarted it.
|
|
||||||
|
|
||||||
The `webglcontextlost` listener does not `preventDefault()` the context loss or
|
|
||||||
restore the context; it only reports it.
|
|
||||||
|
|
||||||
## Map Source control ownership
|
## Map Source control ownership
|
||||||
|
|
||||||
Map Source controls own chip listeners, source-state subscriptions and selection
|
Map Source controls own chip listeners, source-state subscriptions and selection
|
||||||
@@ -2292,11 +2539,15 @@ remain unchanged.
|
|||||||
|
|
||||||
## Visual effects ownership
|
## Visual effects ownership
|
||||||
|
|
||||||
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
|
VisualEffects owns crossfades and animation scheduling and drives styles, bloom
|
||||||
and sharpen. Display supplies actions and renders settings; effect execution has
|
and sharpening through `engine.render` on both engines. Cesium's post stages,
|
||||||
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
including the Anime Milky Way fade, live in `src/globe/cesium/postStages.js`;
|
||||||
Stopping the controller revokes animation before asynchronous UI teardown; final
|
its sonar lives in `sonar.js` and `sonarCommands.js`. Display supplies actions
|
||||||
destruction removes its stages and restores the borrowed bloom configuration.
|
and renders settings; effect execution has no DOM dependency. Existing presets
|
||||||
|
and the 500 ms transition remain unchanged. Stopping the controller revokes
|
||||||
|
animation before asynchronous UI teardown; controller destruction disables the
|
||||||
|
effects. Engine destruction removes stages and restores borrowed bloom.
|
||||||
|
Cesium advertises visualStyles, bloom and contactSonar, but not cloudCover.
|
||||||
Snow is no longer a visual preset. A share link that names it, in either the
|
Snow is no longer a visual preset. A share link that names it, in either the
|
||||||
long or compact form, opens on Normal and drops the Snow shader parameters. A
|
long or compact form, opens on Normal and drops the Snow shader parameters. A
|
||||||
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
|
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
|
||||||
@@ -2589,7 +2840,8 @@ translation entries do not interrupt loading of the other stations. This covers
|
|||||||
station names, not complete GBFS v3 support.
|
station names, not complete GBFS v3 support.
|
||||||
`scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer,
|
`scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer,
|
||||||
station selection, keyboard dismissal, mobile cards and bike-share tool against
|
station selection, keyboard dismissal, mobile cards and bike-share tool against
|
||||||
either a dev or preview server (`--url` selects the target).
|
either a dev or preview server (`--url` selects the target); the `--w2`
|
||||||
|
raster checks import source modules and need the dev server.
|
||||||
|
|
||||||
The station reader addresses the proxy as `/api/gbfs/<encoded feed URL>`; the
|
The station reader addresses the proxy as `/api/gbfs/<encoded feed URL>`; the
|
||||||
feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while
|
feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while
|
||||||
@@ -2915,7 +3167,7 @@ retaining rooftop and below-sea-level elevations. A hidden globe does not
|
|||||||
constrain photoreal geometry, and retry work remains bounded. Already sampled
|
constrain photoreal geometry, and retry work remains bounded. Already sampled
|
||||||
nearby markers follow higher settled terrain on existing bounded walks, without
|
nearby markers follow higher settled terrain on existing bounded walks, without
|
||||||
additional GPU samples or timers, when close-up tile detail refines the floor.
|
additional GPU samples or timers, when close-up tile detail refines the floor.
|
||||||
The submarine-cable renderer is unchanged. These limits bound active stem work;
|
Submarine cables draw through `render/display.js` on every engine. These limits bound active stem work;
|
||||||
they do not reduce the materialized entity count or establish an FPS gain.
|
they do not reduce the materialized entity count or establish an FPS gain.
|
||||||
|
|
||||||
## Keyboard interaction and focus
|
## Keyboard interaction and focus
|
||||||
@@ -3071,7 +3323,7 @@ same hues, from a bright red edge through orange and gold to a pale
|
|||||||
white-hot core. The edge starts more than half opaque, so regions far below
|
white-hot core. The edge starts more than half opaque, so regions far below
|
||||||
the hottest ones still read over forest and savanna imagery. Nothing is painted where
|
the hottest ones still read over forest and savanna imagery. Nothing is painted where
|
||||||
nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery
|
nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery
|
||||||
tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt
|
tile on the active imagery host (`src/layers/firms/render/display.js`), rebuilt
|
||||||
on each data refresh or filter change (the first far-band paint at once,
|
on each data refresh or filter change (the first far-band paint at once,
|
||||||
later ones 150 ms after the last change). Heatmap and markers are both kept
|
later ones 150 ms after the last change). Heatmap and markers are both kept
|
||||||
built at every zoom and cross-fade by camera height between 3,600 and
|
built at every zoom and cross-fade by camera height between 3,600 and
|
||||||
@@ -5321,111 +5573,6 @@ core's lookups alone. The MCP globe panel allows the meta-catalogue and the
|
|||||||
three biggest instances; a picture on another instance shows only in the
|
three biggest instances; a picture on another instance shows only in the
|
||||||
app.
|
app.
|
||||||
|
|
||||||
**"Show me X on Street View."** `show_street_level` (voice and typed;
|
|
||||||
`src/voice/gevActions.js`) is one call whose stages fail separately
|
|
||||||
(`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`):
|
|
||||||
|
|
||||||
- **Key first.** A keyless source explains itself and changes nothing, as a
|
|
||||||
chip does.
|
|
||||||
- **Place.** A name, coordinates, a pointed spot or a numbered item: it flies
|
|
||||||
there at street framing and waits for arrival. With no place it looks at the
|
|
||||||
ground under the screen centre, as OPEN does (`aim: 'screen-centre'`,
|
|
||||||
`armed: false`: sky refuses, and a link's armed point is not used and is kept
|
|
||||||
until a photo opens). Coordinates off the globe are refused.
|
|
||||||
- **Map and source.** It switches to the map the source needs (Google Street
|
|
||||||
View: Google 3D), lights the source through `setLayerParams`, one at a time
|
|
||||||
and kept like a chip click (origin `voice`; a link runs the action with
|
|
||||||
origin `programmatic`, so it never writes the recipient's saved layer state),
|
|
||||||
turns the layer on and calls `openNearest`. `provider` is `google` for
|
|
||||||
"Street View", `mapillary`, `kartaview` or `panoramax` by name, else the lit
|
|
||||||
source.
|
|
||||||
- **Hidden panel.** Cockpit, clean view, recording or scene playback end it at
|
|
||||||
`view`, before the flight and after every await; embed mode's own clean view
|
|
||||||
is exempt, since the photo is lifted over the globe.
|
|
||||||
- **Superseded.** After every await (flight, map switch, layer on, lookup) a
|
|
||||||
newer turn, a shell navigation stamp (`_navigationGeneration`, re-taken where
|
|
||||||
its own flight lands) or a press or wheel on the globe stops it
|
|
||||||
(`superseded: true`). The same checks run before a found photo loads
|
|
||||||
(`proceed`), so nothing billed loads for a request that was given up. A
|
|
||||||
lookup given up leaves the panel as it was when the request kept the photo's
|
|
||||||
source; one that had switched the source off closes that source's photo with
|
|
||||||
it. A photo that opened after either is closed again.
|
|
||||||
- **Replacing a photo.** An open photo stays up while the new request looks
|
|
||||||
(`openNearest` `replace: true`; the viewer is replaced, not closed, so EXPAND
|
|
||||||
stays, also across sources through `holdViewer`, which holds only the action's
|
|
||||||
own switch-off of the photo's source: a source switched by hand meanwhile
|
|
||||||
still stops the load) and closes if nothing opens, so a place with no imagery
|
|
||||||
shows its own error; a link that only arms closes it at once. A replacement
|
|
||||||
from another source drops the old photo's details, and the request's error or
|
|
||||||
armed OPEN outlives the old viewer. A replacement stops a playing Mapillary
|
|
||||||
sequence.
|
|
||||||
- **Outcome.** `noImagery` marks an empty result, `imageryError` a source or
|
|
||||||
viewer failure and `aim: 'sky'` a sky centre (from `street.nearestOutcome`);
|
|
||||||
an empty result names a narrowing 360°/flat or SINCE filter the source
|
|
||||||
honours, as `filter`. Every photo the action opens reveals the panel, also
|
|
||||||
one the user had collapsed.
|
|
||||||
- **Search.** A source without a coverage layer searches up to 250 m
|
|
||||||
(`NEAREST_RADIUS_MAX_M`; Google honours it); clicks keep 50 m. Mapillary
|
|
||||||
asks for 100 photos within 50 m, its API's most (the API returns them
|
|
||||||
unordered and cuts at the limit), and gives up after 12 s, body included
|
|
||||||
(`NEAREST_TIMEOUT_MS`).
|
|
||||||
- **Phone.** The panel sits in the Cameras tab sheet: a photo that opens, or a
|
|
||||||
link that arms, opens the sheet and scrolls the panel into view
|
|
||||||
(`revealPanel`, which does nothing above 720px).
|
|
||||||
|
|
||||||
The layer also exposes `requiredMapStack(id)` and `providerStatus(id)` for
|
|
||||||
callers that decide before the panel lists a source, and `holdViewer()`. A
|
|
||||||
caller's abort is honoured through the viewer's mount and
|
|
||||||
load, and only the lookup that still owns the panel changes its spinner or
|
|
||||||
error.
|
|
||||||
|
|
||||||
Views carry the request as `street_level` (`src/view/index.js`): a source and
|
|
||||||
a point, which defaults to where the camera looks (below 20 km, at a pitch
|
|
||||||
steeper than -5° and within 10 km ahead; else the camera's own point); half a
|
|
||||||
point is not mixed with the camera's: both coordinates come from that default. A `google` source brings `map: photoreal` and refuses
|
|
||||||
another map; `street_level` does not combine with `follow`.
|
|
||||||
`show_in_gods_eye_view`, and the embedded app for a posted view with no
|
|
||||||
point, measure the view's altitude from the terrain under the camera
|
|
||||||
(`groundedLookAt` with the terrain heights service) before taking the look-at
|
|
||||||
point, and keep the view's own estimate without a terrain answer (the embedded
|
|
||||||
app waits 2 s for one). Links carry
|
|
||||||
`sl=<source>,<lat>,<lon>`; the globe panel applies views through the action,
|
|
||||||
and a plain link runs it once restored (one shot: the rewritten hash drops
|
|
||||||
`sl`). A link that follows an entity ignores `sl`.
|
|
||||||
|
|
||||||
- **Billing gate.** A plain link never loads a photo from a source that bills
|
|
||||||
every load (`billedPerImage`; Google): the action runs with
|
|
||||||
`autoOpen: false` and arms the layer (`armNearest`: the link's point and
|
|
||||||
radius, for the next OPEN press only, and only while the ground under the
|
|
||||||
screen centre is within 1 km of it (`ARMED_REACH_M`) or cannot be picked; a
|
|
||||||
sky centre refuses; any other lookup, open, close, disable, the source
|
|
||||||
switched off or its map leaving drops it), opens the panel and shows a toast
|
|
||||||
pointing at OPEN GOOGLE STREET VIEW. A link whose source has no working
|
|
||||||
key here changes nothing and says so in a toast (not in a frame).
|
|
||||||
- **Who may open.** Posted views, and the panel page's own link (inline embed)
|
|
||||||
for a tool result issued in the last 5 minutes, open the photo:
|
|
||||||
`show_in_gods_eye_view` stamps a street-level result `issued_at`, every
|
|
||||||
`gev:view` the panel posts carries `fresh`, and the inline app applies a
|
|
||||||
view without it with `autoOpen: false` (a framing page's posted views still
|
|
||||||
open). `?embed=1` on a published link only arms, without the panel nudge or
|
|
||||||
toast.
|
|
||||||
- **Who owns the camera.** A link's photo step is skipped when a newer link
|
|
||||||
superseded its restore, when its restore flight never landed
|
|
||||||
(`share.camera !== 'applied'`: the visitor dragged or searched), or after
|
|
||||||
any navigation since the generation that flight owned (`navigationToken` on
|
|
||||||
the settled result), also while its layers still load. A link pasted into
|
|
||||||
the open tab runs its step after its restore settles
|
|
||||||
(`gev:share-navigation-restore-settled`).
|
|
||||||
- **Embed.** The rail is hidden, so an open photo moves to an overlay on the
|
|
||||||
globe (`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in
|
|
||||||
full screen, Esc leaves full screen first). EXPAND stays under embed mode's
|
|
||||||
clean view; cockpit, recording and scene playback hide the overlay. Loading
|
|
||||||
errors render inside the overlay, and inside the viewer while EXPAND is on.
|
|
||||||
A posted view whose photo step fails before the imagery stage closes the
|
|
||||||
earlier photo before its camera flies; one the user overtook (a click on the
|
|
||||||
map, a newer request) leaves their photo and camera alone; one that found a
|
|
||||||
photo it could not load keeps that error.
|
|
||||||
|
|
||||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||||
@@ -5560,22 +5707,14 @@ zero-duration journey or immediately relocate a visible vehicle.
|
|||||||
**Selected history.** Selecting one vehicle draws its available recent path,
|
**Selected history.** Selecting one vehicle draws its available recent path,
|
||||||
clipped to the displayed time. The browser retains up to 15 minutes and 128
|
clipped to the displayed time. The browser retains up to 15 minutes and 128
|
||||||
fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may
|
fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may
|
||||||
shorten that history. Only the selected vehicle has a trail: completed segments
|
shorten that history. Only the selected vehicle has a trail, drawn as
|
||||||
are batched primitives, and a small moving head ends at the marker. Trail
|
`engine.ground` corridors draped on terrain and 3D tiles on every engine:
|
||||||
geometry is bounded to 640 prepared vertices, prioritizing the active corridor
|
completed segments draw in two lanes (a 5 px dark edge under a 3 px mode
|
||||||
and shortening the oldest geometry first. Active and future marker corridors
|
colour), one corridor per lane for each run of joined segments in one of 29
|
||||||
retain their own 640-vertex budget; long report intervals use coarser subdivisions
|
age bands, re-banded once per displayed second or style change. The active
|
||||||
than 25 m instead of dropping an admitted vehicle. Supported scenes use ground polylines
|
segment is one corridor with its dark backing; its geometry is written once
|
||||||
classified against both terrain and 3D tiles, including after map-stack changes,
|
per segment and a shader reveal (`setReveal`) draws it up to the marker, so it
|
||||||
with per-segment age colours. History changes rebuild the body. The head is one
|
is not rewritten while the marker moves. Trail geometry is bounded to 640 prepared vertices, prioritizing the active corridor and shortening the oldest geometry first. Active and future marker corridors retain their own 640-vertex budget; long report intervals use coarser subdivisions than 25 m instead of dropping an admitted vehicle. Deselecting releases selection geometry; marker paths retain only
|
||||||
short draped corridor, clipped to displayed time by a material uniform; only
|
|
||||||
subdivision crossings rebuild it. Ordinary frames upload no head geometry.
|
|
||||||
Without ground-polyline support, ordinary primitives retain a mode-coloured
|
|
||||||
0.55-alpha depth-fail path, including the head, without dimming it to near-black.
|
|
||||||
Instance attribute updates write back the same typed array returned by Cesium;
|
|
||||||
its getters return copies, so reading a second time discards a visibility or colour edit.
|
|
||||||
Normal instance colours follow the age ramp once per display second or style change. Subdivision crossings update only changed visibility
|
|
||||||
attributes. Deselecting releases selection geometry; marker paths retain only
|
|
||||||
active and future corridors.
|
active and future corridors.
|
||||||
|
|
||||||
MBTA history is also retained in the running proxy: up to 15 minutes and 128
|
MBTA history is also retained in the running proxy: up to 15 minutes and 128
|
||||||
@@ -5698,7 +5837,7 @@ heap growth ≤24/48 MiB. The 3,000-vehicle CPU budget is 4 ms (recorded browser
|
|||||||
measurement 3.4 ms); the 800-vehicle budget stays at 1 ms (measured 0.9 ms).
|
measurement 3.4 ms); the 800-vehicle budget stays at 1 ms (measured 0.9 ms).
|
||||||
Transit-authored frame allocation is ≤8 KiB/frame at both fleets, attributed
|
Transit-authored frame allocation is ≤8 KiB/frame at both fleets, attributed
|
||||||
by source stack to `src/layers/transit/**`, `src/data/contactPlayback.js`,
|
by source stack to `src/layers/transit/**`, `src/data/contactPlayback.js`,
|
||||||
`src/data/contactTrailRenderer.js` and `src/data/transit*.js`. Whole-frame
|
`src/data/transit*.js`. Whole-frame
|
||||||
allocation and its three largest allocators are reported without gating:
|
allocation and its three largest allocators are reported without gating:
|
||||||
Cesium billboard buffer uploads and the shared DETECT painter are outside
|
Cesium billboard buffer uploads and the shared DETECT painter are outside
|
||||||
this layer's allocation budget. **Follow-up: pool shared DETECT placement and
|
this layer's allocation budget. **Follow-up: pool shared DETECT placement and
|
||||||
@@ -5780,17 +5919,17 @@ height still choose their own unit.
|
|||||||
At global scale, ambient Radio cluster badges are hard-opacity shared-host
|
At global scale, ambient Radio cluster badges are hard-opacity shared-host
|
||||||
entries: count/category updates and identity replacement do not run keyhole or
|
entries: count/category updates and identity replacement do not run keyhole or
|
||||||
enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon
|
enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon
|
||||||
culling can still remove an invalid placement, and Cesium still owns the cluster
|
culling can still remove an invalid placement, and the radio display's cluster
|
||||||
point geometry and picks. Their 50,000 km line-of-sight range covers the
|
points (screen clusters with Cesium's EntityCluster rules) carry the picks. Their 50,000 km line-of-sight range covers the
|
||||||
supported full-globe camera above 24,000 km, including farther horizon clusters.
|
supported full-globe camera above 24,000 km, including farther horizon clusters.
|
||||||
Unclustered visible stations publish nearest-first ambient labels through that
|
Unclustered visible stations publish nearest-first ambient labels through that
|
||||||
same host, capped at 16 labels globally, 32 at intermediate zoom, and 48 nearby;
|
same host, capped at 16 labels globally, 32 at intermediate zoom, and 48 nearby;
|
||||||
cluster and singleton candidates still share the Radio source's 64-entry ambient
|
cluster and singleton candidates still share the Radio source's 64-entry ambient
|
||||||
cohort. Cesium continues to own each 13 px station point, horizon visibility,
|
cohort. The display draws each 13 px station point, horizon visibility,
|
||||||
and direct/nearby picking. Native points and shared-host text use one 50,000 km
|
and direct/nearby picking. Points and shared-host text use one 50,000 km
|
||||||
interaction limit, so a painted singleton or cluster label always retains a
|
interaction limit, so a painted singleton or cluster label always retains a
|
||||||
pickable point at its anchor throughout the supported high-global view. No
|
pickable point at its anchor throughout the supported high-global view. No
|
||||||
Radio entity uses native Cesium label text.
|
Radio point uses engine label text.
|
||||||
|
|
||||||
Delivery constraint: PR #10 is stack-only and unsafe standalone. PR #11 owns
|
Delivery constraint: PR #10 is stack-only and unsafe standalone. PR #11 owns
|
||||||
the required Radio lifecycle/authority repair, so PR #10 must not merge or ship
|
the required Radio lifecycle/authority repair, so PR #10 must not merge or ship
|
||||||
@@ -6221,7 +6360,7 @@ silently demoting every later lookup for the session.
|
|||||||
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
|
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
|
||||||
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
||||||
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
||||||
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
Close selected-pad views add one static 500 m-radius cyan launch zone, an `engine.ground` disc with the radial material (a faint fill, a bright soft rim and emission) draped on terrain and 3D tiles. The zone is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
||||||
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's callout fades by the painted opacity of its own ambient card, so a card dropped by budget, collisions or UI chrome, or faded at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's callout fades by the painted opacity of its own ambient card, so a card dropped by budget, collisions or UI chrome, or faded at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
||||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
|
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
|
||||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||||
@@ -6481,7 +6620,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
|
|
||||||
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint.
|
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint.
|
||||||
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
|
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
|
||||||
- **Thirty-five tools** (thirty-four in a session without `OVERPASS_UPSTREAMS`, which adds `osm_query`; argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `show_street_level`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_voice_inactivity_timeout`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `resolve_area`, `find_imagery`, `osm_query`, `next_iss_pass`, and `next_satellite_pass`.
|
- **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`.
|
||||||
|
|
||||||
> **Reading `npm test` totals:** the count depends on the Node major. The two
|
> **Reading `npm test` totals:** the count depends on the Node major. The two
|
||||||
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
|
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
|
||||||
@@ -6491,7 +6630,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
> matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25
|
> matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25
|
||||||
> runs can therefore remain lower because their 14 allocation tests were skipped.
|
> runs can therefore remain lower because their 14 allocation tests were skipped.
|
||||||
|
|
||||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated
|
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per frame of the globe engine (Cesium or Navara) through the globe contract. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck currentLine truncated
|
||||||
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
|
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
|
||||||
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
|
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
|
||||||
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
|
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
|
||||||
@@ -6787,14 +6926,17 @@ request viewbox and continues searching without that geographic preference.
|
|||||||
### Import Your Own Files (October 2026)
|
### Import Your Own Files (October 2026)
|
||||||
|
|
||||||
- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||||
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy.
|
- **Planning and limits.** `geoImportPlan.js` turns plain records and supplied image dimensions into drawing plans and undrawn counts without a DOM or engine. `geoImportDisplay.js` loads images, applies the plan through the globe contract and owns the drawings; `geoImportLimits.js` holds the shared reader, image, drawing and scheduling budgets.
|
||||||
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
|
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. `geoFileImport.js` and `geoImportKml.js` produce plain records; one `geoImportDisplay.js` draws them on ground, movers, lines and imagery on every engine, without loading Cesium. Ground paths follow great circles and retain segment gaps. Pins use square sprite canvases preserving Cesium's icon placement and styles; a file's icon looks share a budget of 16 million canvas pixels, so many distinct styles draw at the same size from lower-resolution textures. A KML heading turns with the camera's heading, staying measured from north. Ground and relative pins sample loaded terrain and, at camera heights below 250 km with a tile surface, `engine.surface.clampToHeight` for the pins in the window; absolute pins keep their height. The first sampling and every refresh run in batches of 256 that yield between them; refreshes go through the controller's serial queue after camera/map changes and terrain completion, retaining the last available height on misses; on a tile surface a pin outside the window keeps its last tile height, which the window test also uses. Ground pins ignore scene depth and are horizon-culled. Removal destroys every owned drawing set.
|
||||||
|
- **Drawing limits.** Raised polygons draw their fills (meshes triangulated in the plane tangent to the Earth, as Cesium's perPositionHeight) and outlines at their vertices' heights; a raised polygon of more than 128 rings is not drawn, and one of more than 32,768 vertices draws only its outlines (both counted as undrawn). Repeated closing vertices do not consume the fill budget. Ground overlays take their colour's tint and alpha and stack by KML `drawOrder`, then document order (a file imported later draws over); a rotated one is turned on a canvas over the box around it; a file's tinted and turned canvases share a budget of 16 million pixels and equal ones are drawn once; an absolute image or plain-colour overlay is a mesh fixed at its altitude, including negative heights, as Cesium's rectangle at a height (a relative one drapes, as on Cesium). Wall fills, extruded prisms, `gx:LatLonQuad` and a rotated overlay whose box passes a pole are counted as undrawn; wall top edges and polygon top geometry remain. Rhumb edges become great circles. Packed image overlays draw on both engines. A GeoJSON line of one point or a polygon of fewer than three distinct corners is not a record, so a file of only those is refused as having nothing to draw. A hole of fewer than three distinct corners is dropped; a ground polygon the ground contract still refuses is counted as undrawn.
|
||||||
|
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rectangular overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
|
||||||
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
|
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
|
||||||
- **Input handling.** Coordinates and GeoJSON geometry types are checked before Cesium allocation. Only short plain names reach GeoJSON property bags; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits.
|
- **Input handling.** Coordinates and GeoJSON geometry types are checked before drawing allocation. Only short plain names reach the record model; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits.
|
||||||
- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and generated pins share one lossless image URL per canvas.
|
- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and points with the same icon look share a sprite set and atlas canvas.
|
||||||
- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 entities (including multipart children and Folder containers); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason.
|
- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 drawable records (including multipart children); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason.
|
||||||
- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with viewer, navigation, ground services and map-source subscription. Destroy aborts pending work, unsubscribes, removes data sources, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import.
|
- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with the scene engine, navigation, ground services and map-source subscription. Detach destroys drawings and revokes packed-image URLs; reattach reparses the retained file bytes and restores visibility. Destroy aborts pending work, unsubscribes, removes drawings, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import.
|
||||||
- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, native style/image equivalence, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules.
|
- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, effective style equivalence against a native Cesium control, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules.
|
||||||
|
- **Reference corpus.** `scripts/fixtures/geo-import/` holds 13 KML/KMZ/GeoJSON fixtures covering heights, rotations, poles, the dateline, holes, ordering, styles, packed images and a 20,000-vertex polygon. `node scripts/qa-geo-import-reference.mjs` compares planned geometry and styles with Cesium 1.138.0 DataSources in Node; `--record` recreates the reference offline with canvas pins stubbed and KMZ images resolved locally. Pixel composition and ordering between primitive families remain browser checks.
|
||||||
|
|
||||||
### Recent Imagery (September 2026)
|
### Recent Imagery (September 2026)
|
||||||
|
|
||||||
@@ -6809,7 +6951,7 @@ pins and share links already supply complete boxes.
|
|||||||
- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
|
- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
|
||||||
- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
|
- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
|
||||||
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
|
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
|
||||||
- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
|
- **Rendering.** `render/display.js` owns at most two GIBS layers on the surface reported by `engine.imagery.surface()`: the globe, a shown tileset, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
|
||||||
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
|
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
|
||||||
- **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
|
- **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
|
||||||
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
|
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
|
||||||
@@ -7392,7 +7534,7 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
counters, and timing labels.
|
counters, and timing labels.
|
||||||
- Voice debug log: `tail -f .gev-logs/realtime-conversations.jsonl` (gitignored).
|
- Voice debug log: `tail -f .gev-logs/realtime-conversations.jsonl` (gitignored).
|
||||||
|
|
||||||
Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch-zone ground primitive and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time.
|
Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch zone (a radial ground disc draped on the scene) and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time.
|
||||||
|
|
||||||
Orbit replay framing uses one combined bounding sphere for Earth and every sample of the selected orbit. The camera derives its final range from that full envelope, while its look-at target retains a radial bias toward the moving vehicle rather than collapsing onto the singular Earth-center frame. Compact-orbit launchers therefore remain tracked during camera rotation, and highly eccentric transfer orbits still keep both the globe and their distant apogee arc visible. The orbital camera stays on one side of the mission's 3D orbit plane and uses the vehicle radial as visual up, so forward motion remains screen-left through polar/local-heading wraps instead of alternating left and right. The fixed-size cyan orbit dot retains one pixel scale throughout the pullback. While replay owns the camera, the selected launch-site host label is suppressed so it cannot duplicate or overlap the replay vehicle's DOM callout; cancel/completion restores it.
|
Orbit replay framing uses one combined bounding sphere for Earth and every sample of the selected orbit. The camera derives its final range from that full envelope, while its look-at target retains a radial bias toward the moving vehicle rather than collapsing onto the singular Earth-center frame. Compact-orbit launchers therefore remain tracked during camera rotation, and highly eccentric transfer orbits still keep both the globe and their distant apogee arc visible. The orbital camera stays on one side of the mission's 3D orbit plane and uses the vehicle radial as visual up, so forward motion remains screen-left through polar/local-heading wraps instead of alternating left and right. The fixed-size cyan orbit dot retains one pixel scale throughout the pullback. While replay owns the camera, the selected launch-site host label is suppressed so it cannot duplicate or overlap the replay vehicle's DOM callout; cancel/completion restores it.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
Successful loading leaves the attribution cards visible with the assets.
|
||||||
|
|
||||||
`createApplicationTools({ sceneDataPacks: { sources } })` and
|
`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
|
registry of source functions. Each receives `{ path, signal, maxBytes }` and
|
||||||
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
||||||
signal, bound reads and return the real MIME type. They remain application code,
|
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)`.
|
`gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`.
|
||||||
It returns fresh datacenter and dam layers in that order, with the existing IDs,
|
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
|
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
|
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.
|
base paths. This browser source export expects a Vite-compatible asset build.
|
||||||
|
|
||||||
@@ -21,7 +21,13 @@ const layers = createInfrastructureLayers({
|
|||||||
governorRequestRender,
|
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 |
|
| Function | Arguments / responsibility |
|
||||||
@@ -35,28 +41,39 @@ const layers = createInfrastructureLayers({
|
|||||||
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
||||||
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||||
|
|
||||||
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
Each returned session layer supports `init(null, { engine })`,
|
||||||
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
`attachRenderer(null, { engine, signal })`, `detachRenderer()`, `enable()`,
|
||||||
`getAnalystRecords()`.
|
`disable()`, `update()`, `destroy()`, `getStats()`, `getLodDiagnostics()` and
|
||||||
Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay
|
`getAnalystRecords(limit)`. The first argument is retained for lifecycle
|
||||||
host permits one live instance per layer ID; destroy an old instance before
|
compatibility; drawing uses the engine passed in the options. Attach once per
|
||||||
replacing it. Separate applications may use identical IDs with separate hosts.
|
scene, detach before attaching another engine, and destroy when the session ends.
|
||||||
Callbacks must be ready before enable and remain usable through destruction.
|
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.
|
`src/layers/infrastructure/index.js` owns the parsed dataset, shared in-flight
|
||||||
Concurrent enables share one load. Destroy is permanent, aborts pending fetches,
|
load, enabled intention and selection across scene switches. Enable loads on
|
||||||
discards late results, and clears the owned data source, context records, overlays,
|
first use. Disable hides the drawing and clears selection but retains parsed
|
||||||
input handler and listeners. The layer never creates an application context store,
|
data. Detach destroys scene resources and retains the dataset and selected ID;
|
||||||
overlay host, viewer, or render scheduler. The consumer must resolve one compatible
|
reattach redraws enabled data and restores selection when no other selection
|
||||||
Cesium installation shared with its viewer; it must not bundle separate copies.
|
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
|
`src/layers/infrastructure/render/display.js` draws the same records on every
|
||||||
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
|
engine: point movers for tips and pins, lines for stems, ground polygons for
|
||||||
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers.
|
footprints and ground corridors for paths. It owns the LOD walk, surface-height
|
||||||
Consumers use the package exports, which do not import standalone application
|
refresh, picks, context handles and overlay publications. Destruction releases
|
||||||
globals.
|
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/`.
|
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
|
Their input counts are 4,352 datacenter features and 704 dam features. The session
|
||||||
expand multipart geometries into multiple entities; entity and stem counts are
|
splits multipart geometries using Cesium's stable repeated-id suffixes on every
|
||||||
different measurements. This change does not refresh or relicense the datasets.
|
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
|
# KNOWN ISSUES
|
||||||
|
|
||||||
Updated: November 5, 2026
|
Updated: November 10, 2026
|
||||||
|
|
||||||
This file tracks active runtime issues only.
|
This file tracks active runtime issues only.
|
||||||
|
|
||||||
@@ -10,6 +10,68 @@ For the roadmap and open backlog, see the repository issue tracker.
|
|||||||
|
|
||||||
## Open
|
## 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
|
### Wildlife grid relies on the iNaturalist UTFGrid cell schema
|
||||||
|
|
||||||
Status: Open (guarded fallback)
|
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
|
world time waits for the vessel layer's snapshot at that time and selects the
|
||||||
vessel once it has it.
|
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
|
## Payload
|
||||||
|
|
||||||
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
|
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 |
|
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
||||||
| 13 | thing | LEN | none | sub-message, see thing views |
|
| 13 | thing | LEN | none | sub-message, see thing views |
|
||||||
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) |
|
| 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 |
|
| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
|
||||||
| 17 | `bloomIntensity` | uint | style | |
|
| 17 | `bloomIntensity` | uint | style | |
|
||||||
| 18 | `bloomVersion` | uint | 2 | |
|
| 18 | `bloomVersion` | uint | 2 | |
|
||||||
|
|||||||
+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
|
name or coordinates). Links carry them in the `an` parameter, and the app
|
||||||
draws them once the link has been restored.
|
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
|
Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a
|
||||||
`camera`, plus `layers`, `style`, `map`, `follow`, `annotations` and
|
`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A
|
||||||
`street_level`. A tilted camera over an area looks at its center from behind;
|
tilted camera over an area looks at its center from behind; camera fields
|
||||||
camera fields given with an area override its framing. A passed-through
|
given with an area override its framing. `resolveViewArguments` turns them
|
||||||
view's `street_level` point moves with a new `area` or camera position (a
|
into a view.
|
||||||
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`.
|
|
||||||
|
|
||||||
### The God's Eye View panel
|
### 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 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
|
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
|
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
|
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
|
for the portable transit entry, and its card and detection canvases stay
|
||||||
painting surfaces it draws on, like the Cesium canvas), the Global
|
painting surfaces it draws on, like the Cesium canvas), the Global
|
||||||
Context compass and contact bearings (`AwarenessDirection` teleports into
|
Context compass and contact bearings (`AwarenessDirection` teleports into
|
||||||
|
|||||||
Generated
+1113
-5
File diff suppressed because it is too large.
Load diff
+11
-4
@@ -54,21 +54,28 @@
|
|||||||
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
|
"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",
|
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
||||||
"qa:transit": "node scripts/qa-transit.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": {
|
"dependencies": {
|
||||||
"@jtarrio/signals": "^0.10.0",
|
"@jtarrio/signals": "^0.10.0",
|
||||||
"@jtarrio/webrtlsdr": "^3.0.6",
|
"@jtarrio/webrtlsdr": "^3.0.6",
|
||||||
"@mapbox/vector-tile": "^3.0.0",
|
"@mapbox/vector-tile": "^3.0.0",
|
||||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||||
|
"@navaramap/three": "0.2.1",
|
||||||
|
"@navaramap/three-default-plugin": "0.2.1",
|
||||||
"cesium": "^1.124.0",
|
"cesium": "^1.124.0",
|
||||||
|
"earcut": "^3.0.2",
|
||||||
"egm96-universal": "^1.1.1",
|
"egm96-universal": "^1.1.1",
|
||||||
"hls.js": "^1.7.3",
|
"hls.js": "^1.7.3",
|
||||||
|
"kdbush": "4.0.2",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"mgrs": "^2.1.0",
|
"mgrs": "^2.1.0",
|
||||||
"pbf": "^5.1.2",
|
"pbf": "^5.1.2",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
|
"postprocessing": "6.39.4",
|
||||||
"satellite.js": "^6.0.2",
|
"satellite.js": "^6.0.2",
|
||||||
|
"three": "0.185.1",
|
||||||
"vue": "^3.5.44",
|
"vue": "^3.5.44",
|
||||||
"vue-i18n": "^11.4.15"
|
"vue-i18n": "^11.4.15"
|
||||||
},
|
},
|
||||||
@@ -90,7 +97,7 @@
|
|||||||
"./infrastructure/geojson": "./src/data/localGeojsonCore.js",
|
"./infrastructure/geojson": "./src/data/localGeojsonCore.js",
|
||||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||||
"./application": "./src/app/application.js",
|
"./application": "./src/app/application.js",
|
||||||
"./application/viewer": "./src/app/viewer.js",
|
"./application/viewer": "./src/globe/cesium/viewer.js",
|
||||||
"./time": "./src/time/worldClock.js",
|
"./time": "./src/time/worldClock.js",
|
||||||
"./build/vite": {
|
"./build/vite": {
|
||||||
"node": "./build/vite.js"
|
"node": "./build/vite.js"
|
||||||
@@ -204,8 +211,8 @@
|
|||||||
"./geography/neighborhoods": "./src/data/neighborhoodPolygons.js",
|
"./geography/neighborhoods": "./src/data/neighborhoodPolygons.js",
|
||||||
"./maps/controller": "./src/maps/controller.js",
|
"./maps/controller": "./src/maps/controller.js",
|
||||||
"./maps/catalog": "./src/maps/catalog.js",
|
"./maps/catalog": "./src/maps/catalog.js",
|
||||||
"./maps/imagery": "./src/maps/imagery.js",
|
"./maps/imagery": "./src/globe/cesium/mapImagery.js",
|
||||||
"./maps/terrain": "./src/maps/terrain.js",
|
"./maps/terrain": "./src/globe/cesium/terrain.js",
|
||||||
"./maps/3d": "./src/maps/google3d.js",
|
"./maps/3d": "./src/maps/google3d.js",
|
||||||
"./maps/defaults": "./src/maps/defaultSources.js",
|
"./maps/defaults": "./src/maps/defaultSources.js",
|
||||||
"./voice/controller": "./src/voice/realtimeController.js",
|
"./voice/controller": "./src/voice/realtimeController.js",
|
||||||
|
|||||||
@@ -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(
|
const builtins = new Set(
|
||||||
builtinModules.flatMap((name) => [name, `node:${name}`]),
|
builtinModules.flatMap((name) => [name, `node:${name}`]),
|
||||||
);
|
);
|
||||||
const code = /\.[mc]?js$/;
|
const code = /\.(?:[mc]?js|vue)$/;
|
||||||
const entry = (file) =>
|
const entry = (file) =>
|
||||||
file === 'src/main.js' || file.startsWith('src/standalone/');
|
file === 'src/main.js' || file.startsWith('src/standalone/');
|
||||||
const tests = (file) =>
|
const tests = (file) =>
|
||||||
@@ -19,6 +19,17 @@ const tests = (file) =>
|
|||||||
const source = (file) =>
|
const source = (file) =>
|
||||||
file.startsWith('src/sources/') ||
|
file.startsWith('src/sources/') ||
|
||||||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
|
/^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) =>
|
const renderer = (file) =>
|
||||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||||
@@ -70,6 +81,25 @@ export function checkImportDirections(root) {
|
|||||||
(file) =>
|
(file) =>
|
||||||
code.test(file) && !tests(file) && existsSync(path.join(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 errors = new Set();
|
||||||
const modules = new Map();
|
const modules = new Map();
|
||||||
const relative = (file) =>
|
const relative = (file) =>
|
||||||
@@ -87,7 +117,10 @@ export function checkImportDirections(root) {
|
|||||||
}
|
}
|
||||||
let analysis;
|
let analysis;
|
||||||
try {
|
try {
|
||||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
|
analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
|
||||||
|
dynamic: true,
|
||||||
|
filename: file,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
report(file, error.message);
|
report(file, error.message);
|
||||||
return null;
|
return null;
|
||||||
@@ -109,6 +142,19 @@ export function checkImportDirections(root) {
|
|||||||
target = path.resolve(root, exported);
|
target = path.resolve(root, exported);
|
||||||
} else {
|
} else {
|
||||||
record.external.push(specifier);
|
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 (
|
if (
|
||||||
file.startsWith('src/') &&
|
file.startsWith('src/') &&
|
||||||
(builtins.has(specifier) || specifier.startsWith('node:'))
|
(builtins.has(specifier) || specifier.startsWith('node:'))
|
||||||
@@ -131,6 +177,18 @@ export function checkImportDirections(root) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
record.edges.push(to);
|
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/'))
|
if (file.startsWith('src/') && to.startsWith('server/'))
|
||||||
report(file, `Browser imports server: ${to}`);
|
report(file, `Browser imports server: ${to}`);
|
||||||
if (
|
if (
|
||||||
@@ -177,6 +235,8 @@ export function checkImportDirections(root) {
|
|||||||
for (const file of files.filter((file) => file.startsWith('src/')))
|
for (const file of files.filter((file) => file.startsWith('src/')))
|
||||||
browser(file);
|
browser(file);
|
||||||
const roots = new Map(files.filter(source).map((file) => [file, 'source']));
|
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 || {})) {
|
for (const [key, value] of Object.entries(pkg.exports || {})) {
|
||||||
if (portableExport(key) && typeof value === 'string')
|
if (portableExport(key) && typeof value === 'string')
|
||||||
roots.set(value.replace(/^\.\//, ''), key);
|
roots.set(value.replace(/^\.\//, ''), key);
|
||||||
@@ -202,7 +262,12 @@ export function checkImportDirections(root) {
|
|||||||
if (
|
if (
|
||||||
builtins.has(specifier) ||
|
builtins.has(specifier) ||
|
||||||
specifier.startsWith('node:') ||
|
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(
|
report(
|
||||||
file,
|
file,
|
||||||
@@ -217,6 +282,51 @@ export function checkImportDirections(root) {
|
|||||||
}
|
}
|
||||||
visit(start);
|
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.
|
// Common voice controls may use their DOM view, but never a protocol implementation.
|
||||||
const voiceSeen = new Set();
|
const voiceSeen = new Set();
|
||||||
function voice(file) {
|
function voice(file) {
|
||||||
@@ -231,8 +341,17 @@ export function checkImportDirections(root) {
|
|||||||
}
|
}
|
||||||
if (existsSync(path.join(root, 'src/voice/sessionCommands.js')))
|
if (existsSync(path.join(root, 'src/voice/sessionCommands.js')))
|
||||||
voice('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'));
|
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 (
|
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 path from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
|
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
|
||||||
import { build, normalizePath } from 'vite';
|
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) {
|
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);
|
root = await realpath(root);
|
||||||
const readJson = async (name) =>
|
const readJson = async (name) =>
|
||||||
JSON.parse(await readFile(path.join(root, name), 'utf8'));
|
JSON.parse(await readFile(path.join(root, name), 'utf8'));
|
||||||
const pkg = await readJson('package.json');
|
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 declaredExports = Object.keys(pkg.exports || {}).sort();
|
||||||
const classifiedExports = Object.values(groups)
|
const classifiedExports = Object.values(groups)
|
||||||
.flatMap((group) => group.exports)
|
.flatMap((group) => group.exports)
|
||||||
@@ -25,6 +36,7 @@ export async function checkPackageBoundaries(root) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const reports = [];
|
const reports = [];
|
||||||
|
const differences = [];
|
||||||
for (const [name, group] of Object.entries(groups)) {
|
for (const [name, group] of Object.entries(groups)) {
|
||||||
const node = group.runtime === 'node';
|
const node = group.runtime === 'node';
|
||||||
if (group.runtime && !['browser', 'node'].includes(group.runtime)) {
|
if (group.runtime && !['browser', 'node'].includes(group.runtime)) {
|
||||||
@@ -32,12 +44,23 @@ export async function checkPackageBoundaries(root) {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!Array.isArray(group.modules) ||
|
!Array.isArray(group.modules) ||
|
||||||
!group.modules.length ||
|
(!write && !group.modules.length) ||
|
||||||
!Array.isArray(group.external)
|
!Array.isArray(group.external)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Invalid package boundary: ${name}`);
|
throw new Error(`Invalid package boundary: ${name}`);
|
||||||
}
|
}
|
||||||
|
// The shared globe directory excludes engines and their test support.
|
||||||
const allowed = new Set();
|
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) {
|
for (const module of group.modules) {
|
||||||
if (
|
if (
|
||||||
typeof module !== 'string' ||
|
typeof module !== 'string' ||
|
||||||
@@ -47,7 +70,13 @@ export async function checkPackageBoundaries(root) {
|
|||||||
) {
|
) {
|
||||||
throw new Error(`Boundary modules must be repository paths: ${name}`);
|
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);
|
const relative = path.relative(root, resolved);
|
||||||
if (
|
if (
|
||||||
relative === '..' ||
|
relative === '..' ||
|
||||||
@@ -56,7 +85,12 @@ export async function checkPackageBoundaries(root) {
|
|||||||
) {
|
) {
|
||||||
throw new Error(`Boundary module escapes repository: ${name}`);
|
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) {
|
for (const external of group.external) {
|
||||||
if (
|
if (
|
||||||
@@ -79,11 +113,7 @@ export async function checkPackageBoundaries(root) {
|
|||||||
) {
|
) {
|
||||||
throw new Error(`Node export must have only a node condition: ${key}`);
|
throw new Error(`Node export must have only a node condition: ${key}`);
|
||||||
}
|
}
|
||||||
if (
|
if (typeof target !== 'string' || !target.startsWith('./')) {
|
||||||
typeof target !== 'string' ||
|
|
||||||
!target.startsWith('./') ||
|
|
||||||
!allowed.has(normalizePath(path.resolve(root, target)))
|
|
||||||
) {
|
|
||||||
throw new Error(`Export must point to an owned module: ${key}`);
|
throw new Error(`Export must point to an owned module: ${key}`);
|
||||||
}
|
}
|
||||||
return path.resolve(root, target);
|
return path.resolve(root, target);
|
||||||
@@ -109,11 +139,6 @@ export async function checkPackageBoundaries(root) {
|
|||||||
// template blocks belong to the component file itself.
|
// template blocks belong to the component file itself.
|
||||||
if (info.id.startsWith('\0')) return;
|
if (info.id.startsWith('\0')) return;
|
||||||
const id = info.id.split('?')[0];
|
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);
|
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]
|
const invoked = process.argv[1]
|
||||||
@@ -148,13 +222,25 @@ const invoked = process.argv[1]
|
|||||||
: '';
|
: '';
|
||||||
if (import.meta.url === invoked) {
|
if (import.meta.url === invoked) {
|
||||||
try {
|
try {
|
||||||
const reports = await checkPackageBoundaries(
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||||
fileURLToPath(new URL('../', import.meta.url)),
|
if (process.argv.includes('--write')) {
|
||||||
);
|
const reports = await writePackageBoundaries(root);
|
||||||
for (const report of reports)
|
for (const { name, added, removed } of reports) {
|
||||||
console.log(
|
console.log(
|
||||||
`Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`,
|
`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) {
|
} catch (error) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
process.exitCode = 1;
|
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
|
fi
|
||||||
|
|
||||||
if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \
|
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"
|
echo "error: CCTV layer not wired in src/standalone/catalog.js"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 };
|
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 };
|
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||||
return {
|
return {
|
||||||
...json(400, { error: 'unsupported images query' }),
|
...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
-34
@@ -1,4 +1,7 @@
|
|||||||
[
|
[
|
||||||
|
"src/data/sceneServices.js",
|
||||||
|
"src/data/sceneServices.test.mjs",
|
||||||
|
"src/data/trackingGesture.js",
|
||||||
".prettierrc.json",
|
".prettierrc.json",
|
||||||
"package.json",
|
"package.json",
|
||||||
"scripts/format-scope.json",
|
"scripts/format-scope.json",
|
||||||
@@ -56,11 +59,11 @@
|
|||||||
"src/app/application.test.mjs",
|
"src/app/application.test.mjs",
|
||||||
"src/app/mapLifecycle.test.mjs",
|
"src/app/mapLifecycle.test.mjs",
|
||||||
"src/app/startupCamera.test.mjs",
|
"src/app/startupCamera.test.mjs",
|
||||||
"src/app/viewer.js",
|
"src/globe/cesium/viewer.js",
|
||||||
"src/app/zoomOutGuard.test.mjs",
|
"src/globe/cesium/zoomOutGuard.test.mjs",
|
||||||
"src/app/controls.test.mjs",
|
"src/app/controls.test.mjs",
|
||||||
"scripts/qa-zoom-out-guard.mjs",
|
"scripts/qa-zoom-out-guard.mjs",
|
||||||
"src/app/atmosphereCompat.test.mjs",
|
"src/globe/cesium/atmosphereCompat.test.mjs",
|
||||||
"src/standalone/application.js",
|
"src/standalone/application.js",
|
||||||
"src/standalone/controls.js",
|
"src/standalone/controls.js",
|
||||||
"src/standalone/data.js",
|
"src/standalone/data.js",
|
||||||
@@ -377,8 +380,10 @@
|
|||||||
"src/layers/military/index.js",
|
"src/layers/military/index.js",
|
||||||
"src/layers/aircraft/index.js",
|
"src/layers/aircraft/index.js",
|
||||||
"src/layers/aircraft/classification.js",
|
"src/layers/aircraft/classification.js",
|
||||||
|
"src/layers/aircraft/tracking.js",
|
||||||
"src/layers/military/ownership.test.mjs",
|
"src/layers/military/ownership.test.mjs",
|
||||||
"src/layers/aircraft/classification.test.mjs",
|
"src/layers/aircraft/classification.test.mjs",
|
||||||
|
"src/layers/aircraft/tracking.test.mjs",
|
||||||
"src/data/aisLiveVessels.js",
|
"src/data/aisLiveVessels.js",
|
||||||
"src/layers/vessels/cards.js",
|
"src/layers/vessels/cards.js",
|
||||||
"src/layers/vessels/evidence.js",
|
"src/layers/vessels/evidence.js",
|
||||||
@@ -484,17 +489,14 @@
|
|||||||
"src/layers/launches/source.test.mjs",
|
"src/layers/launches/source.test.mjs",
|
||||||
"src/layers/launches/ingestion.test.mjs",
|
"src/layers/launches/ingestion.test.mjs",
|
||||||
"src/layers/launches/failedEntry.test.mjs",
|
"src/layers/launches/failedEntry.test.mjs",
|
||||||
"src/layers/installations/lifecycle.js",
|
|
||||||
"src/layers/installations/viewport.js",
|
"src/layers/installations/viewport.js",
|
||||||
"src/layers/installations/controls.js",
|
"src/layers/installations/controls.js",
|
||||||
"src/layers/installations/source.js",
|
"src/layers/installations/source.js",
|
||||||
"src/layers/installations/state.js",
|
"src/layers/installations/state.js",
|
||||||
"src/layers/installations/policy.js",
|
"src/layers/installations/policy.js",
|
||||||
"src/layers/installations/rendering.js",
|
|
||||||
"src/layers/installations/ingestion.js",
|
"src/layers/installations/ingestion.js",
|
||||||
"src/layers/installations/index.js",
|
"src/layers/installations/index.js",
|
||||||
"src/layers/installations/model.js",
|
"src/layers/installations/model.js",
|
||||||
"src/layers/installations/selection.js",
|
|
||||||
"src/layers/awareness/focus.js",
|
"src/layers/awareness/focus.js",
|
||||||
"src/layers/awareness/lifecycle.js",
|
"src/layers/awareness/lifecycle.js",
|
||||||
"src/layers/awareness/dependencies.js",
|
"src/layers/awareness/dependencies.js",
|
||||||
@@ -530,20 +532,17 @@
|
|||||||
"src/layers/traffic/model.js",
|
"src/layers/traffic/model.js",
|
||||||
"src/layers/traffic/timing.js",
|
"src/layers/traffic/timing.js",
|
||||||
"src/layers/traffic/style.js",
|
"src/layers/traffic/style.js",
|
||||||
"src/layers/bikeshare/lifecycle.js",
|
|
||||||
"src/layers/bikeshare/viewport.js",
|
"src/layers/bikeshare/viewport.js",
|
||||||
"src/layers/bikeshare/registry.js",
|
"src/layers/bikeshare/registry.js",
|
||||||
"src/layers/bikeshare/controls.js",
|
"src/layers/bikeshare/controls.js",
|
||||||
"src/layers/bikeshare/source.js",
|
"src/layers/bikeshare/source.js",
|
||||||
"src/layers/bikeshare/state.js",
|
"src/layers/bikeshare/state.js",
|
||||||
"src/layers/bikeshare/policy.js",
|
"src/layers/bikeshare/policy.js",
|
||||||
"src/layers/bikeshare/rendering.js",
|
|
||||||
"src/layers/bikeshare/testing.js",
|
"src/layers/bikeshare/testing.js",
|
||||||
"src/layers/bikeshare/ingestion.js",
|
"src/layers/bikeshare/ingestion.js",
|
||||||
"src/layers/bikeshare/queries.js",
|
"src/layers/bikeshare/queries.js",
|
||||||
"src/layers/bikeshare/index.js",
|
"src/layers/bikeshare/index.js",
|
||||||
"src/layers/bikeshare/model.js",
|
"src/layers/bikeshare/model.js",
|
||||||
"src/layers/bikeshare/selection.js",
|
|
||||||
"src/data/traffic.js",
|
"src/data/traffic.js",
|
||||||
"src/data/bikeshare.js",
|
"src/data/bikeshare.js",
|
||||||
"src/layers/traffic/source.test.mjs",
|
"src/layers/traffic/source.test.mjs",
|
||||||
@@ -555,12 +554,6 @@
|
|||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/frameRateMonitor.test.mjs",
|
"src/ui/frameRateMonitor.test.mjs",
|
||||||
"src/ui/frameRateView.test.mjs",
|
"src/ui/frameRateView.test.mjs",
|
||||||
"src/ui/renderFailure.test.mjs",
|
|
||||||
"src/ui/renderFailureView.test.mjs",
|
|
||||||
"src/ui/renderFailureLoopErrors.test.mjs",
|
|
||||||
"src/ui/renderFailureEventRetry.test.mjs",
|
|
||||||
"src/app/renderFailureInlineRetry.test.mjs",
|
|
||||||
"src/ui/renderFailureSecondLoop.test.mjs",
|
|
||||||
"src/layers/cctv/lifecycle.js",
|
"src/layers/cctv/lifecycle.js",
|
||||||
"src/layers/cctv/controls.js",
|
"src/layers/cctv/controls.js",
|
||||||
"src/layers/cctv/geometry.js",
|
"src/layers/cctv/geometry.js",
|
||||||
@@ -611,9 +604,9 @@
|
|||||||
"src/layers/alpr/index.js",
|
"src/layers/alpr/index.js",
|
||||||
"src/layers/alpr/model.js",
|
"src/layers/alpr/model.js",
|
||||||
"src/layers/alpr/policy.js",
|
"src/layers/alpr/policy.js",
|
||||||
"src/layers/alpr/presentation.js",
|
|
||||||
"src/layers/alpr/visuals.js",
|
"src/layers/alpr/visuals.js",
|
||||||
"src/layers/alpr/overlay.js",
|
"src/layers/alpr/overlay.js",
|
||||||
|
"src/layers/alpr/presentation.js",
|
||||||
"src/layers/alpr/overlay.test.mjs",
|
"src/layers/alpr/overlay.test.mjs",
|
||||||
"src/layers/alpr/source.js",
|
"src/layers/alpr/source.js",
|
||||||
"src/layers/alpr/source.test.mjs",
|
"src/layers/alpr/source.test.mjs",
|
||||||
@@ -644,13 +637,8 @@
|
|||||||
"src/layers/submarineCables/bundledSource.js",
|
"src/layers/submarineCables/bundledSource.js",
|
||||||
"src/layers/submarineCables/geometry.js",
|
"src/layers/submarineCables/geometry.js",
|
||||||
"src/layers/submarineCables/index.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/overlay.js",
|
||||||
"src/layers/submarineCables/policy.js",
|
"src/layers/submarineCables/policy.js",
|
||||||
"src/layers/submarineCables/rendering.js",
|
|
||||||
"src/layers/submarineCables/state.js",
|
|
||||||
"src/layers/submarineCables/surface.js",
|
"src/layers/submarineCables/surface.js",
|
||||||
"src/data/telegeographySubmarineCables.js",
|
"src/data/telegeographySubmarineCables.js",
|
||||||
"src/data/telegeographySubmarineCables.test.mjs",
|
"src/data/telegeographySubmarineCables.test.mjs",
|
||||||
@@ -666,9 +654,9 @@
|
|||||||
"src/maps/credits.js",
|
"src/maps/credits.js",
|
||||||
"src/maps/defaultSources.js",
|
"src/maps/defaultSources.js",
|
||||||
"src/maps/google3d.js",
|
"src/maps/google3d.js",
|
||||||
"src/maps/imagery.js",
|
"src/globe/cesium/mapImagery.js",
|
||||||
"src/maps/registry.js",
|
"src/maps/registry.js",
|
||||||
"src/maps/terrain.js",
|
"src/globe/cesium/terrain.js",
|
||||||
"src/maps/terrainRetry.js",
|
"src/maps/terrainRetry.js",
|
||||||
"src/maps/terrainRetry.test.mjs",
|
"src/maps/terrainRetry.test.mjs",
|
||||||
"src/mapStackController.js",
|
"src/mapStackController.js",
|
||||||
@@ -833,8 +821,6 @@
|
|||||||
"src/data/transitIcons.js",
|
"src/data/transitIcons.js",
|
||||||
"src/data/cameraSensitivity.js",
|
"src/data/cameraSensitivity.js",
|
||||||
"src/data/contactPlayback.js",
|
"src/data/contactPlayback.js",
|
||||||
"src/data/contactTrailRenderer.js",
|
|
||||||
"src/data/contactTrailRenderer.test.mjs",
|
|
||||||
"src/layers/transit/trails.js",
|
"src/layers/transit/trails.js",
|
||||||
"src/data/contactPlayback.test.mjs",
|
"src/data/contactPlayback.test.mjs",
|
||||||
"src/layers/transit/movement.js",
|
"src/layers/transit/movement.js",
|
||||||
@@ -861,7 +847,6 @@
|
|||||||
"src/layers/weather/index.js",
|
"src/layers/weather/index.js",
|
||||||
"src/layers/weather/clock.js",
|
"src/layers/weather/clock.js",
|
||||||
"src/layers/weather/clock.test.mjs",
|
"src/layers/weather/clock.test.mjs",
|
||||||
"src/layers/weather/rendering.js",
|
|
||||||
"src/layers/weather/shellRendering.js",
|
"src/layers/weather/shellRendering.js",
|
||||||
"src/layers/weather/shellRendering.test.mjs",
|
"src/layers/weather/shellRendering.test.mjs",
|
||||||
"src/layers/weather/infraredAlpha.js",
|
"src/layers/weather/infraredAlpha.js",
|
||||||
@@ -870,7 +855,6 @@
|
|||||||
"src/layers/weather/infraredImage.test.mjs",
|
"src/layers/weather/infraredImage.test.mjs",
|
||||||
"src/layers/weather/source.js",
|
"src/layers/weather/source.js",
|
||||||
"src/layers/weather/weather.test.mjs",
|
"src/layers/weather/weather.test.mjs",
|
||||||
"src/layers/weather/rasterTiles.test.mjs",
|
|
||||||
"src/layers/wind/rendering.test.mjs",
|
"src/layers/wind/rendering.test.mjs",
|
||||||
"src/layers/weather/source.test.mjs",
|
"src/layers/weather/source.test.mjs",
|
||||||
"src/data/weatherProxy.test.mjs",
|
"src/data/weatherProxy.test.mjs",
|
||||||
@@ -896,8 +880,6 @@
|
|||||||
"src/ui/railCards.test.mjs",
|
"src/ui/railCards.test.mjs",
|
||||||
"src/ui/railTimeline.js",
|
"src/ui/railTimeline.js",
|
||||||
"src/ui/railTimeline.test.mjs",
|
"src/ui/railTimeline.test.mjs",
|
||||||
"src/layers/weather/imageryOrder.js",
|
|
||||||
"src/layers/weather/imageryOrder.test.mjs",
|
|
||||||
"server/providers/cyclones.js",
|
"server/providers/cyclones.js",
|
||||||
"server/providers/firePerimeters.js",
|
"server/providers/firePerimeters.js",
|
||||||
"server/providers/droneAirspace.js",
|
"server/providers/droneAirspace.js",
|
||||||
@@ -930,7 +912,6 @@
|
|||||||
"src/layers/cyclones/rendering.js",
|
"src/layers/cyclones/rendering.js",
|
||||||
"src/layers/cyclones/labels.js",
|
"src/layers/cyclones/labels.js",
|
||||||
"src/layers/cyclones/source.js",
|
"src/layers/cyclones/source.js",
|
||||||
"src/layers/cyclones/rendering.test.mjs",
|
|
||||||
"src/layers/cyclones/labels.test.mjs",
|
"src/layers/cyclones/labels.test.mjs",
|
||||||
"src/layers/cyclones/index.test.mjs",
|
"src/layers/cyclones/index.test.mjs",
|
||||||
"src/layers/cyclones/source.test.mjs",
|
"src/layers/cyclones/source.test.mjs",
|
||||||
@@ -939,7 +920,6 @@
|
|||||||
"scripts/qa-weather-journey.mjs",
|
"scripts/qa-weather-journey.mjs",
|
||||||
"scripts/qa-weather-swap.mjs",
|
"scripts/qa-weather-swap.mjs",
|
||||||
"scripts/qa-weather-teardown.mjs",
|
"scripts/qa-weather-teardown.mjs",
|
||||||
"src/layers/weather/imageryHost.test.mjs",
|
|
||||||
"src/layers/weather/display.test.mjs",
|
"src/layers/weather/display.test.mjs",
|
||||||
"src/sdr/adsbDecoder.test.mjs",
|
"src/sdr/adsbDecoder.test.mjs",
|
||||||
"src/sdr/controller.test.mjs",
|
"src/sdr/controller.test.mjs",
|
||||||
@@ -964,8 +944,6 @@
|
|||||||
"src/data/modelEligibility.test.mjs",
|
"src/data/modelEligibility.test.mjs",
|
||||||
"src/layers/recentImagery/model.test.mjs",
|
"src/layers/recentImagery/model.test.mjs",
|
||||||
"src/layers/recentImagery/catalog.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/thumbnails.test.mjs",
|
||||||
"src/layers/recentImagery/index.test.mjs",
|
"src/layers/recentImagery/index.test.mjs",
|
||||||
"src/ui/imageryBoxTool.test.mjs",
|
"src/ui/imageryBoxTool.test.mjs",
|
||||||
@@ -1084,6 +1062,7 @@
|
|||||||
"src/layers/traffic/roadModes.test.mjs",
|
"src/layers/traffic/roadModes.test.mjs",
|
||||||
"scripts/qa-traffic-motion.mjs",
|
"scripts/qa-traffic-motion.mjs",
|
||||||
"src/layers/traffic/motion.test.mjs",
|
"src/layers/traffic/motion.test.mjs",
|
||||||
|
"src/layers/traffic/handoff.test.mjs",
|
||||||
"src/layers/traffic/lineRebuild.test.mjs",
|
"src/layers/traffic/lineRebuild.test.mjs",
|
||||||
"scripts/qa-journey-recorder.mjs",
|
"scripts/qa-journey-recorder.mjs",
|
||||||
"scripts/qa-alpr-journey.mjs",
|
"scripts/qa-alpr-journey.mjs",
|
||||||
@@ -1133,7 +1112,7 @@
|
|||||||
"scripts/build-bright-stars.mjs",
|
"scripts/build-bright-stars.mjs",
|
||||||
"scripts/build-milky-way.mjs",
|
"scripts/build-milky-way.mjs",
|
||||||
"src/sky/stars.test.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.test.mjs",
|
||||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||||
"src/tooling/responseCompression.test.mjs",
|
"src/tooling/responseCompression.test.mjs",
|
||||||
@@ -1234,6 +1213,198 @@
|
|||||||
"src/data/headingDegrees.test.mjs",
|
"src/data/headingDegrees.test.mjs",
|
||||||
"src/data/distanceFormat.test.mjs",
|
"src/data/distanceFormat.test.mjs",
|
||||||
"src/sources/adsbFields.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.js",
|
||||||
"src/data/firmsIntensity.test.mjs",
|
"src/data/firmsIntensity.test.mjs",
|
||||||
"src/ui/layerPanel/RowSwitch.vue",
|
"src/ui/layerPanel/RowSwitch.vue",
|
||||||
@@ -1385,6 +1556,170 @@
|
|||||||
"src/layers/wildlifeSightings/density.test.mjs",
|
"src/layers/wildlifeSightings/density.test.mjs",
|
||||||
"src/layers/wildlifeSightings/selection.test.mjs",
|
"src/layers/wildlifeSightings/selection.test.mjs",
|
||||||
"server/providers/inaturalist/proxy.fixture.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",
|
"scripts/build-owner-flags.mjs",
|
||||||
"src/ui/ownerFlagSheet.js",
|
"src/ui/ownerFlagSheet.js",
|
||||||
"src/data/placePacksLoading.test.mjs",
|
"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 { parsers } from 'prettier/plugins/babel.mjs';
|
||||||
|
import { vueScripts } from './vue-scripts.mjs';
|
||||||
|
|
||||||
const browserGlobals = new Set([
|
const browserGlobals = new Set([
|
||||||
'window',
|
'window',
|
||||||
@@ -15,9 +16,14 @@ const browserGlobals = new Set([
|
|||||||
'getComputedStyle',
|
'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 imports = [];
|
||||||
|
const staticImports = [];
|
||||||
const browser = new Set();
|
const browser = new Set();
|
||||||
function walk(node, parent, field) {
|
function walk(node, parent, field) {
|
||||||
if (!node || typeof node !== 'object') return;
|
if (!node || typeof node !== 'object') return;
|
||||||
@@ -35,6 +41,8 @@ export function analyzeModule(source) {
|
|||||||
'Computed module imports are not allowed in runtime code',
|
'Computed module imports are not allowed in runtime code',
|
||||||
);
|
);
|
||||||
imports.push(node.source.value);
|
imports.push(node.source.value);
|
||||||
|
if (node.type !== 'ImportExpression')
|
||||||
|
staticImports.push(node.source.value);
|
||||||
}
|
}
|
||||||
if (node.type === 'CallExpression' && node.callee?.name === 'require')
|
if (node.type === 'CallExpression' && node.callee?.name === 'require')
|
||||||
throw new Error('Runtime modules must use ES imports');
|
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);
|
else if (value && typeof value === 'object') walk(value, node, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(parsers.babel.parse(source));
|
for (const script of filename.endsWith('.vue')
|
||||||
return { imports, browser: [...browser] };
|
? vueScripts(source, filename)
|
||||||
|
: [source])
|
||||||
|
walk(parsers.babel.parse(script));
|
||||||
|
return {
|
||||||
|
imports,
|
||||||
|
browser: [...browser],
|
||||||
|
...(dynamic && { staticImports }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return static imports, literal dynamic imports and re-exports. */
|
/** Return static imports, literal dynamic imports and re-exports. */
|
||||||
|
|||||||
+742
-1837
File diff suppressed because it is too large.
Load diff
@@ -6,12 +6,13 @@
|
|||||||
* native dispatch/disabled/teardown without providers or world calibration writes.
|
* native dispatch/disabled/teardown without providers or world calibration writes.
|
||||||
* Real Scene authoring/playback remains covered by qa-scene-controls.mjs.
|
* 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 fs from 'node:fs';
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
args: [],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
@@ -34,11 +35,10 @@ try {
|
|||||||
{ waitUntil: 'domcontentloaded' },
|
{ waitUntil: 'domcontentloaded' },
|
||||||
);
|
);
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() =>
|
() => window.__godsEyeView?.styleManager?._cctvControls,
|
||||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
|
||||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
|
await whenSettled(page);
|
||||||
const inventory = await page.evaluate(() => {
|
const inventory = await page.evaluate(() => {
|
||||||
const ids = [
|
const ids = [
|
||||||
'scene-new-btn',
|
'scene-new-btn',
|
||||||
@@ -304,11 +304,10 @@ try {
|
|||||||
localized.searchParams.set('lang', 'ru');
|
localized.searchParams.set('lang', 'ru');
|
||||||
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
await page.goto(localized.href, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() =>
|
() => window.__godsEyeView?.styleManager?._cctvControls,
|
||||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
|
||||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
|
await whenSettled(page);
|
||||||
for (const panel of ['scene-panel', 'cctv-panel']) {
|
for (const panel of ['scene-panel', 'cctv-panel']) {
|
||||||
await page.evaluate((panel) => {
|
await page.evaluate((panel) => {
|
||||||
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
|
||||||
@@ -329,7 +328,7 @@ try {
|
|||||||
}
|
}
|
||||||
check('no uncaught browser errors', errors.length === 0, errors);
|
check('no uncaught browser errors', errors.length === 0, errors);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
console.log(`RESULT: ${failures} failures`);
|
console.log(`RESULT: ${failures} failures`);
|
||||||
process.exitCode = failures ? 1 : 0;
|
process.exitCode = failures ? 1 : 0;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
* qa-admin-outlines.mjs — bundled state/province/county outlines, driven like a
|
||||||
* user would drive them.
|
* user would drive them.
|
||||||
@@ -23,11 +24,12 @@
|
|||||||
* timings, frame-time p50/p95 and still-camera frame-difference statistics.
|
* timings, frame-time p50/p95 and still-camera frame-difference statistics.
|
||||||
* Exits nonzero on any failed assertion.
|
* 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 assert from 'node:assert/strict';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -63,12 +65,11 @@ const result = {
|
|||||||
video: null,
|
video: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: !args.includes('--headful'),
|
headless: true,
|
||||||
protocolTimeout: 300_000,
|
protocolTimeout: 300_000,
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--use-gl=angle',
|
|
||||||
'--disable-dev-shm-usage',
|
'--disable-dev-shm-usage',
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
'--disable-renderer-backgrounding',
|
'--disable-renderer-backgrounding',
|
||||||
@@ -145,14 +146,10 @@ try {
|
|||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.annotations,
|
() => window.__godsEyeView?.engine && window.__godsEyeView?.annotations,
|
||||||
{ timeout: 90_000, polling: 500 },
|
{ timeout: 90_000, polling: 500 },
|
||||||
);
|
);
|
||||||
await page.waitForFunction(
|
await whenSettled(page);
|
||||||
() =>
|
|
||||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
||||||
{ timeout: 90_000, polling: 250 },
|
|
||||||
);
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||||
);
|
);
|
||||||
@@ -162,7 +159,7 @@ try {
|
|||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
);
|
);
|
||||||
result.renderer = await page.evaluate(() => {
|
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');
|
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
||||||
return debug
|
return debug
|
||||||
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
|
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
|
||||||
@@ -177,7 +174,7 @@ try {
|
|||||||
window.__qaOutlines = {
|
window.__qaOutlines = {
|
||||||
fly(view, duration) {
|
fly(view, duration) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
viewer.camera.cancelFlight();
|
window.__godsEyeView.engine.camera.cancelFlight();
|
||||||
viewer.camera.flyTo({
|
viewer.camera.flyTo({
|
||||||
destination: Cesium.Cartesian3.fromDegrees(
|
destination: Cesium.Cartesian3.fromDegrees(
|
||||||
view.lon,
|
view.lon,
|
||||||
@@ -264,20 +261,7 @@ try {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const settle = () =>
|
const settle = () =>
|
||||||
page
|
whenTilesLoaded(page, { timeoutMs: 60_000 }).catch(() => {});
|
||||||
.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(() => {});
|
|
||||||
|
|
||||||
async function shot(name) {
|
async function shot(name) {
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
@@ -317,11 +301,14 @@ try {
|
|||||||
// built the mark's primitives (each tick updates them before render).
|
// built the mark's primitives (each tick updates them before render).
|
||||||
const { viewer } = window.__godsEyeView;
|
const { viewer } = window.__godsEyeView;
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const off = viewer.scene.postRender.addEventListener(() => {
|
const off =
|
||||||
if (!viewer.dataSourceDisplay.ready) return;
|
window.__godsEyeView.engine.frames.postRender.addEventListener(
|
||||||
off();
|
() => {
|
||||||
resolve();
|
if (!viewer.dataSourceDisplay.ready) return;
|
||||||
});
|
off();
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const drawnMs = performance.now() - started;
|
const drawnMs = performance.now() - started;
|
||||||
const mark = window.__godsEyeView.annotations
|
const mark = window.__godsEyeView.annotations
|
||||||
@@ -343,7 +330,7 @@ try {
|
|||||||
target,
|
target,
|
||||||
flyTo,
|
flyTo,
|
||||||
);
|
);
|
||||||
if (flyTo) await new Promise((r) => setTimeout(r, 2200));
|
if (flyTo) await whenSettled(page);
|
||||||
await settle();
|
await settle();
|
||||||
// Hold still: the drawn mark must not rebuild or grow.
|
// Hold still: the drawn mark must not rebuild or grow.
|
||||||
marker = `still:${name}`;
|
marker = `still:${name}`;
|
||||||
@@ -551,7 +538,7 @@ try {
|
|||||||
.screenshot({ path: path.join(shots, 'admin-outlines-failure.png') })
|
.screenshot({ path: path.join(shots, 'admin-outlines-failure.png') })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recordVideo && frames.length > 1) {
|
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.
|
* 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
|
* Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const getOpt = (name, dflt) => {
|
const getOpt = (name, dflt) => {
|
||||||
@@ -27,19 +29,13 @@ function record(name, ok, detail) {
|
|||||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
}
|
}
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
let exitCode = 0;
|
let exitCode = 0;
|
||||||
const consoleErrors = [];
|
const consoleErrors = [];
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: 'new',
|
headless: true,
|
||||||
args: [
|
args: ['--enable-unsafe-swiftshader', '--no-sandbox', '--disable-dev-shm-usage'],
|
||||||
'--use-gl=swiftshader',
|
|
||||||
'--enable-unsafe-swiftshader',
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
],
|
|
||||||
defaultViewport: { width: 1400, height: 900 },
|
defaultViewport: { width: 1400, height: 900 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,10 +48,10 @@ async function main() {
|
|||||||
|
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
() => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
|
||||||
{ timeout: 90000 },
|
{ timeout: 90000 },
|
||||||
);
|
);
|
||||||
await sleep(1500);
|
await whenSettled(page);
|
||||||
|
|
||||||
const stats = await page.evaluate(async () => {
|
const stats = await page.evaluate(async () => {
|
||||||
const dm = window.__godsEyeView.dataManager;
|
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
|
// 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,
|
// over Canada before asserting on it — the default view is over Austin,
|
||||||
// where an empty in-view list is correct and proves nothing.
|
// where an empty in-view list is correct and proves nothing.
|
||||||
await page.evaluate(() => {
|
await evaluateWithGeo(page, async (qaGeo) => {
|
||||||
const gev = window.__godsEyeView;
|
|
||||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
|
||||||
const d2r = Math.PI / 180;
|
const d2r = Math.PI / 180;
|
||||||
try {
|
try {
|
||||||
gev.viewer.camera.cancelFlight();
|
window.__godsEyeView.engine.camera.cancelFlight();
|
||||||
} catch {
|
} catch {
|
||||||
/* no flight in progress */
|
/* no flight in progress */
|
||||||
}
|
}
|
||||||
gev.viewer.camera.setView({
|
window.__godsEyeView.engine.camera.setView({
|
||||||
destination: ellipsoid.cartographicToCartesian({
|
destination: qaGeo.cartographic.toCartesian({
|
||||||
longitude: -100 * d2r,
|
longitude: -100 * d2r,
|
||||||
latitude: 55 * d2r,
|
latitude: 55 * d2r,
|
||||||
height: 4_000_000,
|
height: 4_000_000,
|
||||||
}),
|
}),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
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(
|
const controls = await page.evaluate(
|
||||||
() =>
|
() =>
|
||||||
@@ -154,7 +148,7 @@ async function main() {
|
|||||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||||
exitCode = 3;
|
exitCode = 3;
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pass = results.filter((r) => r.ok === true).length;
|
const pass = results.filter((r) => r.ok === true).length;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/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).
|
* (path-derived, rate-limited display course; Batch 3 overnight run).
|
||||||
*
|
*
|
||||||
* The permanent track-regression harness flies planes STRAIGHT, so it cannot
|
* 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
|
* 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
|
* rate limiter advanced — postRender sampling skews apparent rates by the
|
||||||
* variable render-pass duration (~40% at SwiftShader's ~3 fps). The course
|
* variable render-pass duration (~40% at SwiftShader's ~3 fps). The course
|
||||||
* is derived from the tracked standalone Cesium.Model's modelMatrix (the
|
* is derived from the tracked model's heading-pitch-roll frame (the layer's
|
||||||
* actually-rendered transform), inverting _modelMatrix: with pitch=roll=0
|
* 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) →
|
* 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).
|
* 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
|
* The 15 s fix cadence × 3°/s turn = 45° course step per interpolation
|
||||||
@@ -44,7 +46,7 @@
|
|||||||
* normalized nose -X, offset 180°).
|
* normalized nose -X, offset 180°).
|
||||||
*
|
*
|
||||||
* VISUAL — with the turning flights plane tracked in 3D, capture screenshots
|
* 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.
|
* the nose must lead the curved trail, never sideways/backwards.
|
||||||
*
|
*
|
||||||
* LOW-SPEED (heading-v2, 2026-07-03 field-test regression) — two extra
|
* 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
|
* exceeds 15°/s (truth is 2°/s; the pre-fix snap replayed ~120° of frozen
|
||||||
* divergence at the 60°/s slew cap).
|
* 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.
|
* 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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
import { installNavaraHeadingProbe } from './qa-heading-navara.mjs';
|
||||||
import { stubPlanespotters } from './qa-photo-stub.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;
|
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 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 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
|
// PASS/FAIL reporting
|
||||||
@@ -149,7 +140,7 @@ function record(name, ok, detail) {
|
|||||||
|
|
||||||
async function observeTrackedHostPaint(page, timeoutMs = 5000) {
|
async function observeTrackedHostPaint(page, timeoutMs = 5000) {
|
||||||
return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => {
|
return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => {
|
||||||
const viewer = window.__godsEyeView?.viewer;
|
const viewer = (window.__headingView ?? window.__godsEyeView?.engine);
|
||||||
const scene = viewer?.scene;
|
const scene = viewer?.scene;
|
||||||
const history = [];
|
const history = [];
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
@@ -447,20 +438,9 @@ async function main() {
|
|||||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||||
fs.mkdirSync(SHOT_DIR_V2, { recursive: true });
|
fs.mkdirSync(SHOT_DIR_V2, { recursive: true });
|
||||||
|
|
||||||
const chromeExecutable = findChromeExecutable();
|
const browser = await launchBrowser({
|
||||||
const browser = await puppeteer.launch({
|
headless: true,
|
||||||
headless: HEADFUL ? false : 'new',
|
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'],
|
||||||
...(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 consoleErrors = [];
|
const consoleErrors = [];
|
||||||
@@ -482,6 +462,7 @@ async function main() {
|
|||||||
|
|
||||||
// ---- Turning-plane fetch shim (installed before any app code runs) ------
|
// ---- Turning-plane fetch shim (installed before any app code runs) ------
|
||||||
await page.evaluateOnNewDocument((turn) => {
|
await page.evaluateOnNewDocument((turn) => {
|
||||||
|
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||||
window.__TURN = turn;
|
window.__TURN = turn;
|
||||||
window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time
|
window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time
|
||||||
window.__TURN_HITS = { opensky: 0, mil: 0 };
|
window.__TURN_HITS = { opensky: 0, mil: 0 };
|
||||||
@@ -576,58 +557,43 @@ async function main() {
|
|||||||
console.log('Loading app...');
|
console.log('Loading app...');
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
|
||||||
{ timeout: 60000, polling: 200 }
|
{ 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.');
|
console.log(' App globals ready.');
|
||||||
|
|
||||||
// ---- Model finder + course derivation, installed once ------------------
|
// ---- Model finder + course derivation, installed once ------------------
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
// Every sampler in this harness targets a KNOWN aircraft, and both the
|
// Every sampler in this harness targets a KNOWN aircraft, and both the
|
||||||
// fleet models and the tracked standalone model carry id=icao — so the
|
// fleet models and the tracked model carry id=icao — so the finder
|
||||||
// finder selects by id when given one. (The old nearest-to-tracked
|
// selects by id. The models draw through the globe contract (engine
|
||||||
// fallback could mis-pick ANOTHER plane's model for a frame whenever
|
// model movers), on Cesium as on Navara; the layer's drawn rows
|
||||||
// the tracked display position read null, which injected a one-frame
|
// (window.__godsEyeView.layers.drawn, src/layers/aircraft/qaDrawn.js)
|
||||||
// ~90° course spike into the sampled series — a measurement flake, not
|
// give each model's heading-pitch-roll frame, the matrix the course
|
||||||
// a product regression.)
|
// 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) {
|
window.__findTrackedModel = function (icao) {
|
||||||
const v = window.__godsEyeView.viewer;
|
const rows = ['flights', 'military']
|
||||||
const out = [];
|
.flatMap((layer) => window.__godsEyeView.layers.drawn(layer) ?? [])
|
||||||
const walk = (coll) => {
|
.filter((row) => row.id === icao);
|
||||||
const n = coll.length;
|
const row =
|
||||||
for (let i = 0; i < n; i++) {
|
rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ??
|
||||||
const p = coll.get(i);
|
rows.find((r) => r.model?.ready && r.model.show);
|
||||||
if (!p) continue;
|
return row
|
||||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
? { id: icao, ready: row.model.ready, show: row.model.show, modelMatrix: row.model.matrix }
|
||||||
if (p.modelMatrix && typeof p.ready !== 'undefined') {
|
: null;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0)
|
// Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0)
|
||||||
// → h = atan2(−x·north, x·east); world course = h − headingOffsetDeg.
|
// → h = atan2(−x·north, x·east); world course = h − headingOffsetDeg.
|
||||||
window.__courseFromModelMatrix = function (mm, 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 C3 = v.camera.position.constructor;
|
||||||
const Carto = v.camera.positionCartographic.constructor;
|
const Carto = v.camera.positionCartographic.constructor;
|
||||||
const carto = Carto.fromCartesian(new C3(mm[12], mm[13], mm[14]));
|
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
|
// preUpdate/preRender listeners and reads the rendered model matrix, but
|
||||||
// does not depend on ambient render-loop scheduling for sample count.
|
// does not depend on ambient render-loop scheduling for sample count.
|
||||||
window.__sampleModelCourse = async function (icao, headingOffsetDeg, windowMs, stepMs = 100) {
|
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 tileset = window.__godsEyeView.tileset;
|
||||||
const out = [];
|
const out = [];
|
||||||
const start = performance.now();
|
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 ------
|
// ---- Enable layers with fixes back-dated 32 s, then prime forward ------
|
||||||
// First update() fires inside setEnabled, so the FIRST fix must already be
|
// First update() fires inside setEnabled, so the FIRST fix must already be
|
||||||
// the oldest (history appends only monotonically-newer fix times).
|
// the oldest (history appends only monotonically-newer fix times).
|
||||||
console.log('Priming turning history through the render delay (30 s / 15 s)...');
|
console.log('Priming turning history through the render delay (30 s / 15 s)...');
|
||||||
const primed = await page.evaluate(async () => {
|
const primed = await page.evaluate(async () => {
|
||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||||
window.__TURN.timeOffsetSec = -32;
|
window.__TURN.timeOffsetSec = -32;
|
||||||
await dm.setEnabled('flights', true);
|
await dm.setEnabled('flights', true);
|
||||||
await dm.setEnabled('military', true);
|
await dm.setEnabled('military', true);
|
||||||
@@ -728,11 +696,14 @@ async function main() {
|
|||||||
|
|
||||||
const flModelUp = await page.waitForFunction((icao) => {
|
const flModelUp = await page.waitForFunction((icao) => {
|
||||||
const m = window.__findTrackedModel(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);
|
}, { 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');
|
record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready');
|
||||||
if (!flModelUp) { finish(); return; }
|
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 readoutObservation = await observeTrackedHostPaint(page);
|
||||||
const readoutHost = await page.evaluate(() => ({
|
const readoutHost = await page.evaluate(() => ({
|
||||||
@@ -778,25 +749,25 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Screenshots: two zoom levels × two orbit angles --------------------
|
// ---- 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(() => {
|
await page.evaluate(() => {
|
||||||
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
|
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
|
||||||
});
|
});
|
||||||
await sleep(800);
|
await whenFramesDrawn(page);
|
||||||
const shots = [
|
const shots = [
|
||||||
['flights-far-orbitA.png', async () => { /* default follow framing */ }],
|
['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(() => {
|
['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,
|
// Synthetic cruise frame settles ~5.6 km out; leave ~430 m so model,
|
||||||
// readout anchor, tracking line, and trail can be judged close-up.
|
// readout anchor, tracking line, and trail can be judged close-up.
|
||||||
cam.rotateRight(-1.9); cam.zoomIn(5200);
|
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) {
|
for (const [name, move] of shots) {
|
||||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
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) });
|
await page.screenshot({ path: path.join(SHOT_DIR, name) });
|
||||||
console.log(` saved ${name}`);
|
console.log(` saved ${name}`);
|
||||||
}
|
}
|
||||||
@@ -814,11 +785,14 @@ async function main() {
|
|||||||
|
|
||||||
const milModelUp = await page.waitForFunction((hex) => {
|
const milModelUp = await page.waitForFunction((hex) => {
|
||||||
const m = window.__findTrackedModel(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);
|
}, { 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');
|
record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready');
|
||||||
if (milModelUp) {
|
if (milModelUp) {
|
||||||
await sleep(1500);
|
await whenFramesDrawn(page);
|
||||||
const milSamples = await page.evaluate(
|
const milSamples = await page.evaluate(
|
||||||
(icao) => window.__sampleModelCourse(icao, 180, 40000),
|
(icao) => window.__sampleModelCourse(icao, 180, 40000),
|
||||||
TURN.military[0].hex,
|
TURN.military[0].hex,
|
||||||
@@ -840,21 +814,21 @@ async function main() {
|
|||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
|
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
|
||||||
});
|
});
|
||||||
await sleep(800);
|
await whenFramesDrawn(page);
|
||||||
for (const [name, move] of [
|
for (const [name, move] of [
|
||||||
['military-orbitA.png', async () => { }],
|
['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(() => {
|
['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.
|
// 12,000 ft synthetic frame settles ~6.3 km out; leave ~630 m.
|
||||||
cam.rotateLeft(1.6); cam.zoomIn(5700);
|
cam.rotateLeft(1.6); cam.zoomIn(5700);
|
||||||
})],
|
})],
|
||||||
['military-near-orbitB.png', () => page.evaluate(() => {
|
['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})`); }
|
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) });
|
await page.screenshot({ path: path.join(SHOT_DIR, name) });
|
||||||
console.log(` saved ${name}`);
|
console.log(` saved ${name}`);
|
||||||
}
|
}
|
||||||
@@ -879,7 +853,10 @@ async function main() {
|
|||||||
}, icao);
|
}, icao);
|
||||||
const up = await page.waitForFunction((id) => {
|
const up = await page.waitForFunction((id) => {
|
||||||
const m = window.__findTrackedModel(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);
|
}, { 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');
|
record(`${label}: tracked 3D model rendered (ready+shown)`, up, up ? 'model up' : 'model never became ready');
|
||||||
return up;
|
return up;
|
||||||
@@ -888,7 +865,7 @@ async function main() {
|
|||||||
// ---- Phase 3 — hovering helicopter --------------------------------------
|
// ---- Phase 3 — hovering helicopter --------------------------------------
|
||||||
console.log('\nPhase 3 — hovering helicopter (klass=helicopter, drift ~4 m, track flips ±45°)');
|
console.log('\nPhase 3 — hovering helicopter (klass=helicopter, drift ~4 m, track flips ±45°)');
|
||||||
if (await trackFlightsAndWaitModel(TURN.hover.icao, 'hover-heli')) {
|
if (await trackFlightsAndWaitModel(TURN.hover.icao, 'hover-heli')) {
|
||||||
await sleep(1500);
|
await whenFramesDrawn(page);
|
||||||
const heliSamples = await sampleCourse(180, 35000, TURN.hover.icao);
|
const heliSamples = await sampleCourse(180, 35000, TURN.hover.icao);
|
||||||
analyzeHover('hover-heli', heliSamples, {});
|
analyzeHover('hover-heli', heliSamples, {});
|
||||||
for (const [name, waitMs] of [['hover-heli-T0.png', 0], ['hover-heli-T8s.png', 8000]]) {
|
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 --------------
|
// ---- 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)');
|
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')) {
|
if (await trackFlightsAndWaitModel(TURN.slow.icao, 'slow-turn')) {
|
||||||
await sleep(1500);
|
await whenFramesDrawn(page);
|
||||||
const slowSamples = await sampleCourse(180, 40000, TURN.slow.icao);
|
const slowSamples = await sampleCourse(180, 40000, TURN.slow.icao);
|
||||||
analyzeSlowTurn('slow-turn', slowSamples, { scoreProgress: false });
|
analyzeSlowTurn('slow-turn', slowSamples, { scoreProgress: false });
|
||||||
// Exercise the production dt clamp with render gaps above
|
// Exercise the production dt clamp with render gaps above
|
||||||
@@ -916,10 +893,10 @@ async function main() {
|
|||||||
});
|
});
|
||||||
for (const [name, move] of [
|
for (const [name, move] of [
|
||||||
['slowturn-far.png', async () => { /* default follow framing */ }],
|
['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})`); }
|
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) });
|
await page.screenshot({ path: path.join(SHOT_DIR_V2, name) });
|
||||||
console.log(` saved ${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)');
|
console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__findModelByIcao = function (icao) {
|
window.__findModelByIcao = window.__findTrackedModel;
|
||||||
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;
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate the heli in the feed now (kept out of phases 1–4), ingest it,
|
// 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;
|
window.__TURN.heli65.active = true;
|
||||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||||
fl.setParams({ models3d: false });
|
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 sleep(4000); // several fleet ticks establish the heli's fleet course
|
||||||
await page.evaluate((icao) => {
|
await page.evaluate((icao) => {
|
||||||
@@ -968,7 +930,7 @@ async function main() {
|
|||||||
const heliTracked = await page.waitForFunction((icao) => {
|
const heliTracked = await page.waitForFunction((icao) => {
|
||||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||||
const ti = fl.getTrackedInfo();
|
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);
|
}, { timeout: 15000, polling: 250 }, TURN.heli65.icao).then(() => true).catch(() => false);
|
||||||
record('heli65: tracking engaged (2D billboard mode)', heliTracked,
|
record('heli65: tracking engaged (2D billboard mode)', heliTracked,
|
||||||
heliTracked ? 'tracked' : 'tracking never engaged');
|
heliTracked ? 'tracked' : 'tracking never engaged');
|
||||||
@@ -985,9 +947,10 @@ async function main() {
|
|||||||
// rotation after. The release-in-place keeps the camera static, so
|
// rotation after. The release-in-place keeps the camera static, so
|
||||||
// rotation deltas across the boundary are course deltas.
|
// rotation deltas across the boundary are course deltas.
|
||||||
const c1 = await page.evaluate(async ({ icao, actionAtMs, windowMs }) => {
|
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 fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||||
const findFleetBillboard = (id) => {
|
const findFleetBillboard = (id) => {
|
||||||
|
if (window.__headingView) return window.__godsEyeView.layers.drawn('flights').find((row) => row.id === id && row.show);
|
||||||
const out = [];
|
const out = [];
|
||||||
const walk = (coll) => {
|
const walk = (coll) => {
|
||||||
const n = coll.length;
|
const n = coll.length;
|
||||||
@@ -1039,7 +1002,16 @@ async function main() {
|
|||||||
window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true });
|
window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true });
|
||||||
});
|
});
|
||||||
const fleetModelUp = await page.evaluate(async (icao) => {
|
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 tileset = window.__godsEyeView.tileset;
|
||||||
const priorDefaultLoop = v.useDefaultRenderLoop;
|
const priorDefaultLoop = v.useDefaultRenderLoop;
|
||||||
const priorTilesetShow = tileset?.show;
|
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');
|
record('heli65 C2 (click): fleet model rendered before re-track', false, 'fleet model never became ready');
|
||||||
} else {
|
} else {
|
||||||
const c2 = await page.evaluate(async ({ icao, offsetDeg, actionAtMs, windowMs }) => {
|
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 fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||||
const out = [];
|
const out = [];
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
@@ -1098,7 +1070,7 @@ async function main() {
|
|||||||
|
|
||||||
finish();
|
finish();
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
|
|
||||||
function finish() {
|
function finish() {
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/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).
|
* (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5).
|
||||||
*
|
*
|
||||||
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
|
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
|
||||||
* classes — the flights layer via OpenSky extended categories (state[17]),
|
* classes — the flights layer via OpenSky extended categories (state[17]),
|
||||||
* the military layer via adsb.lol type codes — using the same fetch-shim +
|
* 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).
|
* serve time so every fix is self-consistent through the render delay).
|
||||||
*
|
*
|
||||||
* Machine assertions:
|
* Machine assertions:
|
||||||
@@ -15,17 +15,17 @@
|
|||||||
* per-class glyph (image data-URI === aircraftIcon(klass),
|
* per-class glyph (image data-URI === aircraftIcon(klass),
|
||||||
* imported from src) and per-class scale (flights:
|
* imported from src) and per-class scale (flights:
|
||||||
* CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D)
|
* 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)
|
* MODEL_SCALE (both 1; legacy scale is baked into the GLB)
|
||||||
* × CLASS_SCALE_3D[klass] per icao; tracked too
|
* × CLASS_SCALE_3D[klass] per icao; tracked too
|
||||||
* B4 clean : no console errors
|
* 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
|
* 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
|
* zooms per layer showing per-class model scale differences. The PNGs are the
|
||||||
* acceptance test — read them.
|
* 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.
|
* 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';
|
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 argv = process.argv.slice(2);
|
||||||
const getFlag = (name) => argv.includes(name);
|
const getFlag = (name) => argv.includes(name);
|
||||||
@@ -54,9 +54,15 @@ const getOpt = (name, dflt) => {
|
|||||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : 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 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 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
|
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: '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 },
|
{ 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
|
// 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 },
|
{ 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
|
// 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
|
// real-world-scale asset (native bounding radius ~29.8) rendered at
|
||||||
@@ -174,7 +180,7 @@ async function main() {
|
|||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-setuid-sandbox',
|
'--disable-setuid-sandbox',
|
||||||
'--use-gl=angle',
|
'--use-gl=angle',
|
||||||
'--use-angle=swiftshader',
|
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||||
'--disable-dev-shm-usage',
|
'--disable-dev-shm-usage',
|
||||||
'--disable-web-security',
|
'--disable-web-security',
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
@@ -202,6 +208,7 @@ async function main() {
|
|||||||
|
|
||||||
// ---- Straight-flight fetch shim (installed before any app code runs) ----
|
// ---- Straight-flight fetch shim (installed before any app code runs) ----
|
||||||
await page.evaluateOnNewDocument((spec) => {
|
await page.evaluateOnNewDocument((spec) => {
|
||||||
|
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||||
window.__SPR = spec;
|
window.__SPR = spec;
|
||||||
window.__SPR.epochMs = Date.now();
|
window.__SPR.epochMs = Date.now();
|
||||||
window.__SPR_HITS = { opensky: 0, mil: 0 };
|
window.__SPR_HITS = { opensky: 0, mil: 0 };
|
||||||
@@ -277,52 +284,33 @@ async function main() {
|
|||||||
console.log('Loading app...');
|
console.log('Loading app...');
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
|
||||||
{ timeout: 60000, polling: 200 }
|
// 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.');
|
console.log(' App globals ready.');
|
||||||
|
|
||||||
// ---- In-page probes: billboard walk, model walk, tracked-model finder ---
|
// ---- In-page probes: billboard walk, model walk, tracked-model finder ---
|
||||||
await page.evaluate(() => {
|
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 () {
|
window.__collectBillboards = function () {
|
||||||
const v = window.__godsEyeView.viewer;
|
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||||
const out = [];
|
.filter((row) => !row.trackedModel)
|
||||||
const walk = (coll) => {
|
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
// 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 () {
|
window.__collectModels = function () {
|
||||||
const v = window.__godsEyeView.viewer;
|
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||||
const out = [];
|
.filter((row) => row.model && (row.trackedModel || !row.tracked))
|
||||||
const walk = (coll) => {
|
.map(({ id, model }) => ({ id, ...model, radius: null }));
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
// Displayed (render-delayed) latitude of a synthetic plane right now.
|
// Displayed (render-delayed) latitude of a synthetic plane right now.
|
||||||
window.__displayedState = function (layer, idx, renderDelaySec) {
|
window.__displayedState = function (layer, idx, renderDelaySec) {
|
||||||
@@ -333,43 +321,46 @@ async function main() {
|
|||||||
};
|
};
|
||||||
// Canvas coordinates of every synthetic plane at its displayed position —
|
// Canvas coordinates of every synthetic plane at its displayed position —
|
||||||
// ground truth for identifying which glyph is which in a screenshot.
|
// ground truth for identifying which glyph is which in a screenshot.
|
||||||
window.__planeCanvasCoords = function (layer, renderDelaySec, altOf) {
|
window.__planeCanvasCoords = async function (layer, renderDelaySec, altOf) {
|
||||||
const v = window.__godsEyeView.viewer;
|
const geo = await import('/src/geo/index.js');
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
const S = window.__SPR;
|
const S = window.__SPR;
|
||||||
const C3 = v.camera.position.constructor;
|
|
||||||
return S[layer].map((p, i) => {
|
return S[layer].map((p, i) => {
|
||||||
const s = window.__displayedState(layer, i, renderDelaySec);
|
const s = window.__displayedState(layer, i, renderDelaySec);
|
||||||
const pos = C3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
const pos = geo.cartesian3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
||||||
const c = v.scene.cartesianToCanvasCoordinates(pos);
|
const c = engine.projector.worldToWindow(pos);
|
||||||
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
|
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
|
||||||
}).join(' ');
|
}).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)...');
|
console.log('Priming straight-flight history through the render delay (30 s / 15 s)...');
|
||||||
const primed = await page.evaluate(async () => {
|
const primed = await page.evaluate(async () => {
|
||||||
const dm = window.__godsEyeView.dataManager;
|
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;
|
window.__SPR.timeOffsetSec = -32;
|
||||||
await dm.setEnabled('flights', true);
|
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 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]) {
|
for (const off of [-24, -16, -8, 0]) {
|
||||||
window.__SPR.timeOffsetSec = off;
|
window.__SPR.timeOffsetSec = off;
|
||||||
await fl.update(v);
|
await fl.update(v);
|
||||||
await mil.update(v);
|
await mil?.update(v);
|
||||||
}
|
}
|
||||||
window.__SPR.timeOffsetSec = 0;
|
window.__SPR.timeOffsetSec = 0;
|
||||||
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil.update(v); }, 15000);
|
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil?.update(v); }, 15000);
|
||||||
return { fl: fl.getStats().count, mil: mil.getStats().count, hits: window.__SPR_HITS };
|
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}`);
|
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',
|
record('B1 ingest: synthetic planes in both layers',
|
||||||
primed.fl === SPRITES.flights.length && primed.mil === SPRITES.military.length,
|
primed.fl === SPRITES.flights.length && (!hasMilitary || primed.mil === SPRITES.military.length),
|
||||||
`flights=${primed.fl}/${SPRITES.flights.length} military=${primed.mil}/${SPRITES.military.length}`);
|
`flights=${primed.fl}/${SPRITES.flights.length} military=${hasMilitary ? `${primed.mil}/${SPRITES.military.length}` : 'n/a'}`);
|
||||||
if (!(primed.fl > 0 && primed.mil > 0)) { finish(); return; }
|
if (!(primed.fl > 0 && (!hasMilitary || primed.mil > 0))) { finish(); return; }
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// B2 — billboard glyph + scale per class (machine check)
|
// B2 — billboard glyph + scale per class (machine check)
|
||||||
@@ -381,37 +372,38 @@ async function main() {
|
|||||||
let scaleBad = [];
|
let scaleBad = [];
|
||||||
for (const [id, klass] of expectedFlights) {
|
for (const [id, klass] of expectedFlights) {
|
||||||
const bb = bbById.get(id);
|
const bb = bbById.get(id);
|
||||||
const wantImg = aircraftIcon(klass);
|
const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
|
||||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
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}`);
|
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 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);
|
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}`);
|
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)',
|
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',
|
record('B2b: every synthetic billboard has its class scale',
|
||||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact');
|
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact');
|
||||||
const distinctGlyphs = new Set([...expectedFlights.values(), ...expectedMilitary.values()].map((k) => aircraftIcon(k)));
|
const distinctGlyphs = new Set([...expectedFlights.values(), ...(hasMilitary ? expectedMilitary.values() : [])].map((k) => aircraftIcon(k)));
|
||||||
record('B2c: all 8 classes exercised with 8 distinct glyphs',
|
const wantGlyphs = hasMilitary ? 8 : new Set(expectedFlights.values()).size;
|
||||||
distinctGlyphs.size === 8, `${distinctGlyphs.size} distinct data URIs`);
|
record(`B2c: all ${wantGlyphs} classes exercised with ${wantGlyphs} distinct glyphs`,
|
||||||
|
distinctGlyphs.size === wantGlyphs, `${distinctGlyphs.size} distinct data URIs`);
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 2D screenshots
|
// 2D screenshots
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
console.log('\n2D screenshots → qa-shots/b5/');
|
console.log('\n2D screenshots → qa-shots/aircraft-sprites/');
|
||||||
const topDown = async (lat, lon, height) => {
|
const topDown = async (lat, lon, height) => {
|
||||||
await page.evaluate(({ lat, lon, height }) => {
|
await page.evaluate(async ({ lat, lon, height }) => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const camera = window.__godsEyeView.engine.camera;
|
||||||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||||
const C3 = v.camera.position.constructor;
|
const geo = await import('/src/geo/index.js');
|
||||||
v.camera.setView({
|
camera.setView({
|
||||||
destination: C3.fromDegrees(lon, lat, height),
|
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
});
|
});
|
||||||
}, { lat, lon, height });
|
}, { lat, lon, height });
|
||||||
@@ -424,6 +416,7 @@ async function main() {
|
|||||||
const zoomToRange = async (want) => {
|
const zoomToRange = async (want) => {
|
||||||
await page.evaluate((want) => {
|
await page.evaluate((want) => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer;
|
||||||
|
if (!v) return; // the engine follow keeps its own range (screenshots only)
|
||||||
const p = v.camera.position;
|
const p = v.camera.position;
|
||||||
const d = Math.hypot(p.x, p.y, p.z);
|
const d = Math.hypot(p.x, p.y, p.z);
|
||||||
if (d > 1e6) return; // not in a tracked reference frame — leave it alone
|
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 flRowLat = await rowLat('flights', 2, 30);
|
||||||
const milRowLat = await rowLat('military', 4, 15);
|
const milRowLat = await rowLat('military', 4, 15);
|
||||||
await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000);
|
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') });
|
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-spread-bothrows.png') });
|
||||||
console.log(' saved b5-2d-spread-bothrows.png (flights row top, military row bottom)');
|
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;
|
// 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.
|
// 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);
|
if (hasMilitary) {
|
||||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-military-row.png') });
|
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
|
||||||
console.log(' saved b5-2d-military-row.png (8 classes, amber)');
|
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-military-row.png') });
|
||||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
|
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 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') });
|
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-flights-row.png') });
|
||||||
console.log(' saved b5-2d-flights-row.png (6 category classes, white)');
|
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'))}`);
|
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`);
|
||||||
|
|
||||||
// Tracked tint: commercial tracked plane goes cyan; neighbors stay white.
|
// 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);
|
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
|
||||||
}, 'caa002');
|
}, 'caa002');
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-tracked-cyan.png') });
|
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-tracked-cyan.png') });
|
||||||
console.log(' saved b5-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
|
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)');
|
console.log('\n3D — flights cluster (airplane.glb, per-class scale)');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -499,16 +494,19 @@ async function main() {
|
|||||||
|
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
await zoomToRange(900);
|
await zoomToRange(900);
|
||||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-mid.png') });
|
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-mid.png') });
|
||||||
console.log(' saved b5-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
|
console.log(' saved aircraft-sprites-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
|
||||||
await zoomToRange(450);
|
await zoomToRange(450);
|
||||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-close.png') });
|
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-close.png') });
|
||||||
console.log(' saved b5-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
|
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)');
|
console.log('\n3D — military cluster (jet.glb, per-class scale)');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const dm = window.__godsEyeView.dataManager;
|
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
|
// 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).
|
// flights cluster (close zoom no longer puts the camera inside a model).
|
||||||
await zoomToRange(900);
|
await zoomToRange(900);
|
||||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') });
|
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-far.png') });
|
||||||
console.log(' saved b5-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
|
console.log(' saved aircraft-sprites-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
|
||||||
await zoomToRange(450);
|
await zoomToRange(450);
|
||||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-close.png') });
|
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-close.png') });
|
||||||
console.log(' saved b5-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
|
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,
|
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
|
#!/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.
|
* 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]
|
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
|
||||||
*
|
*
|
||||||
* Drives the same eased camera path several times: a neighbourhood by Camp
|
* 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
|
* 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
|
* 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
|
* marker stability while the camera is still (camera-set changes after the stop
|
||||||
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show
|
* 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
|
* 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
|
* Writes <out>/result.json, <out>/clip.mp4 (CDP screencast with real frame
|
||||||
* timing) and per-stop screenshots. Needs ffmpeg on PATH.
|
* 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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { installAlprFixture } from './fixtures/layers/alpr.mjs';
|
||||||
|
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
|
||||||
import {
|
import {
|
||||||
createScreencast,
|
createScreencast,
|
||||||
frameStats,
|
frameStats,
|
||||||
@@ -60,12 +65,20 @@ const option = (name, fallback) => {
|
|||||||
const i = argv.indexOf(name);
|
const i = argv.indexOf(name);
|
||||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||||
};
|
};
|
||||||
const appUrl =
|
const appUrl = argv.find((arg) => /^https?:/.test(arg));
|
||||||
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
|
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 outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
|
||||||
const passes = option('--passes', 'off,on,on').split(',');
|
const passes = option('--passes', 'off,on,on').split(',');
|
||||||
const assertOn = !argv.includes('--no-assert');
|
const assertOn = !argv.includes('--no-assert');
|
||||||
const dropRenders = argv.includes('--self-test-drop-renders');
|
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 });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const cyanCount = async (file) => {
|
const cyanCount = async (file) => {
|
||||||
@@ -152,14 +165,12 @@ const STOPS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: !argv.includes('--headful'),
|
headless: true,
|
||||||
protocolTimeout: 300_000,
|
protocolTimeout: 300_000,
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
...(process.platform === 'darwin'
|
...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
|
||||||
? ['--use-angle=metal', '--enable-gpu']
|
|
||||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
'--disable-renderer-backgrounding',
|
'--disable-renderer-backgrounding',
|
||||||
'--disable-backgrounding-occluded-windows',
|
'--disable-backgrounding-occluded-windows',
|
||||||
@@ -170,6 +181,8 @@ const result = {
|
|||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
passes: [],
|
passes: [],
|
||||||
consoleErrors: [],
|
consoleErrors: [],
|
||||||
|
expectedPreviewErrors: [],
|
||||||
|
failedRequests: [],
|
||||||
failures: [],
|
failures: [],
|
||||||
};
|
};
|
||||||
const fail = (message) => {
|
const fail = (message) => {
|
||||||
@@ -179,23 +192,51 @@ const fail = (message) => {
|
|||||||
let page;
|
let page;
|
||||||
try {
|
try {
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
|
await blockPaidQaMaps(page);
|
||||||
|
if (argv.includes('--fixture')) await installAlprFixture(page);
|
||||||
await page.setViewport({ width: 1440, height: 900 });
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
await page.evaluateOnNewDocument(() =>
|
await page.evaluateOnNewDocument(() =>
|
||||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||||
);
|
);
|
||||||
page.on('console', (message) => {
|
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);
|
const url = new URL(appUrl);
|
||||||
url.searchParams.set('welcome', '0');
|
url.searchParams.set('welcome', '0');
|
||||||
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||||
() =>
|
timeout: 120_000,
|
||||||
window.__godsEyeView?.dataManager &&
|
});
|
||||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
await whenSettled(page);
|
||||||
{ timeout: 120_000 },
|
|
||||||
);
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||||
);
|
);
|
||||||
@@ -210,23 +251,17 @@ try {
|
|||||||
const entry = window.__godsEyeView.dataManager.layers.get('alpr-cameras');
|
const entry = window.__godsEyeView.dataManager.layers.get('alpr-cameras');
|
||||||
const stats = entry?.module?.getStats?.() || {};
|
const stats = entry?.module?.getStats?.() || {};
|
||||||
// Layers that publish a render revision are measured by it; otherwise
|
// 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 hash = 0;
|
||||||
let shown = 0;
|
let shown = 0;
|
||||||
if (Number.isFinite(stats.renderRevision)) {
|
if (Number.isFinite(stats.renderRevision)) {
|
||||||
hash = stats.renderRevision;
|
hash = stats.renderRevision;
|
||||||
shown = stats.shown ?? stats.count ?? 0;
|
shown = stats.shown ?? stats.count ?? 0;
|
||||||
} else {
|
} else if (entry?.module && entry.enabled) {
|
||||||
const entities =
|
for (const set of window.__godsEyeView.engine.movers.list()) {
|
||||||
entry?.module && entry.enabled
|
if (!String(set.id).startsWith('alpr-')) continue;
|
||||||
? window.__godsEyeView.viewer.dataSources.getByName(
|
shown += set.count;
|
||||||
'alpr-cameras',
|
const id = `${set.id}:${set.count}`;
|
||||||
)[0]?.entities.values || []
|
|
||||||
: [];
|
|
||||||
for (const entity of entities) {
|
|
||||||
if (entity.show === false) continue;
|
|
||||||
shown += 1;
|
|
||||||
const id = String(entity.id);
|
|
||||||
for (let i = 0; i < id.length; i++)
|
for (let i = 0; i < id.length; i++)
|
||||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||||
}
|
}
|
||||||
@@ -249,20 +284,7 @@ try {
|
|||||||
await recorder.start();
|
await recorder.start();
|
||||||
|
|
||||||
async function waitTiles(maxMs = 20_000) {
|
async function waitTiles(maxMs = 20_000) {
|
||||||
await page
|
await whenTilesLoaded(page, { timeoutMs: maxMs }).catch(() => {});
|
||||||
.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(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [passIndex, mode] of passes.entries()) {
|
for (const [passIndex, mode] of passes.entries()) {
|
||||||
@@ -308,8 +330,31 @@ try {
|
|||||||
// changed state without asking for a render.
|
// changed state without asking for a render.
|
||||||
let staleFrame = null;
|
let staleFrame = null;
|
||||||
if (alpr) {
|
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 () => {
|
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 { scene } = window.__godsEyeView.viewer;
|
||||||
const mode = scene.requestRenderMode;
|
const mode = scene.requestRenderMode;
|
||||||
scene.requestRenderMode = false;
|
scene.requestRenderMode = false;
|
||||||
@@ -317,12 +362,23 @@ try {
|
|||||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
scene.requestRenderMode = mode;
|
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 = {
|
staleFrame = {
|
||||||
shown,
|
shown,
|
||||||
forced,
|
forced,
|
||||||
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
|
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 moving = frameStats(await readFrames(page, moveStart, holdStart));
|
||||||
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
|
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
|
||||||
@@ -580,7 +636,7 @@ try {
|
|||||||
path.join(outDir, 'result.json'),
|
path.join(outDir, 'result.json'),
|
||||||
JSON.stringify(result, null, 1),
|
JSON.stringify(result, null, 1),
|
||||||
);
|
);
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`,
|
`${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`,
|
||||||
|
|||||||
+13
-13
@@ -1,4 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/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.
|
* 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/]
|
* Usage: node scripts/qa-anime-sky.mjs [--url http://localhost:4333/]
|
||||||
* Exit: 0 pass, 1 fail, 2 the anime style was not reached.
|
* Exit: 0 pass, 1 fail, 2 the anime style was not reached.
|
||||||
*/
|
*/
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
const i = process.argv.indexOf('--url');
|
const i = process.argv.indexOf('--url');
|
||||||
const URL_ =
|
const URL_ =
|
||||||
i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/';
|
i > 0 && process.argv[i + 1] ? process.argv[i + 1] : 'http://localhost:4333/';
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: 'new',
|
headless: true,
|
||||||
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
args: ['--no-sandbox', '--ignore-gpu-blocklist'],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -41,17 +43,15 @@ try {
|
|||||||
await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, {
|
await page.waitForFunction(() => window.__godsEyeView?.viewer?.scene, {
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
});
|
});
|
||||||
|
await whenSettled(page);
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => {
|
() => {
|
||||||
const sky = window.__godsEyeView.viewer.scene.skyBox;
|
const sky = window.__godsEyeView.viewer.scene.skyBox;
|
||||||
return (
|
return sky?._milkyWayCubeMap && sky?._starCommand;
|
||||||
sky?._milkyWayCubeMap &&
|
|
||||||
sky?._starCommand &&
|
|
||||||
document.getElementById('loading-screen')?.classList.contains('hidden')
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
|
await whenSettled(page);
|
||||||
const r = await page.evaluate(async () => {
|
const r = await page.evaluate(async () => {
|
||||||
const entry = performance
|
const entry = performance
|
||||||
.getEntriesByType('resource')
|
.getEntriesByType('resource')
|
||||||
@@ -94,22 +94,22 @@ try {
|
|||||||
C.Cartesian3.cross(look, north, new C.Cartesian3()),
|
C.Cartesian3.cross(look, north, new C.Cartesian3()),
|
||||||
new C.Cartesian3(),
|
new C.Cartesian3(),
|
||||||
);
|
);
|
||||||
s.camera.cancelFlight();
|
window.__godsEyeView.engine.camera.cancelFlight();
|
||||||
s.tweens.removeAll();
|
s.tweens.removeAll();
|
||||||
s.camera.setView({
|
window.__godsEyeView.engine.camera.setView({
|
||||||
destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()),
|
destination: C.Cartesian3.multiplyByScalar(look, 3e7, new C.Cartesian3()),
|
||||||
orientation: {
|
orientation: {
|
||||||
direction: look,
|
direction: look,
|
||||||
up: C.Cartesian3.cross(right, look, new C.Cartesian3()),
|
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,
|
w = gl.drawingBufferWidth,
|
||||||
h = gl.drawingBufferHeight,
|
h = gl.drawingBufferHeight,
|
||||||
px = new Uint8Array(w * h * 4);
|
px = new Uint8Array(w * h * 4);
|
||||||
const measure = async () => {
|
const measure = async () => {
|
||||||
await new Promise((res) => setTimeout(res, 2500));
|
await new Promise((res) => setTimeout(res, 2500));
|
||||||
s.requestRender();
|
window.__godsEyeView.engine.render.request();
|
||||||
s.render();
|
s.render();
|
||||||
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
||||||
let banded = 0;
|
let banded = 0;
|
||||||
@@ -156,5 +156,5 @@ try {
|
|||||||
console.error(`UNREACHED: ${error.message}`);
|
console.error(`UNREACHED: ${error.message}`);
|
||||||
process.exitCode = 2;
|
process.exitCode = 2;
|
||||||
} finally {
|
} 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
|
#!/usr/bin/env node
|
||||||
|
import { whenSettled } from './qa/ready.mjs';
|
||||||
/** Prove standalone startup and terminal resource ownership in a real browser. */
|
/** 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 assert from 'node:assert/strict';
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const urlIndex = args.indexOf('--url');
|
const urlIndex = args.indexOf('--url');
|
||||||
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: true,
|
headless: true,
|
||||||
executablePath:
|
args: ['--no-sandbox'],
|
||||||
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
|
|
||||||
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
@@ -28,6 +27,7 @@ try {
|
|||||||
await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, {
|
await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, {
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
|
await whenSettled(page);
|
||||||
const before = await page.evaluate(async () => {
|
const before = await page.evaluate(async () => {
|
||||||
const entry = [...document.scripts].find((script) =>
|
const entry = [...document.scripts].find((script) =>
|
||||||
/\/src\/main\.js(?:\?|$)/.test(script.src),
|
/\/src\/main\.js(?:\?|$)/.test(script.src),
|
||||||
@@ -52,7 +52,7 @@ try {
|
|||||||
enabled,
|
enabled,
|
||||||
layers: app.dataManager.layers.size,
|
layers: app.dataManager.layers.size,
|
||||||
annotationCount: annotation.drawn,
|
annotationCount: annotation.drawn,
|
||||||
viewerAlive: !app.viewer.isDestroyed(),
|
viewerAlive: !window.__godsEyeView.engine.isDestroyed(),
|
||||||
credits: Boolean(document.querySelector('#cesium-credits')),
|
credits: Boolean(document.querySelector('#cesium-credits')),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -83,7 +83,7 @@ try {
|
|||||||
return {
|
return {
|
||||||
status: application.getState().status,
|
status: application.getState().status,
|
||||||
samePromise,
|
samePromise,
|
||||||
viewerDestroyed: app.viewer.isDestroyed(),
|
viewerDestroyed: app.engine.isDestroyed(),
|
||||||
layers: app.dataManager.layers.size,
|
layers: app.dataManager.layers.size,
|
||||||
annotations: app.annotations.count(),
|
annotations: app.annotations.count(),
|
||||||
governor: app.getRenderGovernorDiagnostics(),
|
governor: app.getRenderGovernorDiagnostics(),
|
||||||
@@ -112,5 +112,5 @@ try {
|
|||||||
'PASS: terminal teardown releases runtime owners without late browser errors',
|
'PASS: terminal teardown releases runtime owners without late browser errors',
|
||||||
);
|
);
|
||||||
} finally {
|
} 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).
|
* 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
|
* 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 { existsSync, mkdirSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, resolve } from 'node:path';
|
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');
|
const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12');
|
||||||
mkdirSync(SHOT_DIR, { recursive: true });
|
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 passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -70,10 +60,8 @@ const REQUIRED_CREDIT_SUBSTRINGS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const chromeExecutable = findChromeExecutable();
|
const browser = await launchBrowser({
|
||||||
const browser = await puppeteer.launch({
|
headless: true,
|
||||||
headless: 'new',
|
|
||||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
@@ -122,8 +110,9 @@ async function main() {
|
|||||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay,
|
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay,
|
||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
|
await whenSettled(page);
|
||||||
// Give Cesium a few frames to render the on-screen credit line (logo + link).
|
// 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 ────────────────────────────
|
// ── (i) H11: static credits registered ────────────────────────────
|
||||||
console.log('H11 — per-layer credits registered in viewer.creditDisplay');
|
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.
|
// Force a render frame so the credit display flushes, then open the lightbox.
|
||||||
const lightboxState = await page.evaluate(async () => {
|
const lightboxState = await page.evaluate(async () => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
viewer.scene.requestRender();
|
window.__godsEyeView.engine.render.request();
|
||||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
const cd = viewer.creditDisplay;
|
const cd = viewer.creditDisplay;
|
||||||
cd.showLightbox();
|
cd.showLightbox();
|
||||||
viewer.scene.requestRender();
|
window.__godsEyeView.engine.render.request();
|
||||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
const box = document.querySelector('.cesium-credit-lightbox');
|
const box = document.querySelector('.cesium-credit-lightbox');
|
||||||
const list = box?.querySelector(':scope > ul');
|
const list = box?.querySelector(':scope > ul');
|
||||||
@@ -263,7 +252,7 @@ async function main() {
|
|||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
viewer.resize();
|
viewer.resize();
|
||||||
viewer.scene.requestRender();
|
window.__godsEyeView.engine.render.request();
|
||||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
});
|
});
|
||||||
const mobileLightbox = await page.evaluate(() => {
|
const mobileLightbox = await page.evaluate(() => {
|
||||||
@@ -311,7 +300,7 @@ async function main() {
|
|||||||
check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`);
|
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 (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);
|
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(() =>
|
const afterEnableHtml = await page.evaluate(() =>
|
||||||
(window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html),
|
(window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html),
|
||||||
);
|
);
|
||||||
@@ -348,7 +337,7 @@ async function main() {
|
|||||||
// ── (iii) clean-view: credit line STILL visible ───────────────────
|
// ── (iii) clean-view: credit line STILL visible ───────────────────
|
||||||
console.log('\nH10 — clean-view keeps the credit line visible');
|
console.log('\nH10 — clean-view keeps the credit line visible');
|
||||||
await page.evaluate(() => document.body.classList.add('ui-clean-view'));
|
await page.evaluate(() => document.body.classList.add('ui-clean-view'));
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
await whenUiSettled(page);
|
||||||
const cleanVis = await creditVisibility();
|
const cleanVis = await creditVisibility();
|
||||||
await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') });
|
await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') });
|
||||||
check(
|
check(
|
||||||
@@ -366,7 +355,7 @@ async function main() {
|
|||||||
// ── (iv) recording-mode: credit line STILL visible ────────────────
|
// ── (iv) recording-mode: credit line STILL visible ────────────────
|
||||||
console.log('\nH10 — recording-mode keeps the credit line visible');
|
console.log('\nH10 — recording-mode keeps the credit line visible');
|
||||||
await page.evaluate(() => document.body.classList.add('recording-mode'));
|
await page.evaluate(() => document.body.classList.add('recording-mode'));
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
await whenUiSettled(page);
|
||||||
const recVis = await creditVisibility();
|
const recVis = await creditVisibility();
|
||||||
await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') });
|
await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') });
|
||||||
check(
|
check(
|
||||||
@@ -377,7 +366,7 @@ async function main() {
|
|||||||
await page.evaluate(() => document.body.classList.remove('recording-mode'));
|
await page.evaluate(() => document.body.classList.remove('recording-mode'));
|
||||||
|
|
||||||
// baseline (normal) screenshot for comparison
|
// baseline (normal) screenshot for comparison
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
await whenUiSettled(page);
|
||||||
await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') });
|
await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') });
|
||||||
|
|
||||||
check(
|
check(
|
||||||
@@ -391,7 +380,7 @@ async function main() {
|
|||||||
failedResponses.slice(0, 3).join(' | ') || 'clean',
|
failedResponses.slice(0, 3).join(' | ') || 'clean',
|
||||||
);
|
);
|
||||||
|
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
|
|
||||||
console.log('\n────────────────────────────────────────────────────────────');
|
console.log('\n────────────────────────────────────────────────────────────');
|
||||||
console.log(` RESULT: ${passed} passed, ${failed} failed`);
|
console.log(` RESULT: ${passed} passed, ${failed} failed`);
|
||||||
|
|||||||
@@ -23,6 +23,74 @@ export function redact(text) {
|
|||||||
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
|
.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) {
|
function push(list, entry) {
|
||||||
list.push(entry);
|
list.push(entry);
|
||||||
if (list.length > EVIDENCE_LIMIT)
|
if (list.length > EVIDENCE_LIMIT)
|
||||||
@@ -37,6 +105,8 @@ export function hookRenderErrors(page) {
|
|||||||
return page.evaluateOnNewDocument(() => {
|
return page.evaluateOnNewDocument(() => {
|
||||||
window.__qaRenderErrors = [];
|
window.__qaRenderErrors = [];
|
||||||
const hook = () => {
|
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;
|
const scene = window.__godsEyeView?.viewer?.scene;
|
||||||
if (!scene?.renderError) return false;
|
if (!scene?.renderError) return false;
|
||||||
scene.renderError.addEventListener((_, error) =>
|
scene.renderError.addEventListener((_, error) =>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
#!/usr/bin/env node
|
#!/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.
|
* 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
|
* a. per-frame `scene.render` cost over a ~10 s driven-orbit window
|
||||||
* (mean / p50 / p95 / max — relative numbers, SwiftShader surface),
|
* (mean / p50 / p95 / max — relative numbers, SwiftShader surface),
|
||||||
* b. toggle-ON → first-labels-visible latency,
|
* 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).
|
* d. parked-idle postRender fires over 5 s (render-governor honesty).
|
||||||
* 2. GATE (unified build only): assert the migration contract —
|
* 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
|
* present for the source, idle stays near-zero, and a clean OFF→ON cycle
|
||||||
* (no orphan host entries after disable).
|
* (no orphan host entries after disable).
|
||||||
*
|
*
|
||||||
@@ -19,7 +25,6 @@
|
|||||||
* Requires a running dev server. Headless; rAF throttling disabled so the
|
* Requires a running dev server. Headless; rAF throttling disabled so the
|
||||||
* frame clock is honest (hidden-pane gotcha).
|
* frame clock is honest (hidden-pane gotcha).
|
||||||
*/
|
*/
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
const argv = process.argv;
|
const argv = process.argv;
|
||||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
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)}`);
|
console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: 'new',
|
headless: true,
|
||||||
protocolTimeout: 300_000,
|
protocolTimeout: 300_000,
|
||||||
args: [
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--window-size=1440,900',
|
|
||||||
'--disable-backgrounding-occluded-windows',
|
|
||||||
'--disable-renderer-backgrounding',
|
|
||||||
'--disable-background-timer-throttling',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setViewport({ width: 1440, height: 860 });
|
await page.setViewport({ width: 1440, height: 860 });
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 });
|
||||||
await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init
|
await whenSettled(page); // boot flyTo + deferred init
|
||||||
|
|
||||||
// Park mid-Atlantic (many cables + both coasts' landings in range) and
|
// Park mid-Atlantic (many cables + both coasts' landings in range) and
|
||||||
// disable every layer so the cables layer is measured in isolation.
|
// 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 gev = window.__godsEyeView;
|
||||||
const v = gev.viewer;
|
window.__godsEyeView.engine.camera.cancelFlight();
|
||||||
v.camera.cancelFlight();
|
window.__godsEyeView.engine.camera.setView({
|
||||||
const ell = v.scene.globe.ellipsoid;
|
destination: qaGeo.cartographic.toCartesian({
|
||||||
v.camera.setView({
|
|
||||||
destination: ell.cartographicToCartesian({
|
|
||||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||||
}),
|
}),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
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 */ } }
|
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 ─────────────────────────────────────
|
// ── b. toggle-ON → labels visible ─────────────────────────────────────
|
||||||
if (!control) {
|
if (!control) {
|
||||||
@@ -95,45 +91,58 @@ try {
|
|||||||
};
|
};
|
||||||
while (performance.now() < deadline) {
|
while (performance.now() < deadline) {
|
||||||
if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false };
|
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));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
return { ms: 60_000, timedOut: true };
|
return { ms: 60_000, timedOut: true };
|
||||||
}, LAYER_ID, legacy);
|
}, LAYER_ID, legacy);
|
||||||
report('toggle-ON -> labels visible (ms)', labelLatency);
|
report('toggle-ON -> labels visible (ms)', labelLatency);
|
||||||
check('labels became visible after enable', labelLatency.timedOut === false, 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 ───────────────────────────────────
|
// ── c. drawn-set / label / host counts ────────────────────────────────
|
||||||
const counts = await page.evaluate((layerId) => {
|
// 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 gev = window.__godsEyeView;
|
||||||
const v = gev.viewer;
|
const v = gev.viewer;
|
||||||
|
const engine = gev.engine;
|
||||||
const out = {
|
const out = {
|
||||||
dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0,
|
layerDataSources: 0, layerEntities: 0,
|
||||||
referenceEntities: 0, hostEntries: 0, hostPainted: 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);
|
const ds = v.dataSources.get(i);
|
||||||
if (!/TeleGeography/.test(ds.name || '')) continue;
|
if (!/TeleGeography/.test(ds.name || '')) continue;
|
||||||
const list = ds.entities.values;
|
out.layerDataSources++;
|
||||||
out.dataSources[ds.name] = list.length;
|
out.layerEntities += ds.entities.values.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;
|
|
||||||
}
|
}
|
||||||
|
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?.() || {};
|
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||||
out.hostEntries = diag.entriesBySource?.[layerId] || 0;
|
out.hostEntries = diag.entriesBySource?.[layerId] || 0;
|
||||||
out.hostPainted = diag.paintedBySource?.[layerId] || 0;
|
out.hostPainted = diag.paintedBySource?.[layerId] || 0;
|
||||||
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
||||||
out.referenceLabelCount = stats.referenceLabelCount ?? null;
|
out.referenceLabelCount = stats.referenceLabelCount ?? null;
|
||||||
return out;
|
return out;
|
||||||
}, LAYER_ID);
|
}, LAYER_ID, legacy);
|
||||||
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
|
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
|
||||||
|
|
||||||
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
|
// ── 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);
|
: 'scene.render cost, cables ON, 10s orbit', frameCost);
|
||||||
|
|
||||||
// ── d. parked idle honesty (labels must not force continuous render) ──
|
// ── d. parked idle honesty (labels must not force continuous render) ──
|
||||||
await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle
|
await whenSettled(page);
|
||||||
const idle = await page.evaluate(() => new Promise((resolve) => {
|
const idle = await measureIdleRender(page);
|
||||||
const scene = window.__godsEyeView.viewer.scene;
|
|
||||||
let renders = 0;
|
|
||||||
const remove = scene.postRender.addEventListener(() => { renders += 1; });
|
|
||||||
setTimeout(() => { remove(); resolve({ renders }); }, 5_000);
|
|
||||||
}));
|
|
||||||
report(control
|
report(control
|
||||||
? 'parked idle, control (postRender fires / 5s)'
|
? 'parked idle, control (postRender fires / 5s)'
|
||||||
: 'parked idle with cables ON (postRender fires / 5s)', idle);
|
: 'parked idle with cables ON (postRender fires / 5s)', idle);
|
||||||
|
|
||||||
if (!legacy && !control) {
|
if (!legacy && !control) {
|
||||||
// ── unified-build contract gates ────────────────────────────────────
|
// ── unified-build contract gates ────────────────────────────────────
|
||||||
check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels);
|
// Paths, pins and tips belong to contract sets; labels belong to the host.
|
||||||
check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities);
|
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 has cable entries', counts.hostEntries > 0, counts.hostEntries);
|
||||||
check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted);
|
check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted);
|
||||||
// The cables source never republishes an unchanged cohort (unit pin), and
|
// The cables source never republishes an unchanged cohort (unit pin), and
|
||||||
@@ -208,7 +214,7 @@ try {
|
|||||||
const cycle = await page.evaluate(async (layerId) => {
|
const cycle = await page.evaluate(async (layerId) => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
await gev.dataManager.setEnabled(layerId, false, { origin: 'user' });
|
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));
|
await new Promise((r) => setTimeout(r, 1_200));
|
||||||
const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||||
const offEntries = offDiag.entriesBySource?.[layerId] || 0;
|
const offEntries = offDiag.entriesBySource?.[layerId] || 0;
|
||||||
@@ -220,7 +226,7 @@ try {
|
|||||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||||
onPainted = diag.paintedBySource?.[layerId] || 0;
|
onPainted = diag.paintedBySource?.[layerId] || 0;
|
||||||
if (onPainted > 0) break;
|
if (onPainted > 0) break;
|
||||||
gev.viewer.scene.requestRender?.();
|
window.__godsEyeView.engine.render.request?.();
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
return { offEntries, offPainted, onPainted };
|
return { offEntries, offPainted, onPainted };
|
||||||
@@ -229,7 +235,7 @@ try {
|
|||||||
check('re-enable restores painted labels', cycle.onPainted > 0, cycle);
|
check('re-enable restores painted labels', cycle.onPainted > 0, cycle);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
|
|
||||||
const passed = results.filter((r) => r.pass).length;
|
const passed = results.filter((r) => r.pass).length;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
* qa-cables-render-probe — replicates the owner-side perf probe methodology
|
||||||
* for the cables layer (relative numbers; SwiftShader when run headless):
|
* 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]
|
* Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216]
|
||||||
*/
|
*/
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
const argv = process.argv;
|
const argv = process.argv;
|
||||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216';
|
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216';
|
||||||
const LAYER_ID = 'telegeography-submarine-cables';
|
const LAYER_ID = 'telegeography-submarine-cables';
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: 'new',
|
headless: true,
|
||||||
protocolTimeout: 300_000,
|
protocolTimeout: 300_000,
|
||||||
args: [
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
|
||||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
|
||||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
|
||||||
'--disable-background-timer-throttling',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setViewport({ width: 1440, height: 860 });
|
await page.setViewport({ width: 1440, height: 860 });
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 });
|
||||||
await new Promise((r) => setTimeout(r, 12_000));
|
await whenSettled(page);
|
||||||
|
|
||||||
await page.evaluate(async () => {
|
await evaluateWithGeo(page, async (qaGeo) => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
gev.viewer.camera.cancelFlight();
|
window.__godsEyeView.engine.camera.cancelFlight();
|
||||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
window.__godsEyeView.engine.camera.setView({
|
||||||
gev.viewer.camera.setView({
|
destination: qaGeo.cartographic.toCartesian({
|
||||||
destination: ell.cartographicToCartesian({
|
|
||||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||||
}),
|
}),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
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 */ } }
|
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) => ({
|
const stats = (values) => ({
|
||||||
mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2),
|
mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2),
|
||||||
@@ -60,7 +58,7 @@ try {
|
|||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
await gev.dataManager.setEnabled(id, on, { origin: 'user' });
|
await gev.dataManager.setEnabled(id, on, { origin: 'user' });
|
||||||
}, LAYER_ID, enabled);
|
}, 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.
|
// 1. PARKED timed renders.
|
||||||
const parked = await page.evaluate(() => {
|
const parked = await page.evaluate(() => {
|
||||||
@@ -77,18 +75,17 @@ try {
|
|||||||
// 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders.
|
// 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders.
|
||||||
const cycles = [];
|
const cycles = [];
|
||||||
for (let cycle = 0; cycle < 3; cycle++) {
|
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 v = window.__godsEyeView.viewer;
|
||||||
const ell = v.scene.globe.ellipsoid;
|
window.__godsEyeView.engine.camera.setView({
|
||||||
v.camera.setView({
|
destination: qaGeo.cartographic.toCartesian({
|
||||||
destination: ell.cartographicToCartesian({
|
|
||||||
longitude: (-40 + step * 3) * Math.PI / 180,
|
longitude: (-40 + step * 3) * Math.PI / 180,
|
||||||
latitude: 35 * Math.PI / 180,
|
latitude: 35 * Math.PI / 180,
|
||||||
height: 4_500_000,
|
height: 4_500_000,
|
||||||
}),
|
}),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
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));
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||||
const durations = [];
|
const durations = [];
|
||||||
for (let i = 0; i < 20; i++) {
|
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(`\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`);
|
console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
+15
-20
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
* qa-cables-shot — capture the cables layer at two fixed cameras for
|
||||||
* before/after visual-identity comparison. Writes to gitignored qa-shots/.
|
* before/after visual-identity comparison. Writes to gitignored qa-shots/.
|
||||||
* Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before]
|
* 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 { mkdirSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
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';
|
const LAYER_ID = 'telegeography-submarine-cables';
|
||||||
mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true });
|
mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true });
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: 'new',
|
headless: true,
|
||||||
protocolTimeout: 300_000,
|
protocolTimeout: 300_000,
|
||||||
args: [
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
|
||||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
|
||||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
|
||||||
'--disable-background-timer-throttling',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setViewport({ width: 1440, height: 860 });
|
await page.setViewport({ width: 1440, height: 860 });
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 90_000 });
|
||||||
await new Promise((r) => setTimeout(r, 12_000));
|
await whenSettled(page);
|
||||||
await page.evaluate(async (layerId) => {
|
await page.evaluate(async (layerId) => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
gev.viewer.camera.cancelFlight();
|
window.__godsEyeView.engine.camera.cancelFlight();
|
||||||
for (const [id, entry] of gev.dataManager.layers) {
|
for (const [id, entry] of gev.dataManager.layers) {
|
||||||
if (entry.enabled && id !== layerId) {
|
if (entry.enabled && id !== layerId) {
|
||||||
try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ }
|
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 },
|
{ name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 },
|
||||||
];
|
];
|
||||||
for (const view of views) {
|
for (const view of views) {
|
||||||
await page.evaluate((v) => {
|
await evaluateWithGeo(page, async (qaGeo, v) => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
window.__godsEyeView.engine.camera.setView({
|
||||||
const ell = viewer.scene.globe.ellipsoid;
|
destination: qaGeo.cartographic.toCartesian({
|
||||||
viewer.camera.setView({
|
|
||||||
destination: ell.cartographicToCartesian({
|
|
||||||
longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height,
|
longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height,
|
||||||
}),
|
}),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
@@ -57,15 +53,14 @@ try {
|
|||||||
}, view);
|
}, view);
|
||||||
// Let the sweep + labels settle with frames flowing.
|
// Let the sweep + labels settle with frames flowing.
|
||||||
await page.evaluate(() => new Promise((resolve) => {
|
await page.evaluate(() => new Promise((resolve) => {
|
||||||
const v = window.__godsEyeView.viewer;
|
|
||||||
let ticks = 0;
|
let ticks = 0;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
v.scene.requestRender?.();
|
window.__godsEyeView.engine.render.request?.();
|
||||||
if (++ticks < 240) requestAnimationFrame(tick); else resolve();
|
if (++ticks < 240) requestAnimationFrame(tick); else resolve();
|
||||||
};
|
};
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
}));
|
}));
|
||||||
await new Promise((r) => setTimeout(r, 1_000));
|
await whenSettled(page);
|
||||||
const path = fileURLToPath(
|
const path = fileURLToPath(
|
||||||
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
|
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
|
||||||
);
|
);
|
||||||
@@ -73,5 +68,5 @@ try {
|
|||||||
console.log(`saved ${path}`);
|
console.log(`saved ${path}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/** Camera-panel browser acceptance with controlled sources, images and terrain. */
|
/** 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 fs from 'node:fs';
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
|
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||||
const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||||
const sources = [
|
const sources = [
|
||||||
@@ -31,13 +32,11 @@ const sources = [
|
|||||||
rangeM: 300,
|
rangeM: 300,
|
||||||
mountHeightM: 8,
|
mountHeightM: 8,
|
||||||
}));
|
}));
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
...(process.platform === 'darwin'
|
...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
|
||||||
? ['--use-angle=metal', '--enable-gpu']
|
|
||||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
@@ -108,10 +107,10 @@ try {
|
|||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||||
window.__godsEyeView?.dataManager?.layers?.has('cctv') &&
|
window.__godsEyeView?.dataManager?.layers?.has('cctv'),
|
||||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
|
await whenSettled(page);
|
||||||
await page.$eval('[data-collapse-target="cctv-panel"]', (button) =>
|
await page.$eval('[data-collapse-target="cctv-panel"]', (button) =>
|
||||||
button.click(),
|
button.click(),
|
||||||
);
|
);
|
||||||
@@ -280,6 +279,8 @@ try {
|
|||||||
.classList.contains('has-frame'),
|
.classList.contains('has-frame'),
|
||||||
);
|
);
|
||||||
await page.setCacheEnabled(false);
|
await page.setCacheEnabled(false);
|
||||||
|
// Keep both options while selecting A flies the view away from B.
|
||||||
|
await page.focus('#cctv-camera-select');
|
||||||
delayCameraA = true;
|
delayCameraA = true;
|
||||||
await page.select('#cctv-camera-select', 'qa-camera-a');
|
await page.select('#cctv-camera-select', 'qa-camera-a');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
@@ -357,22 +358,23 @@ try {
|
|||||||
fs.mkdirSync('qa-shots/camera-controls', { recursive: true });
|
fs.mkdirSync('qa-shots/camera-controls', { recursive: true });
|
||||||
await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' });
|
await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' });
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const camera = window.__godsEyeView.viewer.camera;
|
window.__godsEyeView.engine.camera.setView({
|
||||||
camera.setView({
|
destination: window.__godsEyeView.engine.camera.pose().position,
|
||||||
orientation: {
|
orientation: {
|
||||||
heading: camera.heading + Math.PI / 3,
|
heading:
|
||||||
|
window.__godsEyeView.engine.camera.pose().heading + Math.PI / 3,
|
||||||
pitch: -0.6,
|
pitch: -0.6,
|
||||||
roll: 0,
|
roll: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
await whenUiSettled(page);
|
||||||
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
|
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
|
||||||
await page.setViewport({ width: 390, height: 844 });
|
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.
|
// Phones present CCTV in the Cameras tab's sheet.
|
||||||
await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]');
|
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' });
|
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
|
||||||
check(
|
check(
|
||||||
'narrow camera panel remains within the viewport',
|
'narrow camera panel remains within the viewport',
|
||||||
@@ -411,6 +413,6 @@ try {
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await Promise.all([...delayedFrames].map((respond) => respond()));
|
await Promise.all([...delayedFrames].map((respond) => respond()));
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
if (failures) process.exitCode = 1;
|
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
|
#!/usr/bin/env node
|
||||||
|
import { evaluateWithGeo } from './qa/geometry.mjs';
|
||||||
/**
|
/**
|
||||||
* Rendered proof for backdrop-selective callout plates at GROUND LEVEL.
|
* 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 --tag before
|
||||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth
|
* 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
|
* which is deterministic but is NOT real-GPU evidence; the banner says which
|
||||||
* backend actually ran and the run records it alongside the results.
|
* 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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const getOpt = (name, fallback) => {
|
const getOpt = (name, fallback) => {
|
||||||
@@ -64,7 +66,7 @@ const APP_ORIGIN = new URL(APP_URL).origin;
|
|||||||
const TAG = getOpt('--tag', 'after');
|
const TAG = getOpt('--tag', 'after');
|
||||||
const HEADFUL = argv.includes('--headful');
|
const HEADFUL = argv.includes('--headful');
|
||||||
const TEETH = argv.includes('--teeth');
|
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');
|
const SHOT_DIR = path.resolve('qa-shots/cockpitplates');
|
||||||
|
|
||||||
/** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */
|
/** 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 = [];
|
const results = [];
|
||||||
function record(name, ok, detail) {
|
function record(name, ok, detail) {
|
||||||
@@ -213,25 +207,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||||
const executablePath = CHROME_CANDIDATES.find((candidate) => {
|
const browser = await launchBrowser({
|
||||||
try { return fs.existsSync(candidate); } catch { return false; }
|
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 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 consoleErrors = [];
|
const consoleErrors = [];
|
||||||
@@ -276,10 +254,7 @@ async function main() {
|
|||||||
|
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 });
|
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 });
|
||||||
await page.waitForFunction(
|
await whenSettled(page);
|
||||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
renderer = await page.evaluate(() => {
|
renderer = await page.evaluate(() => {
|
||||||
const gl = document.createElement('canvas').getContext('webgl2')
|
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
|
// Inject the field. The layers keep their real pipelines; only the source
|
||||||
// of observations is replaced, exactly as scripts/qa-labels.mjs does.
|
// of observations is replaced, exactly as scripts/qa-labels.mjs does.
|
||||||
await page.evaluate((payload) => {
|
await evaluateWithGeo(page, async (qaGeo, payload) => {
|
||||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
const { dataManager, styleManager } = window.__godsEyeView;
|
||||||
viewer.camera.cancelFlight();
|
window.__godsEyeView.engine.camera.cancelFlight();
|
||||||
const Cartesian3 = viewer.camera.position.constructor;
|
const Cartesian3 = qaGeo.cartesian3;
|
||||||
window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({
|
window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({
|
||||||
sourceId: contact.id.toLowerCase(),
|
sourceId: contact.id.toLowerCase(),
|
||||||
id: contact.id,
|
id: contact.id,
|
||||||
@@ -330,17 +305,16 @@ async function main() {
|
|||||||
|
|
||||||
for (const pose of POSES) {
|
for (const pose of POSES) {
|
||||||
console.log(`\n — pose "${pose.name}" (${pose.note})`);
|
console.log(`\n — pose "${pose.name}" (${pose.note})`);
|
||||||
await page.evaluate((payload) => {
|
await evaluateWithGeo(page, async (qaGeo, payload) => {
|
||||||
const { viewer } = window.__godsEyeView;
|
const Cartesian3 = qaGeo.cartesian3;
|
||||||
const Cartesian3 = viewer.camera.position.constructor;
|
window.__godsEyeView.engine.camera.setView({
|
||||||
viewer.camera.setView({
|
|
||||||
destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height),
|
destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height),
|
||||||
orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 },
|
orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 },
|
||||||
});
|
});
|
||||||
}, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg });
|
}, { 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.
|
// 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(() => {
|
await page.evaluate(() => {
|
||||||
window.__PLATE_TAP.events = [];
|
window.__PLATE_TAP.events = [];
|
||||||
window.__PLATE_TAP.on = true;
|
window.__PLATE_TAP.on = true;
|
||||||
@@ -356,10 +330,9 @@ async function main() {
|
|||||||
// OUTPUT has to ask for its own renders. Production is unaffected: real
|
// OUTPUT has to ask for its own renders. Production is unaffected: real
|
||||||
// Cockpit holds, and a parked map keeps showing the last correct frame
|
// Cockpit holds, and a parked map keeps showing the last correct frame
|
||||||
// precisely because nothing changed.
|
// precisely because nothing changed.
|
||||||
const scene = window.__godsEyeView.viewer.scene;
|
|
||||||
const pump = () => {
|
const pump = () => {
|
||||||
if (!window.__PLATE_TAP.on) return;
|
if (!window.__PLATE_TAP.on) return;
|
||||||
scene.requestRender();
|
window.__godsEyeView.engine.render.request();
|
||||||
requestAnimationFrame(pump);
|
requestAnimationFrame(pump);
|
||||||
};
|
};
|
||||||
pump();
|
pump();
|
||||||
@@ -522,7 +495,7 @@ async function main() {
|
|||||||
consoleErrors.slice(0, 3).join(' | ') || 'clean',
|
consoleErrors.slice(0, 3).join(' | ') || 'clean',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/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. */
|
/** 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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import puppeteer from 'puppeteer';
|
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
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');
|
const headful = process.argv.includes('--headful');
|
||||||
fs.mkdirSync(shotsDir, { recursive: true });
|
fs.mkdirSync(shotsDir, { recursive: true });
|
||||||
|
|
||||||
const chromeCandidates = [
|
const browser = await launchBrowser({
|
||||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
headless: true,
|
||||||
await puppeteer.executablePath().catch(() => null),
|
args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'],
|
||||||
'/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 page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
await blockPaidQaMaps(page);
|
||||||
await stubPlanespotters(page);
|
await stubPlanespotters(page);
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const consoleErrors = [];
|
const consoleErrors = [];
|
||||||
@@ -169,10 +160,7 @@ try {
|
|||||||
page.on('request', routeQaRequest);
|
page.on('request', routeQaRequest);
|
||||||
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
|
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
|
||||||
await page.waitForFunction(
|
await whenSettled(page);
|
||||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
);
|
|
||||||
await page.waitForFunction(() => (
|
await page.waitForFunction(() => (
|
||||||
typeof window.__gevQaRegisterLayer === 'function'
|
typeof window.__gevQaRegisterLayer === 'function'
|
||||||
&& typeof window.__gevQaUnregisterLayer === 'function'
|
&& typeof window.__gevQaUnregisterLayer === 'function'
|
||||||
@@ -673,6 +661,7 @@ try {
|
|||||||
() => Boolean(window.__godsEyeView.viewer.trackedEntity?.position),
|
() => Boolean(window.__godsEyeView.viewer.trackedEntity?.position),
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
);
|
);
|
||||||
|
await whenSettled(page);
|
||||||
const preselectedContactAdoption = await page.evaluate(async () => {
|
const preselectedContactAdoption = await page.evaluate(async () => {
|
||||||
const { styleManager, dataManager, viewer } = window.__godsEyeView;
|
const { styleManager, dataManager, viewer } = window.__godsEyeView;
|
||||||
const flights = dataManager.layers.get('flights')?.module;
|
const flights = dataManager.layers.get('flights')?.module;
|
||||||
@@ -765,7 +754,7 @@ try {
|
|||||||
} : null,
|
} : null,
|
||||||
navigation: snapshot?.navigation || null,
|
navigation: snapshot?.navigation || null,
|
||||||
afterId: trackedInfo?.icao24 || 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,
|
contextVisible: !document.getElementById('military-awareness-panel')?.hidden,
|
||||||
entryAvailableAfterSettlement: Boolean(entry && !entry.hidden),
|
entryAvailableAfterSettlement: Boolean(entry && !entry.hidden),
|
||||||
blockerStillRegistered: dataManager.layers.has(blockerId),
|
blockerStillRegistered: dataManager.layers.has(blockerId),
|
||||||
@@ -830,6 +819,7 @@ try {
|
|||||||
await stubPlanespotters(readinessPage);
|
await stubPlanespotters(readinessPage);
|
||||||
let deferredInstallationReadiness;
|
let deferredInstallationReadiness;
|
||||||
try {
|
try {
|
||||||
|
await blockPaidQaMaps(readinessPage);
|
||||||
await readinessPage.setViewport({ width: 1440, height: 900 });
|
await readinessPage.setViewport({ width: 1440, height: 900 });
|
||||||
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
|
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||||
await readinessPage.setRequestInterception(true);
|
await readinessPage.setRequestInterception(true);
|
||||||
@@ -851,8 +841,8 @@ try {
|
|||||||
const readinessUrl = new URL(appUrl);
|
const readinessUrl = new URL(appUrl);
|
||||||
readinessUrl.searchParams.set('welcome', '0');
|
readinessUrl.searchParams.set('welcome', '0');
|
||||||
await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' });
|
await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' });
|
||||||
await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager
|
await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60000 });
|
||||||
&& document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 });
|
await whenSettled(readinessPage);
|
||||||
await readinessPage.evaluate(async () => {
|
await readinessPage.evaluate(async () => {
|
||||||
const manager = window.__godsEyeView.dataManager;
|
const manager = window.__godsEyeView.dataManager;
|
||||||
await manager.setEnabled('flights', true, { origin: 'user' });
|
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||||
@@ -1097,7 +1087,7 @@ try {
|
|||||||
const awareness = dataManager.layers.get('military-awareness')?.module;
|
const awareness = dataManager.layers.get('military-awareness')?.module;
|
||||||
const before = awareness?.getContextSnapshot?.()?.subject || null;
|
const before = awareness?.getContextSnapshot?.()?.subject || null;
|
||||||
const entityBefore = viewer.trackedEntity;
|
const entityBefore = viewer.trackedEntity;
|
||||||
viewer.camera.zoomOut(1_500_000);
|
window.__godsEyeView.engine.camera.zoomOut(1_500_000);
|
||||||
const cameraRange = () => Math.hypot(
|
const cameraRange = () => Math.hypot(
|
||||||
viewer.camera.position.x,
|
viewer.camera.position.x,
|
||||||
viewer.camera.position.y,
|
viewer.camera.position.y,
|
||||||
@@ -1153,6 +1143,14 @@ try {
|
|||||||
},
|
},
|
||||||
{ timeout: 10_000 },
|
{ 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 firstCockpitContact = await page.evaluate(() => {
|
||||||
const awareness = window.__godsEyeView.dataManager.layers
|
const awareness = window.__godsEyeView.dataManager.layers
|
||||||
.get('military-awareness')?.module;
|
.get('military-awareness')?.module;
|
||||||
@@ -1454,7 +1452,7 @@ try {
|
|||||||
remove();
|
remove();
|
||||||
reject(new Error('Cockpit camera ownership check did not render a frame'));
|
reject(new Error('Cockpit camera ownership check did not render a frame'));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
const remove = viewer.scene.postRender.addEventListener(() => {
|
const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => {
|
||||||
remove();
|
remove();
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve();
|
resolve();
|
||||||
@@ -1462,10 +1460,10 @@ try {
|
|||||||
});
|
});
|
||||||
// Let revoked listeners retire before comparing ownership across exit.
|
// Let revoked listeners retire before comparing ownership across exit.
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners;
|
const listenersBeforeExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners;
|
||||||
const exited = styleManager.cockpitView.exit() === true;
|
const exited = styleManager.cockpitView.exit() === true;
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners;
|
const listenersAfterExit = window.__godsEyeView.engine.frames.preUpdate.numberOfListeners;
|
||||||
const refocused = awareness?.focusCurrent?.() === true;
|
const refocused = awareness?.focusCurrent?.() === true;
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
return {
|
return {
|
||||||
@@ -1474,7 +1472,7 @@ try {
|
|||||||
sameEntity: viewer.trackedEntity === entity,
|
sameEntity: viewer.trackedEntity === entity,
|
||||||
listenersBeforeExit,
|
listenersBeforeExit,
|
||||||
listenersAfterExit,
|
listenersAfterExit,
|
||||||
listenersAfterFocus: viewer.scene.preUpdate.numberOfListeners,
|
listenersAfterFocus: window.__godsEyeView.engine.frames.preUpdate.numberOfListeners,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
@@ -1514,6 +1512,9 @@ try {
|
|||||||
const before = read();
|
const before = read();
|
||||||
document.getElementById('cockpit-entry')?.click();
|
document.getElementById('cockpit-entry')?.click();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
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();
|
const during = read();
|
||||||
manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false });
|
manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false });
|
||||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
@@ -1534,6 +1535,7 @@ try {
|
|||||||
before,
|
before,
|
||||||
during,
|
during,
|
||||||
after: read(),
|
after: read(),
|
||||||
|
startsClear,
|
||||||
exited,
|
exited,
|
||||||
clearancePx,
|
clearancePx,
|
||||||
contactAutoCollapsed,
|
contactAutoCollapsed,
|
||||||
@@ -1543,7 +1545,7 @@ try {
|
|||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
'Cockpit temporarily collapses standard panels and restores the exact map layout on exit',
|
'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)
|
&& Object.values(cockpitPanelRoundTrip.during).every(Boolean)
|
||||||
&& JSON.stringify(cockpitPanelRoundTrip.after)
|
&& JSON.stringify(cockpitPanelRoundTrip.after)
|
||||||
=== JSON.stringify(cockpitPanelRoundTrip.before)
|
=== JSON.stringify(cockpitPanelRoundTrip.before)
|
||||||
@@ -1608,6 +1610,7 @@ try {
|
|||||||
const states = [read()];
|
const states = [read()];
|
||||||
for (let index = 0; index < 6; index += 1) {
|
for (let index = 0; index < 6; index += 1) {
|
||||||
current.click();
|
current.click();
|
||||||
|
await Promise.resolve();
|
||||||
states.push(read());
|
states.push(read());
|
||||||
}
|
}
|
||||||
const originalStyle = manager.activeStyle;
|
const originalStyle = manager.activeStyle;
|
||||||
@@ -1619,8 +1622,10 @@ try {
|
|||||||
const enteredOnNoir = await reenter();
|
const enteredOnNoir = await reenter();
|
||||||
const entryNoir = read();
|
const entryNoir = read();
|
||||||
cockpit.setVisionMode('optical');
|
cockpit.setVisionMode('optical');
|
||||||
|
await Promise.resolve();
|
||||||
const normalInsideNoir = read();
|
const normalInsideNoir = read();
|
||||||
cockpit.setVisionMode('crt');
|
cockpit.setVisionMode('crt');
|
||||||
|
await Promise.resolve();
|
||||||
const cockpitOnlyCrt = read();
|
const cockpitOnlyCrt = read();
|
||||||
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
|
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
|
||||||
await settle();
|
await settle();
|
||||||
@@ -1695,6 +1700,9 @@ try {
|
|||||||
await page.screenshot({ path: path.join(shotsDir, 'vision-noir.png') });
|
await page.screenshot({ path: path.join(shotsDir, 'vision-noir.png') });
|
||||||
await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('optical'));
|
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 desktopState = async (variant, openKind) => page.evaluate(async ({ variantName, kind }) => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
const hud = document.getElementById('cockpit-hud');
|
const hud = document.getElementById('cockpit-hud');
|
||||||
@@ -1880,7 +1888,7 @@ try {
|
|||||||
// the wall Cockpit actually solves against — not by injecting an anchor.
|
// the wall Cockpit actually solves against — not by injecting an anchor.
|
||||||
// The card is bottom-anchored, so an explicit height moves its top edge.
|
// The card is bottom-anchored, so an explicit height moves its top edge.
|
||||||
const minTop = Math.max(96, window.innerHeight * 0.12);
|
const minTop = Math.max(96, window.innerHeight * 0.12);
|
||||||
const moveSignalTopTo = (wantTop) => {
|
const moveSignalTopTo = async (wantTop) => {
|
||||||
for (let pass = 0; pass < 8; pass += 1) {
|
for (let pass = 0; pass < 8; pass += 1) {
|
||||||
const rect = signal.getBoundingClientRect();
|
const rect = signal.getBoundingClientRect();
|
||||||
const delta = wantTop - rect.top;
|
const delta = wantTop - rect.top;
|
||||||
@@ -1893,9 +1901,11 @@ try {
|
|||||||
// the now-shorter strip, so idempotence is asserted where the strip's
|
// the now-shorter strip, so idempotence is asserted where the strip's
|
||||||
// composition is unchanged.)
|
// composition is unchanged.)
|
||||||
manager.cockpitView.syncSignalLayout();
|
manager.cockpitView.syncSignalLayout();
|
||||||
|
await Promise.resolve();
|
||||||
const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top');
|
const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top');
|
||||||
const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only');
|
const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only');
|
||||||
manager.cockpitView.syncSignalLayout();
|
manager.cockpitView.syncSignalLayout();
|
||||||
|
await Promise.resolve();
|
||||||
return {
|
return {
|
||||||
firstPassTop,
|
firstPassTop,
|
||||||
firstPassPrimaryOnly,
|
firstPassPrimaryOnly,
|
||||||
@@ -1906,6 +1916,7 @@ try {
|
|||||||
signal.style.maxHeight = 'none';
|
signal.style.maxHeight = 'none';
|
||||||
signal.style.height = '150px';
|
signal.style.height = '150px';
|
||||||
manager.cockpitView.syncSignalLayout();
|
manager.cockpitView.syncSignalLayout();
|
||||||
|
await Promise.resolve();
|
||||||
const expandedHeight = Math.max(displayControl.scrollHeight, displayControl.getBoundingClientRect().height);
|
const expandedHeight = Math.max(displayControl.scrollHeight, displayControl.getBoundingClientRect().height);
|
||||||
const collapsedHeight = Math.max(50, radioControl.scrollHeight);
|
const collapsedHeight = Math.max(50, radioControl.scrollHeight);
|
||||||
const stripHeight = expandedHeight + collapsedHeight + 7;
|
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
|
// 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,
|
// the measured requirement instead, which holds for any future panel height,
|
||||||
// and assert the precondition rather than assuming it.
|
// 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')
|
const roomySeedExpanded = !utility.classList.contains('layout-primary-only')
|
||||||
&& roomySeed.firstPassPrimaryOnly === false;
|
&& roomySeed.firstPassPrimaryOnly === false;
|
||||||
const exact = {
|
const exact = {
|
||||||
...moveSignalTopTo(exactSignalTop),
|
...await moveSignalTopTo(exactSignalTop),
|
||||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||||
@@ -1944,7 +1955,7 @@ try {
|
|||||||
const constrained = {
|
const constrained = {
|
||||||
// One pixel on the far side of the same fit (`exactSignalTop` already sits
|
// 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.
|
// 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'),
|
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||||
siblingDisplay: getComputedStyle(radioControl).display,
|
siblingDisplay: getComputedStyle(radioControl).display,
|
||||||
@@ -1955,7 +1966,7 @@ try {
|
|||||||
<= signal.getBoundingClientRect().top - 7,
|
<= signal.getBoundingClientRect().top - 7,
|
||||||
};
|
};
|
||||||
const restored = {
|
const restored = {
|
||||||
...moveSignalTopTo(exactSignalTop + 80),
|
...await moveSignalTopTo(exactSignalTop + 80),
|
||||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||||
@@ -2064,7 +2075,7 @@ try {
|
|||||||
JSON.stringify(cyberStyleStates));
|
JSON.stringify(cyberStyleStates));
|
||||||
for (const height of [987, 720, 640, 900]) {
|
for (const height of [987, 720, 640, 900]) {
|
||||||
await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 });
|
await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
await whenUiSettled(page);
|
||||||
const geometry = await page.evaluate(() => {
|
const geometry = await page.evaluate(() => {
|
||||||
const signal = document.getElementById('cockpit-signal-stream');
|
const signal = document.getElementById('cockpit-signal-stream');
|
||||||
const utility = document.getElementById('cockpit-utility-controls');
|
const utility = document.getElementById('cockpit-utility-controls');
|
||||||
@@ -2355,7 +2366,7 @@ try {
|
|||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
if (!viewer) return false;
|
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;
|
return Math.abs(height - 18_000_000) < 150_000;
|
||||||
}, { timeout: 6_000 });
|
}, { timeout: 6_000 });
|
||||||
const resetState = await page.evaluate(() => {
|
const resetState = await page.evaluate(() => {
|
||||||
@@ -2363,7 +2374,7 @@ try {
|
|||||||
const qa = window.__qaCockpitReset;
|
const qa = window.__qaCockpitReset;
|
||||||
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
|
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
|
||||||
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
|
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;
|
gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
|
||||||
delete window.__qaCockpitReset;
|
delete window.__qaCockpitReset;
|
||||||
return {
|
return {
|
||||||
@@ -2416,7 +2427,7 @@ try {
|
|||||||
else hud.style.removeProperty('--cockpit-utility-top');
|
else hud.style.removeProperty('--cockpit-utility-top');
|
||||||
if (!prior.cockpit) document.body.classList.remove('cockpit-mode');
|
if (!prior.cockpit) document.body.classList.remove('cockpit-mode');
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`RESULT: ${failures.length ? 'NOT_READY' : 'READY'} (${failures.length} failures)`);
|
console.log(`RESULT: ${failures.length ? 'NOT_READY' : 'READY'} (${failures.length} failures)`);
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
|
import {
|
||||||
|
createStrict,
|
||||||
|
localPreview,
|
||||||
|
openPage,
|
||||||
|
waitForEngine,
|
||||||
|
} from './qa-strict-harness.mjs';
|
||||||
|
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||||
|
base.hash =
|
||||||
|
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
|
||||||
|
const strict = createStrict({
|
||||||
|
origin: base.origin,
|
||||||
|
layers: ['military-awareness', 'military-installations'],
|
||||||
|
});
|
||||||
|
await strict.run({}, async (b) => {
|
||||||
|
const { page: p, errors } = await openPage(b);
|
||||||
|
await p.evaluateOnNewDocument(() => {
|
||||||
|
const fetch = window.fetch.bind(window);
|
||||||
|
window.fetch = (input, options) => {
|
||||||
|
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
||||||
|
if (pathname === '/api/vessels')
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
status: 'live',
|
||||||
|
rows: ['123456789', '223456789'].map((mmsi, i) => ({
|
||||||
|
mmsi,
|
||||||
|
name: 'Toronto ' + i,
|
||||||
|
lat: 43.65,
|
||||||
|
lon: -79.38 + i * 0.05,
|
||||||
|
last_position_epoch: Date.now() / 1000,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Contacts reads air quality near the subject; no station here.
|
||||||
|
if (
|
||||||
|
new URL(input.url ?? input, document.baseURI).hostname ===
|
||||||
|
'api.weather.gc.ca'
|
||||||
|
)
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({ type: 'FeatureCollection', features: [] }),
|
||||||
|
);
|
||||||
|
if (pathname === '/api/vessels/track')
|
||||||
|
return Promise.resolve(Response.json({ samples: [] }));
|
||||||
|
if (pathname === '/api/flights')
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({ time: Date.now() / 1000, states: [] }),
|
||||||
|
);
|
||||||
|
if (pathname === '/api/military')
|
||||||
|
return Promise.resolve(Response.json({ now: Date.now(), ac: [] }));
|
||||||
|
// One mapped station next to the Contacts subject, with a footprint.
|
||||||
|
if (pathname === '/api/military-installations') {
|
||||||
|
window.qaInstallationReads = (window.qaInstallationReads ?? 0) + 1;
|
||||||
|
const ring = [
|
||||||
|
[43.665, -79.345],
|
||||||
|
[43.665, -79.315],
|
||||||
|
[43.68, -79.315],
|
||||||
|
[43.68, -79.345],
|
||||||
|
[43.665, -79.345],
|
||||||
|
];
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
status: 'ok',
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: 'way',
|
||||||
|
id: 424242,
|
||||||
|
tags: { military: 'naval_base', name: 'QA Harbour Station' },
|
||||||
|
center: { lat: 43.6725, lon: -79.33 },
|
||||||
|
geometry: ring.map(([lat, lon]) => ({ lat, lon })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fetch(input, options);
|
||||||
|
};
|
||||||
|
window.qaSelected = [];
|
||||||
|
for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared'])
|
||||||
|
window.addEventListener(type, ({ detail }) =>
|
||||||
|
window.qaSelected.push({
|
||||||
|
type,
|
||||||
|
id: detail?.id,
|
||||||
|
layerId: detail?.layerId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await p.goto(base.href);
|
||||||
|
await waitForEngine(p, 'navara');
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { dataManager, styleManager } = window.__godsEyeView;
|
||||||
|
|
||||||
|
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||||
|
window.qaBefore = [...dataManager.getEnabledLayerIds()].sort();
|
||||||
|
const outcome = await styleManager.setContextMode('contacts');
|
||||||
|
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||||
|
window.qaContext = dataManager.layers.get('military-awareness').module;
|
||||||
|
window.qaSites = dataManager.layers.get('military-installations').module;
|
||||||
|
const vessels = dataManager.layers.get('ais-live-vessels').module;
|
||||||
|
if (!vessels.selectById('123456789')) throw Error('Missing first vessel');
|
||||||
|
if (!window.qaContext.navigateNext({ targetLayer: 'ais-live-vessels' }))
|
||||||
|
throw Error('Cannot navigate to the second vessel');
|
||||||
|
});
|
||||||
|
const sample = () =>
|
||||||
|
p.evaluate(() => {
|
||||||
|
const { dataManager, styleManager, engine } = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
context:
|
||||||
|
window.qaContext ===
|
||||||
|
dataManager.layers.get('military-awareness').module,
|
||||||
|
sites:
|
||||||
|
window.qaSites ===
|
||||||
|
dataManager.layers.get('military-installations').module,
|
||||||
|
mode: styleManager.getContextModeState().mode,
|
||||||
|
subject: window.qaContext.getContextSnapshot()?.subject?.id,
|
||||||
|
history:
|
||||||
|
window.qaContext._getAwarenessNavigationStateForTest().historyKeys,
|
||||||
|
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||||
|
ring:
|
||||||
|
engine.kind === 'navara'
|
||||||
|
? engine.lines
|
||||||
|
.list()
|
||||||
|
.filter((line) => line.id === 'gev-awareness-rim').length
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// The station zone: the fixture's installation is the one mapped site
|
||||||
|
// within the Contacts radius of the subject, and the engine draws it where
|
||||||
|
// a pointer can reach it (the engine's own picker returns it at an
|
||||||
|
// uncovered canvas point at its marker or inside its footprint).
|
||||||
|
const inside = [fromDegrees(-79.33, 43.6725)];
|
||||||
|
for (let i = 1; i < 6; i++)
|
||||||
|
for (let k = 1; k < 6; k++)
|
||||||
|
inside.push(fromDegrees(-79.345 + 0.005 * k, 43.665 + 0.0025 * i));
|
||||||
|
const zone = async () => {
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
!window.qaSites.getStats().loading &&
|
||||||
|
window.qaSites
|
||||||
|
.getAnalystRecords()
|
||||||
|
.some((r) => r.name === 'QA Harbour Station'),
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
const drawn = await p
|
||||||
|
.waitForFunction(
|
||||||
|
(points) => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const canvas = engine.host.canvas;
|
||||||
|
const box = canvas.getBoundingClientRect();
|
||||||
|
for (const world of points) {
|
||||||
|
const centre = engine.projector.worldToWindow(world, {});
|
||||||
|
if (!centre) continue;
|
||||||
|
for (const [dx, dy] of [
|
||||||
|
[0, 0],
|
||||||
|
[0, -8],
|
||||||
|
[8, 0],
|
||||||
|
[-8, 0],
|
||||||
|
[0, 8],
|
||||||
|
]) {
|
||||||
|
const at = { x: centre.x + dx, y: centre.y + dy };
|
||||||
|
if (
|
||||||
|
!canvas.contains(
|
||||||
|
document.elementFromPoint(box.left + at.x, box.top + at.y),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const id = engine.picker.pick(at)?.id;
|
||||||
|
if (String(id?.id ?? id) === 'osm:way:424242')
|
||||||
|
return [Math.round(at.x), Math.round(at.y)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
inside,
|
||||||
|
)
|
||||||
|
.then((handle) => handle.jsonValue())
|
||||||
|
.catch(() => null);
|
||||||
|
return p.evaluate(
|
||||||
|
(drawn) => ({
|
||||||
|
drawn,
|
||||||
|
records: window.qaSites.getAnalystRecords().map((r) => r.name),
|
||||||
|
coverage: window.qaSites.getStats().coverage,
|
||||||
|
count: window.qaSites.getStats().count,
|
||||||
|
reads: window.qaInstallationReads,
|
||||||
|
subject: window.qaContext.getContextSnapshot()?.subject?.id,
|
||||||
|
}),
|
||||||
|
drawn,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const checkZone = async () => {
|
||||||
|
const station = await zone();
|
||||||
|
await strict.check(
|
||||||
|
p,
|
||||||
|
'military-installations',
|
||||||
|
'the station zone holds the fixture station, drawn and pickable',
|
||||||
|
station.count === 1 &&
|
||||||
|
station.records.length === 1 &&
|
||||||
|
station.records[0] === 'QA Harbour Station' &&
|
||||||
|
station.coverage?.kind === 'subject' &&
|
||||||
|
Array.isArray(station.drawn),
|
||||||
|
station,
|
||||||
|
);
|
||||||
|
return station;
|
||||||
|
};
|
||||||
|
const firstZone = await checkZone();
|
||||||
|
const baseline = await sample();
|
||||||
|
console.log('baseline', JSON.stringify(baseline));
|
||||||
|
assert.equal(baseline.mode, 'flights');
|
||||||
|
assert.equal(baseline.subject, '223456789');
|
||||||
|
assert.ok(baseline.history.includes('ais-live-vessels:123456789'));
|
||||||
|
assert.ok(baseline.ring >= 1);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'military-awareness',
|
||||||
|
'Contacts subject, navigation history and awareness rim',
|
||||||
|
baseline,
|
||||||
|
);
|
||||||
|
for (const kind of ['cesium', 'navara']) {
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
const current = await sample();
|
||||||
|
console.log('handoff', kind, JSON.stringify(current));
|
||||||
|
assert.deepEqual(current, {
|
||||||
|
...baseline,
|
||||||
|
ring: kind === 'navara' ? baseline.ring : null,
|
||||||
|
});
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'military-awareness',
|
||||||
|
'same awareness model, Contacts mode, subject, history and enabled layers',
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'military-installations',
|
||||||
|
'same installations model and enabled through the switch',
|
||||||
|
);
|
||||||
|
const station = await checkZone();
|
||||||
|
await strict.check(
|
||||||
|
p,
|
||||||
|
'military-installations',
|
||||||
|
'the switch reads no installation snapshot',
|
||||||
|
station.reads === firstZone.reads,
|
||||||
|
{ before: firstZone.reads, after: station.reads },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await checkZone();
|
||||||
|
const restored = await p.evaluate(async () => {
|
||||||
|
const { dataManager, styleManager } = window.__godsEyeView;
|
||||||
|
const result = await styleManager.setContextMode('off');
|
||||||
|
return (
|
||||||
|
result.ok &&
|
||||||
|
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
|
||||||
|
JSON.stringify(window.qaBefore)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
restored,
|
||||||
|
'Restore layers enabled by the user before entering Contacts',
|
||||||
|
);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS Contacts history, dependency ownership and exit restore across both globes',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
import { evaluateWithGeo } from './qa/geometry.mjs';
|
||||||
/**
|
/**
|
||||||
* qa-context-arrivals.mjs — search arrivals above the surface, and Contacts
|
* qa-context-arrivals.mjs — search arrivals above the surface, and Contacts
|
||||||
* listing mapped installations around a moving tracked aircraft.
|
* listing mapped installations around a moving tracked aircraft.
|
||||||
@@ -25,9 +26,10 @@
|
|||||||
* Writes <out>/result.json, screenshots and <out>/clip.mp4 (CDP screencast
|
* Writes <out>/result.json, screenshots and <out>/clip.mp4 (CDP screencast
|
||||||
* with real frame timing; needs ffmpeg).
|
* with real frame timing; needs ffmpeg).
|
||||||
*/
|
*/
|
||||||
|
import { whenSettled, whenFramesDrawn, whenTilesLoaded } from './qa/ready.mjs';
|
||||||
|
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||||
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
|
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
|
||||||
|
|
||||||
@@ -62,14 +64,12 @@ const check = (name, ok, detail = '') => {
|
|||||||
if (!ok) result.failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
|
if (!ok) result.failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: !argv.includes('--headful'),
|
headless: true,
|
||||||
protocolTimeout: 300_000,
|
protocolTimeout: 300_000,
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
...(process.platform === 'darwin'
|
...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
|
||||||
? ['--use-angle=metal', '--enable-gpu']
|
|
||||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
'--disable-renderer-backgrounding',
|
'--disable-renderer-backgrounding',
|
||||||
'--disable-backgrounding-occluded-windows',
|
'--disable-backgrounding-occluded-windows',
|
||||||
@@ -171,12 +171,10 @@ async function openPage({ aircraft = false } = {}) {
|
|||||||
const url = new URL(appUrl);
|
const url = new URL(appUrl);
|
||||||
url.searchParams.set('welcome', '0');
|
url.searchParams.set('welcome', '0');
|
||||||
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||||
() =>
|
timeout: 120_000,
|
||||||
window.__godsEyeView?.dataManager &&
|
});
|
||||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
await whenSettled(page);
|
||||||
{ timeout: 120_000 },
|
|
||||||
);
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||||
);
|
);
|
||||||
@@ -185,31 +183,16 @@ async function openPage({ aircraft = false } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitTiles(page, maxMs = 30_000) {
|
async function waitTiles(page, maxMs = 30_000) {
|
||||||
await page
|
await whenTilesLoaded(page, { timeoutMs: maxMs }).catch(() => {});
|
||||||
.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(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Camera height above the rendered surface under it and under the view centre. */
|
/** Camera height above the rendered surface under it and under the view centre. */
|
||||||
function clearance(page) {
|
function clearance(page) {
|
||||||
return page.evaluate(() => {
|
return evaluateWithGeo(page, async (qaGeo) => {
|
||||||
const { viewer } = window.__godsEyeView;
|
const carto = window.__godsEyeView.engine.camera.pose().cartographic;
|
||||||
const { scene, camera } = viewer;
|
|
||||||
const carto = camera.positionCartographic.clone();
|
|
||||||
const sample = (c) => {
|
const sample = (c) => {
|
||||||
try {
|
try {
|
||||||
const h = scene.sampleHeight(c);
|
const h = window.__godsEyeView.engine.surface.sampleHeight(c);
|
||||||
return Number.isFinite(h) ? h : null;
|
return Number.isFinite(h) ? h : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -217,12 +200,12 @@ function clearance(page) {
|
|||||||
};
|
};
|
||||||
const under = sample(carto);
|
const under = sample(carto);
|
||||||
let centre = null;
|
let centre = null;
|
||||||
const canvas = scene.canvas;
|
const canvas = window.__godsEyeView.engine.host.canvas;
|
||||||
const hit = scene.pickPosition?.({
|
const hit = window.__godsEyeView.engine.picker.pickPosition?.({
|
||||||
x: canvas.clientWidth / 2,
|
x: canvas.clientWidth / 2,
|
||||||
y: canvas.clientHeight / 2,
|
y: canvas.clientHeight / 2,
|
||||||
});
|
});
|
||||||
if (hit) centre = scene.globe.ellipsoid.cartesianToCartographic(hit);
|
if (hit) centre = qaGeo.cartographic.fromCartesian(hit);
|
||||||
const centreSurface = centre ? sample(centre) : null;
|
const centreSurface = centre ? sample(centre) : null;
|
||||||
const surface = Math.max(under ?? -Infinity, centreSurface ?? -Infinity);
|
const surface = Math.max(under ?? -Infinity, centreSurface ?? -Infinity);
|
||||||
return {
|
return {
|
||||||
@@ -234,7 +217,9 @@ function clearance(page) {
|
|||||||
clearanceM: Number.isFinite(surface)
|
clearanceM: Number.isFinite(surface)
|
||||||
? Math.round(carto.height - surface)
|
? Math.round(carto.height - surface)
|
||||||
: null,
|
: null,
|
||||||
pitchDeg: Math.round((camera.pitch * 180) / Math.PI),
|
pitchDeg: Math.round(
|
||||||
|
(window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -255,47 +240,32 @@ async function arrivals() {
|
|||||||
});
|
});
|
||||||
await page.type('#location-search', query, { delay: 40 });
|
await page.type('#location-search', query, { delay: 40 });
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
// Wait for the flight to land: the camera still for 1.5 s.
|
await whenSettled(page);
|
||||||
const landed = Date.now();
|
|
||||||
let last = null;
|
|
||||||
let stillSince = null;
|
|
||||||
while (Date.now() - landed < 30_000) {
|
|
||||||
const now = await page.evaluate(() => {
|
|
||||||
const c = window.__godsEyeView.viewer.camera.positionWC;
|
|
||||||
return [c.x, c.y, c.z];
|
|
||||||
});
|
|
||||||
const moved =
|
|
||||||
!last ||
|
|
||||||
Math.hypot(now[0] - last[0], now[1] - last[1], now[2] - last[2]) > 0.5;
|
|
||||||
last = now;
|
|
||||||
if (moved) stillSince = null;
|
|
||||||
else stillSince ??= Date.now();
|
|
||||||
if (
|
|
||||||
Date.now() - landed > 4000 &&
|
|
||||||
stillSince &&
|
|
||||||
Date.now() - stillSince > 1500
|
|
||||||
)
|
|
||||||
break;
|
|
||||||
await sleep(250);
|
|
||||||
}
|
|
||||||
await waitTiles(page);
|
await waitTiles(page);
|
||||||
await sleep(2500); // the ground guard measures after tiles stream in
|
await whenSettled(page); // the ground guard measures after tiles stream in
|
||||||
const arrived = await clearance(page);
|
const arrived = await clearance(page);
|
||||||
const slug = query.replace(/\W+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
const slug = query.replace(/\W+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
||||||
await page.screenshot({ path: path.join(dir, `arrival-${slug}.png`) });
|
await page.screenshot({ path: path.join(dir, `arrival-${slug}.png`) });
|
||||||
// Look around: a gentle orbit, then check the eye still clears the ground.
|
// Look around: a gentle orbit, then check the eye still clears the ground.
|
||||||
const view = await page.evaluate(() => {
|
const view = await page.evaluate(() => {
|
||||||
const c = window.__godsEyeView.viewer.camera;
|
|
||||||
return {
|
return {
|
||||||
lat: (c.positionCartographic.latitude * 180) / Math.PI,
|
lat:
|
||||||
lon: (c.positionCartographic.longitude * 180) / Math.PI,
|
(window.__godsEyeView.engine.camera.pose().cartographic.latitude *
|
||||||
height: c.positionCartographic.height,
|
180) /
|
||||||
heading: (c.heading * 180) / Math.PI,
|
Math.PI,
|
||||||
pitch: (c.pitch * 180) / Math.PI,
|
lon:
|
||||||
|
(window.__godsEyeView.engine.camera.pose().cartographic.longitude *
|
||||||
|
180) /
|
||||||
|
Math.PI,
|
||||||
|
height: window.__godsEyeView.engine.camera.pose().cartographic.height,
|
||||||
|
heading:
|
||||||
|
(window.__godsEyeView.engine.camera.pose().heading * 180) / Math.PI,
|
||||||
|
pitch:
|
||||||
|
(window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await moveCamera(page, { ...view, heading: view.heading + 25 }, 2.5);
|
await moveCamera(page, { ...view, heading: view.heading + 25 }, 2.5);
|
||||||
await sleep(1500);
|
await whenFramesDrawn(page);
|
||||||
const lookedAround = await clearance(page);
|
const lookedAround = await clearance(page);
|
||||||
result.arrivals.push({ query, arrived, lookedAround });
|
result.arrivals.push({ query, arrived, lookedAround });
|
||||||
check(
|
check(
|
||||||
@@ -323,7 +293,7 @@ async function contacts() {
|
|||||||
{ lat: 30.35, lon: -97.76, height: 900_000, pitch: -90 },
|
{ lat: 30.35, lon: -97.76, height: 900_000, pitch: -90 },
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
await sleep(3000);
|
await whenFramesDrawn(page);
|
||||||
// In-page probe: row text and installation entity churn every 250 ms.
|
// In-page probe: row text and installation entity churn every 250 ms.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const samples = [];
|
const samples = [];
|
||||||
@@ -340,10 +310,16 @@ async function contacts() {
|
|||||||
candidate.querySelector('strong')?.textContent?.trim() ===
|
candidate.querySelector('strong')?.textContent?.trim() ===
|
||||||
'Mapped installations',
|
'Mapped installations',
|
||||||
);
|
);
|
||||||
const ds = window.__godsEyeView.viewer.dataSources.getByName(
|
// The drawn sites are context handles in the shared context store
|
||||||
'military-installations',
|
// (src/layers/installations/render/display.js keeps a site's handle
|
||||||
)[0];
|
// while its geometry is unchanged), not Entities: a new handle is a
|
||||||
for (const entity of ds?.entities.values || [])
|
// rebuilt site.
|
||||||
|
const sites = [
|
||||||
|
...(window.__gevContextStore?.entities.values() || []),
|
||||||
|
].filter((record) =>
|
||||||
|
String(record.entity?.gevTrackedId ?? '').startsWith('installations:'),
|
||||||
|
);
|
||||||
|
for (const { entity } of sites)
|
||||||
if (!seen.has(entity)) {
|
if (!seen.has(entity)) {
|
||||||
seen.add(entity);
|
seen.add(entity);
|
||||||
created += 1;
|
created += 1;
|
||||||
@@ -359,10 +335,10 @@ async function contacts() {
|
|||||||
name: button.childNodes[0]?.textContent?.trim(),
|
name: button.childNodes[0]?.textContent?.trim(),
|
||||||
distance: button.querySelector('span')?.textContent?.trim(),
|
distance: button.querySelector('span')?.textContent?.trim(),
|
||||||
})),
|
})),
|
||||||
entities: ds?.entities.values.length ?? 0,
|
entities: sites.length,
|
||||||
created,
|
created,
|
||||||
tracked:
|
tracked:
|
||||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null,
|
window.__godsEyeView.engine.camera.following()?.label?.id ?? null,
|
||||||
cockpit: document.body.classList.contains('cockpit-mode'),
|
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||||
});
|
});
|
||||||
if (samples.length > 5000) samples.splice(0, 1000);
|
if (samples.length > 5000) samples.splice(0, 1000);
|
||||||
@@ -377,7 +353,7 @@ async function contacts() {
|
|||||||
await page
|
await page
|
||||||
.waitForFunction(
|
.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ===
|
window.__godsEyeView.engine.camera.following()?.label?.id ===
|
||||||
'flights:aaa051',
|
'flights:aaa051',
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
@@ -431,7 +407,7 @@ async function contacts() {
|
|||||||
`${reverted.length}/${flight.length}`,
|
`${reverted.length}/${flight.length}`,
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'site entities are not rebuilt while tracking',
|
'drawn sites are not rebuilt while tracking',
|
||||||
createdDuring === 0,
|
createdDuring === 0,
|
||||||
`${createdDuring} created`,
|
`${createdDuring} created`,
|
||||||
);
|
);
|
||||||
@@ -472,7 +448,7 @@ async function contacts() {
|
|||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
window.__godsEyeView.styleManager.cockpitView.exit?.(),
|
window.__godsEyeView.styleManager.cockpitView.exit?.(),
|
||||||
);
|
);
|
||||||
await sleep(2000);
|
await whenFramesDrawn(page);
|
||||||
|
|
||||||
// SEARCH NEARBY SITES, as the user presses it.
|
// SEARCH NEARBY SITES, as the user presses it.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -498,7 +474,7 @@ async function contacts() {
|
|||||||
const toast = await page.evaluate(() =>
|
const toast = await page.evaluate(() =>
|
||||||
document.getElementById('toast').textContent.trim(),
|
document.getElementById('toast').textContent.trim(),
|
||||||
);
|
);
|
||||||
await sleep(1500);
|
await whenFramesDrawn(page);
|
||||||
const afterSearch = (
|
const afterSearch = (
|
||||||
await readSamples((await page.evaluate(() => performance.now())) - 1000)
|
await readSamples((await page.evaluate(() => performance.now())) - 1000)
|
||||||
).at(-1);
|
).at(-1);
|
||||||
@@ -513,13 +489,13 @@ async function contacts() {
|
|||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
document.getElementById('global-context-flights-btn').click(),
|
document.getElementById('global-context-flights-btn').click(),
|
||||||
);
|
);
|
||||||
await sleep(2000);
|
await whenFramesDrawn(page);
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
window.__godsEyeView.dataManager.layers
|
window.__godsEyeView.dataManager.layers
|
||||||
.get('flights')
|
.get('flights')
|
||||||
.module.trackById('aaa052'),
|
.module.trackById('aaa052'),
|
||||||
);
|
);
|
||||||
await sleep(3000);
|
await whenFramesDrawn(page);
|
||||||
const t1 = await page.evaluate(() => performance.now());
|
const t1 = await page.evaluate(() => performance.now());
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
document.getElementById('global-context-flights-btn').click(),
|
document.getElementById('global-context-flights-btn').click(),
|
||||||
@@ -548,7 +524,7 @@ async function contacts() {
|
|||||||
}
|
}
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
}
|
}
|
||||||
await sleep(2000);
|
await whenFramesDrawn(page);
|
||||||
await page.screenshot({ path: path.join(dir, 'contacts-fort-cavazos.png') });
|
await page.screenshot({ path: path.join(dir, 'contacts-fort-cavazos.png') });
|
||||||
result.contacts.cavazos = cavazos;
|
result.contacts.cavazos = cavazos;
|
||||||
check(
|
check(
|
||||||
@@ -594,7 +570,7 @@ try {
|
|||||||
path.join(outDir, 'result.json'),
|
path.join(outDir, 'result.json'),
|
||||||
JSON.stringify(result, null, 1),
|
JSON.stringify(result, null, 1),
|
||||||
);
|
);
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`,
|
`${result.failures.length ? 'FAIL' : 'PASS'} — ${path.join(outDir, 'result.json')}`,
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Run the globe contract suite (src/globe/testing/contractSuite.mjs) in
|
||||||
|
* headless Chrome against the real engines of a running app, the way
|
||||||
|
* contract.test.mjs runs it against the fake engine.
|
||||||
|
*
|
||||||
|
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4241
|
||||||
|
* node scripts/qa-contract-suite.mjs http://localhost:4241 [--engine=cesium,navara] [--json=FILE]
|
||||||
|
*
|
||||||
|
* Serve the app keyless from the dev server: Google and Cesium ion requests
|
||||||
|
* are refused here anyway, so neither engine draws photoreal tiles. The page
|
||||||
|
* imports the suite from the dev server, so it shares the app's modules (an
|
||||||
|
* engine event is recognised by the module that made it). It gets a real
|
||||||
|
* pointer (the pick checks), real 2D canvases for sprite atlases, and no map
|
||||||
|
* sources (the maps checks would ask for photoreal tiles). Exits 1 when any check fails on any engine.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const BLOCKED_HOSTS =
|
||||||
|
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||||
|
const option = (name) =>
|
||||||
|
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||||
|
if (!origin) {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix,...] [--dpr=1|2] [--json=FILE]',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const engines = (option('engine') ?? 'cesium,navara').split(',');
|
||||||
|
// --only=prefix,prefix runs just the checks whose names start with one.
|
||||||
|
const only =
|
||||||
|
option('only')
|
||||||
|
?.split(',')
|
||||||
|
.map((prefix) =>
|
||||||
|
/^(ground|imagery|movers|lines|meshes|maps):$/.test(prefix)
|
||||||
|
? prefix.replace(':', '.')
|
||||||
|
: prefix,
|
||||||
|
) ?? null;
|
||||||
|
const dpr = Number(option('dpr') ?? 1);
|
||||||
|
if (![1, 2].includes(dpr)) throw new Error('--dpr must be 1 or 2');
|
||||||
|
|
||||||
|
// Checks of state the running app drives itself, by name prefix, with the
|
||||||
|
// owner. The app sets contact sonar on every frame (src/cyberSonarEngine.js),
|
||||||
|
// so a value the suite sets does not survive to its next frame.
|
||||||
|
const APP_OWNED = Object.freeze([
|
||||||
|
['render.setContactSonar', 'src/cyberSonarEngine.js sets it every frame'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function runOn(browser, kind) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: dpr });
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||||
|
await page.setRequestInterception(true);
|
||||||
|
page.on('request', (request) => {
|
||||||
|
const { hostname } = new URL(request.url());
|
||||||
|
if (new URL(request.url()).pathname === '/__contract_missing_model.glb')
|
||||||
|
request.respond({ status: 404, body: 'Model unavailable' });
|
||||||
|
else if (BLOCKED_HOSTS.test(hostname)) request.abort();
|
||||||
|
else request.continue();
|
||||||
|
});
|
||||||
|
// A view in the link: the app starts there instead of flying to a city.
|
||||||
|
await page.goto(
|
||||||
|
`${origin}/?welcome=0&engine=${kind}#v=2&lat=10&lon=20&alt=1000000&heading=0&pitch=-90&map=osm&l=`,
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) =>
|
||||||
|
window.__godsEyeView?.engine?.kind === expected &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
// A cold dev server compiles Navara's modules on the first visit.
|
||||||
|
{ timeout: 300_000, polling: 250 },
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
// The suite owns the globe: no camera flight running, and the app's panels
|
||||||
|
// let the pointer through to the canvas.
|
||||||
|
// The app restores the view in the link with a flight that can start
|
||||||
|
// after the loading screen goes: wait until the camera is there and still,
|
||||||
|
// or that flight lands in the middle of a check.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const { camera } = window.__godsEyeView.engine;
|
||||||
|
return (
|
||||||
|
!camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 120_000, polling: 250 },
|
||||||
|
);
|
||||||
|
// Only the engine's canvas takes the pointer: the world overlay draws on a
|
||||||
|
// canvas of its own above it.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.engine.host.canvas.setAttribute(
|
||||||
|
'data-contract-target',
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.addStyleTag({
|
||||||
|
content:
|
||||||
|
'* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }',
|
||||||
|
});
|
||||||
|
// The globe drawn and its tiles loaded: a restored view hides the globe
|
||||||
|
// for a moment after the loading screen goes.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const canvas = engine.host.canvas;
|
||||||
|
const probe = document.createElement('canvas');
|
||||||
|
probe.width = 1;
|
||||||
|
probe.height = 1;
|
||||||
|
const context = probe.getContext('2d', { willReadFrequently: true });
|
||||||
|
const started = performance.now();
|
||||||
|
let settled = 0;
|
||||||
|
while (performance.now() - started < 120_000 && settled < 20) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const remove = engine.frames.postRender.addEventListener(() => {
|
||||||
|
remove();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
engine.render.request('contract-suite-settle');
|
||||||
|
});
|
||||||
|
context.drawImage(
|
||||||
|
canvas,
|
||||||
|
canvas.width / 2,
|
||||||
|
canvas.height / 2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||||
|
settled =
|
||||||
|
r + g + b > 60 && engine.surface.tilesLoaded() ? settled + 1 : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// A real left click and pointer move at a canvas position.
|
||||||
|
await page.exposeFunction('__contractPointer', async (type, x, y) => {
|
||||||
|
const box = await page.evaluate(() => {
|
||||||
|
const rect =
|
||||||
|
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
|
||||||
|
return { left: rect.left, top: rect.top };
|
||||||
|
});
|
||||||
|
const at = [box.left + x, box.top + y];
|
||||||
|
if (type === 'click') await page.mouse.click(...at);
|
||||||
|
else {
|
||||||
|
await page.mouse.move(at[0] - 1, at[1]);
|
||||||
|
await page.mouse.move(...at);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.exposeFunction('__contractDrag', async (x, y, endX, endY) => {
|
||||||
|
const box = await page.evaluate(() => {
|
||||||
|
const rect =
|
||||||
|
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
|
||||||
|
return { left: rect.left, top: rect.top };
|
||||||
|
});
|
||||||
|
await page.mouse.move(box.left + x, box.top + y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.left + endX, box.top + endY, { steps: 4 });
|
||||||
|
await page.mouse.up();
|
||||||
|
});
|
||||||
|
await page.exposeFunction('__contractResize', async (width, height) => {
|
||||||
|
await page.setViewport({
|
||||||
|
width: Math.round(width),
|
||||||
|
height: Math.round(height),
|
||||||
|
deviceScaleFactor: dpr,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const results = await page.evaluate(
|
||||||
|
async (appOwned, only) => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const waitFrames = (count) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
remove();
|
||||||
|
reject(new Error('Timed out waiting for a rendered frame'));
|
||||||
|
}, 10_000);
|
||||||
|
let left = count;
|
||||||
|
const remove = engine.frames.postRender.addEventListener(() => {
|
||||||
|
if (--left > 0) return;
|
||||||
|
remove();
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const pointer = {
|
||||||
|
click: (x, y) => window.__contractPointer('click', x, y),
|
||||||
|
move: (x, y) => window.__contractPointer('move', x, y),
|
||||||
|
drag: (x, y, endX, endY) => window.__contractDrag(x, y, endX, endY),
|
||||||
|
};
|
||||||
|
const { createContractSuite } =
|
||||||
|
await import('/src/globe/testing/contractSuite.mjs');
|
||||||
|
const fixtureModule =
|
||||||
|
engine.kind === 'cesium'
|
||||||
|
? await import('/src/globe/cesium/testing/contractFixtures.js')
|
||||||
|
: await import('/src/globe/navara/testing/contractFixtures.js');
|
||||||
|
const fixtures = await fixtureModule.createContractFixtures();
|
||||||
|
const outcome = [];
|
||||||
|
// A real 2D canvas: a sprite atlas the engines can draw and pick.
|
||||||
|
const makeCanvas = (width, height) =>
|
||||||
|
Object.assign(document.createElement('canvas'), { width, height });
|
||||||
|
// RGBA of the last drawn frame (both engines keep their drawing buffer).
|
||||||
|
const scratch = document.createElement('canvas');
|
||||||
|
const context = scratch.getContext('2d', { willReadFrequently: true });
|
||||||
|
const readRegion = (x, y, width, height) => {
|
||||||
|
const canvas = engine.host.canvas;
|
||||||
|
const scale = canvas.width / canvas.clientWidth;
|
||||||
|
scratch.width = width;
|
||||||
|
scratch.height = height;
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
context.drawImage(
|
||||||
|
canvas,
|
||||||
|
x * scale,
|
||||||
|
y * scale,
|
||||||
|
width * scale,
|
||||||
|
height * scale,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
return context.getImageData(0, 0, width, height).data;
|
||||||
|
};
|
||||||
|
for (const { name, run } of createContractSuite(engine, {
|
||||||
|
waitFrames,
|
||||||
|
pointer,
|
||||||
|
readRegion,
|
||||||
|
makeCanvas,
|
||||||
|
fixtures,
|
||||||
|
resize: (width, height) => window.__contractResize(width, height),
|
||||||
|
})) {
|
||||||
|
if (only && !only.some((prefix) => name.startsWith(prefix))) continue;
|
||||||
|
const owner = appOwned.find(([prefix]) => name.startsWith(prefix));
|
||||||
|
if (owner) {
|
||||||
|
outcome.push({ name, skipped: owner[1] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
outcome.push({ name, ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
outcome.push({
|
||||||
|
name,
|
||||||
|
ok: false,
|
||||||
|
error: String(error?.message ?? error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fixtures.destroy?.();
|
||||||
|
return outcome;
|
||||||
|
},
|
||||||
|
APP_OWNED,
|
||||||
|
only,
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
return { engine: kind, dpr, results, pageErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
// A Navara page can hold its main thread for minutes while it starts.
|
||||||
|
protocolTimeout: 600_000,
|
||||||
|
args: [
|
||||||
|
`--use-angle=${process.env.QA_ANGLE ?? (process.platform === 'darwin' ? 'metal' : 'swiftshader')}`,
|
||||||
|
'--enable-gpu',
|
||||||
|
'--no-sandbox',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const report = [];
|
||||||
|
try {
|
||||||
|
for (const kind of engines) {
|
||||||
|
const run = await runOn(browser, kind);
|
||||||
|
report.push(run);
|
||||||
|
const ran = run.results.filter(({ skipped }) => !skipped);
|
||||||
|
const failed = ran.filter(({ ok }) => !ok);
|
||||||
|
const known = failed.filter(({ knownFailure }) => knownFailure);
|
||||||
|
console.log(
|
||||||
|
`${kind} DPR ${dpr}: ${ran.length - failed.length}/${ran.length} checks pass; ${known.length} known failures; ${run.results.length - ran.length} skipped`,
|
||||||
|
);
|
||||||
|
for (const { name, skipped } of run.results)
|
||||||
|
if (skipped) console.log(` skipped ${name}: ${skipped}`);
|
||||||
|
for (const { name, error, knownFailure } of failed)
|
||||||
|
console.log(
|
||||||
|
` ${knownFailure ? 'KNOWN FAIL' : 'FAIL'} ${name}: ${error}`,
|
||||||
|
);
|
||||||
|
for (const message of run.pageErrors)
|
||||||
|
console.log(` page error: ${message}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
const json = option('json');
|
||||||
|
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
process.exit(
|
||||||
|
report.every(({ results }) =>
|
||||||
|
results.every(
|
||||||
|
({ ok, skipped, knownFailure }) => ok || skipped || knownFailure,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { whenSettled } from './qa/ready.mjs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
createStrict,
|
||||||
|
localPreview,
|
||||||
|
openPage,
|
||||||
|
waitForEngine,
|
||||||
|
} from './qa-strict-harness.mjs';
|
||||||
|
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||||
|
base.hash =
|
||||||
|
'v=2&lat=55.3241&lon=10.7995&alt=25000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||||
|
const strict = createStrict({ origin: base.origin, layers: ['directions'] });
|
||||||
|
await strict.run({}, async (b) => {
|
||||||
|
const { page: p, errors } = await openPage(b);
|
||||||
|
let routeReads = 0,
|
||||||
|
release;
|
||||||
|
await p.setRequestInterception(true);
|
||||||
|
p.on('request', async (req) => {
|
||||||
|
const u = new URL(req.url());
|
||||||
|
const json = (v) =>
|
||||||
|
req.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(v),
|
||||||
|
});
|
||||||
|
if (u.pathname === '/api/terrain/heights') {
|
||||||
|
const cells = (u.searchParams.get('points') ?? '').split(';');
|
||||||
|
return json({ results: cells.map(() => ({ ellipsoid: 34 })) });
|
||||||
|
}
|
||||||
|
if (u.pathname !== '/api/route') return req.continue();
|
||||||
|
routeReads++;
|
||||||
|
const [a, b] = u.searchParams
|
||||||
|
.get('coords')
|
||||||
|
.split(';')
|
||||||
|
.map((p) => p.split(',').map(Number));
|
||||||
|
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||||
|
if (routeReads === 1)
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
geometry: [a, mid, b],
|
||||||
|
distanceM: 9000,
|
||||||
|
durationS: 800,
|
||||||
|
steps: [a, mid, b].map(([lon, lat], i) => ({
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
instruction: 'Route step ' + i,
|
||||||
|
distanceM: 3000,
|
||||||
|
durationS: 120,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await p.goto(base.href);
|
||||||
|
await waitForEngine(p, 'navara');
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { dataManager } = window.__godsEyeView;
|
||||||
|
|
||||||
|
window.qaRoute = dataManager.layers.get('directions').module;
|
||||||
|
await dataManager.setEnabled('directions', true, { origin: 'user' });
|
||||||
|
});
|
||||||
|
for (const [letter, x] of [
|
||||||
|
['a', 480],
|
||||||
|
['b', 645],
|
||||||
|
]) {
|
||||||
|
await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter);
|
||||||
|
await p.mouse.click(x, 440);
|
||||||
|
await whenSettled(p);
|
||||||
|
}
|
||||||
|
assert.equal(routeReads, 1);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'A and B set by clicks on the globe; one route request',
|
||||||
|
);
|
||||||
|
await strict.switchTo(p, 'cesium');
|
||||||
|
release();
|
||||||
|
await p.waitForFunction(() => window.qaRoute.getStats().count === 3);
|
||||||
|
await p.waitForFunction(() => {
|
||||||
|
window.qaRoute.setParams({ step: 1 });
|
||||||
|
return window.qaRoute.getRowControls().list.items[1]?.active;
|
||||||
|
});
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'the route asked for on Navara arrives on Cesium; a step is selectable',
|
||||||
|
);
|
||||||
|
const transfer = async (kind) => {
|
||||||
|
const flying = await p.evaluate(() =>
|
||||||
|
Boolean(window.qaRoute.getFlightProgress()),
|
||||||
|
);
|
||||||
|
if (flying) {
|
||||||
|
// A running dolly owns the camera; verify its handoff before asking for rest.
|
||||||
|
await strict.pick(p, kind, { settle: false });
|
||||||
|
await p.waitForFunction(
|
||||||
|
(kind) =>
|
||||||
|
window.__godsEyeView?.engine?.kind === kind &&
|
||||||
|
!document.querySelector('#globe-engine[aria-busy="true"]') &&
|
||||||
|
window.qaRoute.getFlightProgress()?.traveledM > 0,
|
||||||
|
{},
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
} else await strict.switchTo(p, kind);
|
||||||
|
assert.equal(
|
||||||
|
await p.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers.get('directions').module ===
|
||||||
|
window.qaRoute,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(routeReads, 1);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'same model; the switch requests no route',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await transfer('navara');
|
||||||
|
assert.equal(
|
||||||
|
await p.evaluate(
|
||||||
|
() => window.qaRoute.getRowControls().list.items[1].active,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await p.evaluate(() => window.qaRoute.setParams({ fly: true }));
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')?.active,
|
||||||
|
);
|
||||||
|
const pos = () =>
|
||||||
|
p.evaluate(() => window.__godsEyeView.engine.camera.pose().position);
|
||||||
|
const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||||
|
await p.waitForFunction(() => window.qaRoute.getFlightProgress()?.traveledM > 0);
|
||||||
|
let progress = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||||
|
for (const kind of ['cesium', 'navara']) {
|
||||||
|
const before = await pos();
|
||||||
|
await transfer(kind);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||||
|
?.active,
|
||||||
|
);
|
||||||
|
const at = await pos();
|
||||||
|
await p.waitForFunction(
|
||||||
|
({ at, traveledM }) => {
|
||||||
|
const next = window.__godsEyeView.engine.camera.pose().position;
|
||||||
|
return (
|
||||||
|
window.qaRoute.getFlightProgress()?.traveledM > traveledM &&
|
||||||
|
Math.hypot(next.x - at.x, next.y - at.y, next.z - at.z) > 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{ at, traveledM: progress.traveledM },
|
||||||
|
);
|
||||||
|
const next = await pos();
|
||||||
|
const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||||
|
assert.equal(resumed.motionId, progress.motionId);
|
||||||
|
assert.ok(resumed.traveledM > progress.traveledM);
|
||||||
|
progress = resumed;
|
||||||
|
console.log(kind, 'moved', d(at, next), 'handoff', d(at, before));
|
||||||
|
assert.ok(d(at, next) > 1, 'dolly advances after switch to ' + kind);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'FLY continues with the same motion and advances',
|
||||||
|
{ moved: d(at, next) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await p.evaluate(() => window.qaRoute.setParams({ clear: true }));
|
||||||
|
assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0);
|
||||||
|
// CLEAR releases the camera: the route's motion ends, the camera stays
|
||||||
|
// where it is, and a wheel zoom by the person moves it and is kept.
|
||||||
|
await waitForEngine(p, 'navara');
|
||||||
|
const released = await p.evaluate(() => ({
|
||||||
|
motion: window.qaRoute.getFlightProgress(),
|
||||||
|
fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||||
|
?.active,
|
||||||
|
}));
|
||||||
|
const still = await pos();
|
||||||
|
await whenSettled(p);
|
||||||
|
const later = await pos();
|
||||||
|
const height = () =>
|
||||||
|
p.evaluate(() => window.__godsEyeView.engine.camera.height());
|
||||||
|
const beforeZoom = await height();
|
||||||
|
await p.mouse.move(512, 300);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await p.mouse.wheel({ deltaY: -240 });
|
||||||
|
await whenSettled(p);
|
||||||
|
}
|
||||||
|
await whenSettled(p);
|
||||||
|
const zoomed = await height();
|
||||||
|
await whenSettled(p);
|
||||||
|
const kept = await height();
|
||||||
|
await strict.check(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'CLEAR removes the route and releases the camera: no route motion, the camera stays put, a wheel zoom moves it and is kept',
|
||||||
|
released.motion === null &&
|
||||||
|
!released.fly &&
|
||||||
|
d(still, later) < 1 &&
|
||||||
|
zoomed < beforeZoom * 0.95 &&
|
||||||
|
Math.abs(kept - zoomed) < 0.02 * zoomed,
|
||||||
|
{ released, drift: d(still, later), beforeZoom, zoomed, kept },
|
||||||
|
);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS route delivery during handoff, A/B clicks, step selection, ongoing FLY, clear and single route request',
|
||||||
|
);
|
||||||
|
});
|
||||||
+188
-114
@@ -1,4 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
// Cesium-only probes: native primitive inspection independently verifies the Cesium route geometry.
|
||||||
|
import { evaluateWithGeo } from './qa/geometry.mjs';
|
||||||
|
import { whenSettled, whenUiSettled, pollPage } from './qa/ready.mjs';
|
||||||
/**
|
/**
|
||||||
* Browser proof of the Directions layer: real chip clicks, real globe clicks,
|
* Browser proof of the Directions layer: real chip clicks, real globe clicks,
|
||||||
* a real route from the real proxy, a real camera flight.
|
* a real route from the real proxy, a real camera flight.
|
||||||
@@ -6,10 +9,10 @@
|
|||||||
* Usage: node scripts/qa-directions.mjs [--url http://localhost:4173]
|
* Usage: node scripts/qa-directions.mjs [--url http://localhost:4173]
|
||||||
* [--shots qa-shots/directions] [--label keyed] [--skip denver]
|
* [--shots qa-shots/directions] [--label keyed] [--skip denver]
|
||||||
*/
|
*/
|
||||||
|
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
@@ -34,9 +37,8 @@ const check = (name, passed, detail = '') => {
|
|||||||
if (!passed) failures += 1;
|
if (!passed) failures += 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: false,
|
headless: true,
|
||||||
executablePath: chromeExecutablePath(),
|
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
@@ -62,12 +64,12 @@ const shot = async (name) => {
|
|||||||
|
|
||||||
/** Put the camera over a place, instantly, and let the scene settle. */
|
/** Put the camera over a place, instantly, and let the scene settle. */
|
||||||
async function lookAt(lat, lon, height) {
|
async function lookAt(lat, lon, height) {
|
||||||
await page.evaluate(
|
await evaluateWithGeo(
|
||||||
(la, lo, h) => {
|
page,
|
||||||
const viewer = window.__godsEyeView.viewer;
|
async (qaGeo, la, lo, h) => {
|
||||||
const Cartesian3 = viewer.camera.positionWC.constructor;
|
const Cartesian3 = qaGeo.cartesian3;
|
||||||
viewer.camera.cancelFlight();
|
window.__godsEyeView.engine.camera.cancelFlight();
|
||||||
viewer.camera.setView({
|
window.__godsEyeView.engine.camera.setView({
|
||||||
destination: Cartesian3.fromDegrees(lo, la, h),
|
destination: Cartesian3.fromDegrees(lo, la, h),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 },
|
orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 },
|
||||||
});
|
});
|
||||||
@@ -80,17 +82,16 @@ async function lookAt(lat, lon, height) {
|
|||||||
|
|
||||||
/** Canvas position of a lon/lat on the rendered surface, or null. */
|
/** Canvas position of a lon/lat on the rendered surface, or null. */
|
||||||
const screenAt = (lat, lon) =>
|
const screenAt = (lat, lon) =>
|
||||||
page.evaluate(
|
evaluateWithGeo(
|
||||||
(la, lo) => {
|
page,
|
||||||
const viewer = window.__godsEyeView.viewer;
|
async (qaGeo, la, lo) => {
|
||||||
const scene = viewer.scene;
|
const Cartesian3 = qaGeo.cartesian3;
|
||||||
const Cartesian3 = viewer.camera.positionWC.constructor;
|
const Cartographic = qaGeo.cartographic;
|
||||||
const Cartographic = viewer.camera.positionCartographic.constructor;
|
|
||||||
const carto = Cartographic.fromDegrees(lo, la);
|
const carto = Cartographic.fromDegrees(lo, la);
|
||||||
let ground = scene.globe?.getHeight?.(carto);
|
let ground = window.__godsEyeView.engine.surface.terrainHeight?.(carto);
|
||||||
if (!Number.isFinite(ground)) ground = 0;
|
if (!Number.isFinite(ground)) ground = 0;
|
||||||
const world = Cartesian3.fromDegrees(lo, la, ground);
|
const world = Cartesian3.fromDegrees(lo, la, ground);
|
||||||
const win = scene.cartesianToCanvasCoordinates(world);
|
const win = window.__godsEyeView.engine.projector.worldToWindow(world);
|
||||||
if (!win || !Number.isFinite(win.x)) return null;
|
if (!win || !Number.isFinite(win.x)) return null;
|
||||||
const x = Math.round(win.x);
|
const x = Math.round(win.x);
|
||||||
const y = Math.round(win.y);
|
const y = Math.round(win.y);
|
||||||
@@ -110,11 +111,7 @@ const layerState = () =>
|
|||||||
'#utilities-toggles [data-layer-id="directions"]',
|
'#utilities-toggles [data-layer-id="directions"]',
|
||||||
);
|
);
|
||||||
const controls = module.getRowControls();
|
const controls = module.getRowControls();
|
||||||
const viewer = gev.viewer;
|
const drawn = window.__gevQaDirections.drawn();
|
||||||
const entities = [...viewer.entities.values];
|
|
||||||
const routeEntities = entities.filter(
|
|
||||||
(entity) => String(entity.id) === 'directions:route',
|
|
||||||
);
|
|
||||||
const listNodes = row
|
const listNodes = row
|
||||||
? [...row.querySelectorAll('.data-row-list-item')]
|
? [...row.querySelectorAll('.data-row-list-item')]
|
||||||
: [];
|
: [];
|
||||||
@@ -136,14 +133,9 @@ const layerState = () =>
|
|||||||
domCurrent: listNodes.findIndex((node) =>
|
domCurrent: listNodes.findIndex((node) =>
|
||||||
node.classList.contains('current'),
|
node.classList.contains('current'),
|
||||||
),
|
),
|
||||||
routeEntities: routeEntities.length,
|
routeEntities: drawn.routeSets,
|
||||||
routePositions:
|
routePositions: drawn.routeVertices,
|
||||||
routeEntities[0]?.polyline?.positions?.getValue?.(
|
markerCount: drawn.markersShown,
|
||||||
viewer.clock.currentTime,
|
|
||||||
)?.length || 0,
|
|
||||||
markerCount: entities.filter((entity) =>
|
|
||||||
String(entity.id).startsWith('directions:marker:'),
|
|
||||||
).length,
|
|
||||||
pointerOwner: window.__gevQa.pointerOwner(),
|
pointerOwner: window.__gevQa.pointerOwner(),
|
||||||
cameraMotion: window.__gevQa.getActiveCameraMotion(),
|
cameraMotion: window.__gevQa.getActiveCameraMotion(),
|
||||||
};
|
};
|
||||||
@@ -159,7 +151,7 @@ const clickChip = async (chipId) => {
|
|||||||
button.click();
|
button.click();
|
||||||
return true;
|
return true;
|
||||||
}, chipId);
|
}, chipId);
|
||||||
await sleep(150);
|
await whenUiSettled(page);
|
||||||
return clicked;
|
return clicked;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +159,28 @@ const setMode = async (mode) => {
|
|||||||
await clickChip(`mode-${mode}`);
|
await clickChip(`mode-${mode}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const waitForFlight = (before) =>
|
||||||
|
pollPage(
|
||||||
|
page,
|
||||||
|
(origin) => {
|
||||||
|
const { engine, dataManager } = window.__godsEyeView;
|
||||||
|
const motion = dataManager.layers
|
||||||
|
.get('directions')
|
||||||
|
.module.getFlightProgress();
|
||||||
|
const position = engine.camera.pose().position;
|
||||||
|
return (
|
||||||
|
motion?.kind === 'route' &&
|
||||||
|
motion.traveledM > 0 &&
|
||||||
|
Math.hypot(
|
||||||
|
position.x - origin.x,
|
||||||
|
position.y - origin.y,
|
||||||
|
position.z - origin.z,
|
||||||
|
) > 50
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeoutMs: 15000, intervalMs: 50, args: [before] },
|
||||||
|
);
|
||||||
|
|
||||||
const waitForRoute = () =>
|
const waitForRoute = () =>
|
||||||
page.waitForFunction(
|
page.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
@@ -189,7 +203,7 @@ const waitForMode = (word) =>
|
|||||||
|
|
||||||
const cameraPose = () =>
|
const cameraPose = () =>
|
||||||
page.evaluate(() => {
|
page.evaluate(() => {
|
||||||
const c = window.__godsEyeView.viewer.camera.positionWC;
|
const c = window.__godsEyeView.engine.camera.pose().position;
|
||||||
return { x: c.x, y: c.y, z: c.z };
|
return { x: c.x, y: c.y, z: c.z };
|
||||||
});
|
});
|
||||||
const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||||
@@ -200,7 +214,7 @@ async function placeEndpoints(a, b) {
|
|||||||
const atA = await screenAt(a.lat, a.lon);
|
const atA = await screenAt(a.lat, a.lon);
|
||||||
if (!atA) return false;
|
if (!atA) return false;
|
||||||
await page.mouse.click(atA.x, atA.y);
|
await page.mouse.click(atA.x, atA.y);
|
||||||
await sleep(700);
|
await whenUiSettled(page);
|
||||||
await clickChip('set-b');
|
await clickChip('set-b');
|
||||||
const atB = await screenAt(b.lat, b.lon);
|
const atB = await screenAt(b.lat, b.lon);
|
||||||
if (!atB) return false;
|
if (!atB) return false;
|
||||||
@@ -216,6 +230,7 @@ try {
|
|||||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||||
timeout: 90000,
|
timeout: 90000,
|
||||||
});
|
});
|
||||||
|
await whenSettled(page);
|
||||||
// Two module reads the assertions need. Both are the app's OWN module
|
// Two module reads the assertions need. Both are the app's OWN module
|
||||||
// instances (Vite serves one copy), so this observes rather than simulates.
|
// instances (Vite serves one copy), so this observes rather than simulates.
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
@@ -229,8 +244,98 @@ try {
|
|||||||
releasePointer: own.releasePointer,
|
releasePointer: own.releasePointer,
|
||||||
getActiveCameraMotion: verbs.getActiveCameraMotion,
|
getActiveCameraMotion: verbs.getActiveCameraMotion,
|
||||||
};
|
};
|
||||||
|
// What Directions draws, read through the globe contract
|
||||||
|
// (src/layers/directions/render/display.js): the route is the
|
||||||
|
// `directions-route` ground corridor set, cut into dash pieces; the A/B
|
||||||
|
// discs and the maneuver dots are the `directions-markers` and
|
||||||
|
// `directions-steps` point mover sets. The route's pieces are seen as the
|
||||||
|
// display hands them to engine.ground; which instances are shown is read
|
||||||
|
// from the Cesium engine's own point collection for the set (this gate
|
||||||
|
// drives the Cesium camera).
|
||||||
|
const engine = window.__godsEyeView.engine;
|
||||||
|
let routePieces = null;
|
||||||
|
const createGroundSet = engine.ground.createSet.bind(engine.ground);
|
||||||
|
engine.ground.createSet = (options) => {
|
||||||
|
const set = createGroundSet(options);
|
||||||
|
if (options?.id === 'directions-route') {
|
||||||
|
const replace = set.replace.bind(set);
|
||||||
|
set.replace = (items) => {
|
||||||
|
routePieces = items.map((item) => item.positions.length);
|
||||||
|
return replace(items);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
};
|
||||||
|
const moverPrimitive = (id) => {
|
||||||
|
const walk = (collection) => {
|
||||||
|
for (let i = 0; i < collection.length; i += 1) {
|
||||||
|
const primitive = collection.get(i);
|
||||||
|
if (!primitive) continue;
|
||||||
|
if (primitive.gevMoverSet === id) return primitive;
|
||||||
|
if (
|
||||||
|
primitive.gevMoverSet === undefined &&
|
||||||
|
typeof primitive.get === 'function' &&
|
||||||
|
typeof primitive.length === 'number'
|
||||||
|
) {
|
||||||
|
const found = walk(primitive);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return walk(window.__godsEyeView.viewer.scene.primitives);
|
||||||
|
};
|
||||||
|
const moverCount = (id) =>
|
||||||
|
engine.movers.list().find((set) => set.id === id)?.count ?? 0;
|
||||||
|
const shownInstances = (id) => {
|
||||||
|
const primitive = moverPrimitive(id);
|
||||||
|
if (!primitive || primitive.show === false) return [];
|
||||||
|
const shown = [];
|
||||||
|
const count = Math.min(moverCount(id), primitive.length);
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const instance = primitive.get(i);
|
||||||
|
if (instance.show) shown.push(instance);
|
||||||
|
}
|
||||||
|
return shown;
|
||||||
|
};
|
||||||
|
window.__gevQaDirections = {
|
||||||
|
drawn() {
|
||||||
|
const route = engine.ground
|
||||||
|
.list()
|
||||||
|
.filter((set) => set.id === 'directions-route');
|
||||||
|
// Every original vertex is kept, and each piece adds its two ends,
|
||||||
|
// which are the next piece's start: vertices = total - 2·pieces + 2.
|
||||||
|
const routeVertices =
|
||||||
|
route.length && routePieces?.length
|
||||||
|
? routePieces.reduce((sum, n) => sum + n, 0) -
|
||||||
|
2 * routePieces.length +
|
||||||
|
2
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
routeSets: route.filter((set) => set.shown && set.count > 0).length,
|
||||||
|
routeVertices,
|
||||||
|
markersShown: shownInstances('directions-markers').length,
|
||||||
|
stepCount: moverCount('directions-steps'),
|
||||||
|
stepsShown: shownInstances('directions-steps').map((point) =>
|
||||||
|
point.position.clone(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// Every other layer's contract sets, to prove a disable touches only
|
||||||
|
// the Directions ones.
|
||||||
|
others() {
|
||||||
|
const mine = (id) => String(id).startsWith('directions');
|
||||||
|
return [
|
||||||
|
...engine.movers.list().map((set) => `movers:${set.id}`),
|
||||||
|
...engine.lines.list().map((line) => `lines:${line.id}`),
|
||||||
|
...engine.ground.list().map((set) => `ground:${set.id}`),
|
||||||
|
]
|
||||||
|
.filter((key) => !mine(key.split(':').slice(1).join(':')))
|
||||||
|
.sort();
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
await sleep(3000);
|
await whenSettled(page);
|
||||||
|
|
||||||
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
|
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
|
||||||
const opened = await page.evaluate(() => {
|
const opened = await page.evaluate(() => {
|
||||||
@@ -247,7 +352,7 @@ try {
|
|||||||
.getElementById('utilities-panel')
|
.getElementById('utilities-panel')
|
||||||
.classList.contains('collapsed');
|
.classList.contains('collapsed');
|
||||||
});
|
});
|
||||||
await sleep(700);
|
await whenUiSettled(page);
|
||||||
check('the UTILITIES panel opens from its own header control', opened);
|
check('the UTILITIES panel opens from its own header control', opened);
|
||||||
const toggled = await page.evaluate(() => {
|
const toggled = await page.evaluate(() => {
|
||||||
const row = document.querySelector(
|
const row = document.querySelector(
|
||||||
@@ -259,7 +364,7 @@ try {
|
|||||||
button.click();
|
button.click();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
await sleep(1200);
|
await whenUiSettled(page);
|
||||||
check(
|
check(
|
||||||
'the Directions row is in the Data Layers panel and toggles on',
|
'the Directions row is in the Data Layers panel and toggles on',
|
||||||
toggled,
|
toggled,
|
||||||
@@ -307,13 +412,13 @@ try {
|
|||||||
);
|
);
|
||||||
close?.click();
|
close?.click();
|
||||||
});
|
});
|
||||||
await sleep(500);
|
await whenUiSettled(page);
|
||||||
|
|
||||||
// ── 2. Place A on the Ferry Building, B in Golden Gate Park. ─────────────
|
// ── 2. Place A on the Ferry Building, B in Golden Gate Park. ─────────────
|
||||||
const FERRY = { lat: 37.7955, lon: -122.3937 };
|
const FERRY = { lat: 37.7955, lon: -122.3937 };
|
||||||
const GGPARK = { lat: 37.7694, lon: -122.4862 };
|
const GGPARK = { lat: 37.7694, lon: -122.4862 };
|
||||||
await lookAt(37.782, -122.44, 16000);
|
await lookAt(37.782, -122.44, 16000);
|
||||||
await sleep(5000);
|
await whenSettled(page);
|
||||||
|
|
||||||
await clickChip('set-a');
|
await clickChip('set-a');
|
||||||
state = await layerState();
|
state = await layerState();
|
||||||
@@ -330,7 +435,7 @@ try {
|
|||||||
let at = await screenAt(FERRY.lat, FERRY.lon);
|
let at = await screenAt(FERRY.lat, FERRY.lon);
|
||||||
check('the Ferry Building is on screen', !!at, JSON.stringify(at));
|
check('the Ferry Building is on screen', !!at, JSON.stringify(at));
|
||||||
await page.mouse.click(at.x, at.y);
|
await page.mouse.click(at.x, at.y);
|
||||||
await sleep(800);
|
await whenUiSettled(page);
|
||||||
state = await layerState();
|
state = await layerState();
|
||||||
check(
|
check(
|
||||||
'placing A releases the pointer claim',
|
'placing A releases the pointer claim',
|
||||||
@@ -344,7 +449,7 @@ try {
|
|||||||
// used to place B and deselect whatever was under it in the same gesture.
|
// used to place B and deselect whatever was under it in the same gesture.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__gevQaLateHandler = [];
|
window.__gevQaLateHandler = [];
|
||||||
const canvas = window.__godsEyeView.viewer.scene.canvas;
|
const canvas = window.__godsEyeView.engine.host.canvas;
|
||||||
canvas.addEventListener('pointerup', () => {
|
canvas.addEventListener('pointerup', () => {
|
||||||
window.__gevQaLateHandler.push(window.__gevQa.pointerOwner());
|
window.__gevQaLateHandler.push(window.__gevQa.pointerOwner());
|
||||||
});
|
});
|
||||||
@@ -362,7 +467,7 @@ try {
|
|||||||
`saw ${JSON.stringify(lateOwners)}`,
|
`saw ${JSON.stringify(lateOwners)}`,
|
||||||
);
|
);
|
||||||
await waitForRoute();
|
await waitForRoute();
|
||||||
await sleep(2000);
|
await whenSettled(page);
|
||||||
state = await layerState();
|
state = await layerState();
|
||||||
check(
|
check(
|
||||||
'once that click is over, the pointer claim is returned',
|
'once that click is over, the pointer claim is returned',
|
||||||
@@ -407,7 +512,7 @@ try {
|
|||||||
items[2]?.click();
|
items[2]?.click();
|
||||||
return { active, tabIndex: items[2]?.tabIndex };
|
return { active, tabIndex: items[2]?.tabIndex };
|
||||||
});
|
});
|
||||||
await sleep(600);
|
await whenUiSettled(page);
|
||||||
const afterListClick = await layerState();
|
const afterListClick = await layerState();
|
||||||
check(
|
check(
|
||||||
'a list row takes keyboard focus and selects its maneuver',
|
'a list row takes keyboard focus and selects its maneuver',
|
||||||
@@ -444,7 +549,7 @@ try {
|
|||||||
// ── 4. Swap endpoints. ──────────────────────────────────────────────────
|
// ── 4. Swap endpoints. ──────────────────────────────────────────────────
|
||||||
const beforeSwap = await layerState();
|
const beforeSwap = await layerState();
|
||||||
await clickChip('swap');
|
await clickChip('swap');
|
||||||
await sleep(3500);
|
await waitForRoute();
|
||||||
const afterSwap = await layerState();
|
const afterSwap = await layerState();
|
||||||
check(
|
check(
|
||||||
'SWAP reverses the route',
|
'SWAP reverses the route',
|
||||||
@@ -453,7 +558,7 @@ try {
|
|||||||
`"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`,
|
`"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`,
|
||||||
);
|
);
|
||||||
await clickChip('swap');
|
await clickChip('swap');
|
||||||
await sleep(3500);
|
await waitForRoute();
|
||||||
|
|
||||||
// ── 5. Three rapid reroutes leave exactly one route. ─────────────────────
|
// ── 5. Three rapid reroutes leave exactly one route. ─────────────────────
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -462,7 +567,7 @@ try {
|
|||||||
manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' });
|
manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' });
|
||||||
manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' });
|
manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' });
|
||||||
});
|
});
|
||||||
await sleep(6000);
|
await waitForRoute();
|
||||||
const rapid = await layerState();
|
const rapid = await layerState();
|
||||||
check(
|
check(
|
||||||
'three rapid reroutes leave exactly one route',
|
'three rapid reroutes leave exactly one route',
|
||||||
@@ -575,19 +680,19 @@ try {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await waitForRoute();
|
await waitForRoute();
|
||||||
await sleep(1200);
|
await whenUiSettled(page);
|
||||||
}
|
}
|
||||||
await cdp.send('Fetch.disable').catch(() => {});
|
await cdp.send('Fetch.disable').catch(() => {});
|
||||||
|
|
||||||
// ── 7. FLY, a mid-flight frame, and a cancel that actually stops it. ─────
|
// ── 7. FLY, a mid-flight frame, and a cancel that actually stops it. ─────
|
||||||
const beforeFly = await cameraPose();
|
const beforeFly = await cameraPose();
|
||||||
check('the FLY chip is offered once a route exists', await clickChip('fly'));
|
check('the FLY chip is offered once a route exists', await clickChip('fly'));
|
||||||
await sleep(3000);
|
const flightStarted = await waitForFlight(beforeFly);
|
||||||
const flying = await layerState();
|
const flying = await layerState();
|
||||||
const midFly = await cameraPose();
|
const midFly = await cameraPose();
|
||||||
check(
|
check(
|
||||||
'FLY starts a route flight',
|
'FLY starts a route flight',
|
||||||
flying.cameraMotion?.kind === 'route',
|
flightStarted && flying.cameraMotion?.kind === 'route',
|
||||||
JSON.stringify(flying.cameraMotion),
|
JSON.stringify(flying.cameraMotion),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
@@ -605,7 +710,7 @@ try {
|
|||||||
// A reroute replaces the route the dolly is flying; the dolly has to land
|
// A reroute replaces the route the dolly is flying; the dolly has to land
|
||||||
// first rather than keep flying a route that no longer exists.
|
// first rather than keep flying a route that no longer exists.
|
||||||
await setMode('foot');
|
await setMode('foot');
|
||||||
await sleep(1200);
|
await whenUiSettled(page);
|
||||||
const afterReroute = await layerState();
|
const afterReroute = await layerState();
|
||||||
check(
|
check(
|
||||||
'changing profile mid-flight lands the flight it replaced',
|
'changing profile mid-flight lands the flight it replaced',
|
||||||
@@ -615,9 +720,10 @@ try {
|
|||||||
await waitForMode('Walk');
|
await waitForMode('Walk');
|
||||||
await setMode('car');
|
await setMode('car');
|
||||||
await waitForMode('Drive');
|
await waitForMode('Drive');
|
||||||
await sleep(800);
|
await whenUiSettled(page);
|
||||||
|
const beforeRefly = await cameraPose();
|
||||||
check('FLY is offered again after the reroute', await clickChip('fly'));
|
check('FLY is offered again after the reroute', await clickChip('fly'));
|
||||||
await sleep(2000);
|
check('the second FLY starts before CLEAR', await waitForFlight(beforeRefly));
|
||||||
|
|
||||||
await clickChip('clear');
|
await clickChip('clear');
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
@@ -687,7 +793,7 @@ try {
|
|||||||
// ── 9. Denver: maneuver dots sit on the ground, not at sea level. ────────
|
// ── 9. Denver: maneuver dots sit on the ground, not at sea level. ────────
|
||||||
if (!SKIP.has('denver')) {
|
if (!SKIP.has('denver')) {
|
||||||
await lookAt(39.744, -104.999, 7000);
|
await lookAt(39.744, -104.999, 7000);
|
||||||
await sleep(6000);
|
await whenSettled(page);
|
||||||
const placed = await placeEndpoints(
|
const placed = await placeEndpoints(
|
||||||
{ lat: 39.7392, lon: -104.9903 },
|
{ lat: 39.7392, lon: -104.9903 },
|
||||||
{ lat: 39.7487, lon: -105.0077 },
|
{ lat: 39.7487, lon: -105.0077 },
|
||||||
@@ -696,32 +802,21 @@ try {
|
|||||||
if (placed) {
|
if (placed) {
|
||||||
await waitForRoute();
|
await waitForRoute();
|
||||||
// Let the shared ground-floor cells warm and the dots re-anchor.
|
// Let the shared ground-floor cells warm and the dots re-anchor.
|
||||||
await sleep(9000);
|
await pollPage(
|
||||||
const denver = await page.evaluate(() => {
|
page,
|
||||||
const viewer = window.__godsEyeView.viewer;
|
() => {
|
||||||
const Cartographic = viewer.camera.positionCartographic.constructor;
|
const { stepCount, stepsShown } = window.__gevQaDirections.drawn();
|
||||||
const scene = viewer.scene;
|
return stepCount > 0 && stepsShown.length === stepCount;
|
||||||
let collection = null;
|
},
|
||||||
for (let i = 0; i < scene.primitives.length; i += 1) {
|
{ timeoutMs: 60_000 },
|
||||||
const primitive = scene.primitives.get(i);
|
);
|
||||||
if (
|
const denver = await evaluateWithGeo(page, async (qaGeo) => {
|
||||||
primitive &&
|
const Cartographic = qaGeo.cartographic;
|
||||||
typeof primitive.get === 'function' &&
|
const { stepCount, stepsShown } = window.__gevQaDirections.drawn();
|
||||||
primitive.length > 0 &&
|
const heights = stepsShown.map(
|
||||||
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
(position) => Cartographic.fromCartesian(position).height,
|
||||||
) {
|
);
|
||||||
collection = primitive;
|
return { dots: stepCount, shown: heights.length, heights };
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!collection) return { dots: 0, shown: 0, heights: [] };
|
|
||||||
const heights = [];
|
|
||||||
for (let i = 0; i < collection.length; i += 1) {
|
|
||||||
const point = collection.get(i);
|
|
||||||
if (!point.show) continue;
|
|
||||||
heights.push(Cartographic.fromCartesian(point.position).height);
|
|
||||||
}
|
|
||||||
return { dots: collection.length, shown: heights.length, heights };
|
|
||||||
});
|
});
|
||||||
check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`);
|
check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`);
|
||||||
check(
|
check(
|
||||||
@@ -744,55 +839,34 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 10. Disable: nothing is left behind. ────────────────────────────────
|
// ── 10. Disable: nothing is left behind. ────────────────────────────────
|
||||||
const { beforeDisable, directionsBeforeDisable } = await page.evaluate(() => {
|
const { othersBefore, drawnBefore } = await page.evaluate(() => ({
|
||||||
const entities = [...window.__godsEyeView.viewer.entities.values];
|
othersBefore: window.__gevQaDirections.others(),
|
||||||
return {
|
drawnBefore: window.__gevQaDirections.drawn(),
|
||||||
beforeDisable: entities.length,
|
}));
|
||||||
directionsBeforeDisable: entities.filter((entity) =>
|
|
||||||
String(entity.id).startsWith('directions:'),
|
|
||||||
).length,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
window.__godsEyeView.dataManager.setEnabled('directions', false, {
|
window.__godsEyeView.dataManager.setEnabled('directions', false, {
|
||||||
origin: 'user',
|
origin: 'user',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await sleep(1500);
|
await whenSettled(page);
|
||||||
const off = await page.evaluate(() => {
|
const off = await page.evaluate(() => {
|
||||||
const gev = window.__godsEyeView;
|
const drawn = window.__gevQaDirections.drawn();
|
||||||
const viewer = gev.viewer;
|
|
||||||
const left = [...viewer.entities.values].filter((entity) =>
|
|
||||||
String(entity.id).startsWith('directions:'),
|
|
||||||
);
|
|
||||||
let shownDots = 0;
|
|
||||||
for (let i = 0; i < viewer.scene.primitives.length; i += 1) {
|
|
||||||
const primitive = viewer.scene.primitives.get(i);
|
|
||||||
if (
|
|
||||||
primitive &&
|
|
||||||
typeof primitive.get === 'function' &&
|
|
||||||
primitive.length > 0 &&
|
|
||||||
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
|
||||||
) {
|
|
||||||
shownDots = primitive.show ? primitive.length : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const row = document.querySelector(
|
const row = document.querySelector(
|
||||||
'#utilities-toggles [data-layer-id="directions"]',
|
'#utilities-toggles [data-layer-id="directions"]',
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
entities: left.length,
|
entities: drawn.routeSets + drawn.markersShown,
|
||||||
shownDots,
|
shownDots: drawn.stepsShown.length,
|
||||||
controlsHidden:
|
controlsHidden:
|
||||||
row?.querySelector('.data-toggle-controls')?.hidden === true,
|
row?.querySelector('.data-toggle-controls')?.hidden === true,
|
||||||
listHidden: row?.querySelector('.data-row-list')?.hidden === true,
|
listHidden: row?.querySelector('.data-row-list')?.hidden === true,
|
||||||
pointerOwner: window.__gevQa.pointerOwner(),
|
pointerOwner: window.__gevQa.pointerOwner(),
|
||||||
motion: window.__gevQa.getActiveCameraMotion(),
|
motion: window.__gevQa.getActiveCameraMotion(),
|
||||||
totalEntities: viewer.entities.values.length,
|
others: window.__gevQaDirections.others(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
'disabling removes every Directions entity',
|
'disabling removes the route and hides both markers',
|
||||||
off.entities === 0,
|
off.entities === 0,
|
||||||
`${off.entities} left`,
|
`${off.entities} left`,
|
||||||
);
|
);
|
||||||
@@ -809,9 +883,9 @@ try {
|
|||||||
);
|
);
|
||||||
check('disabling leaves no camera motion running', off.motion === null);
|
check('disabling leaves no camera motion running', off.motion === null);
|
||||||
check(
|
check(
|
||||||
'exactly the Directions entities went away, and nothing else did',
|
"only the Directions drawing went away: every other layer's sets stay",
|
||||||
off.totalEntities === beforeDisable - directionsBeforeDisable,
|
JSON.stringify(off.others) === JSON.stringify(othersBefore),
|
||||||
`${beforeDisable} total (${directionsBeforeDisable} ours) -> ${off.totalEntities}`,
|
`ours before: route ${drawnBefore.routeSets}, markers ${drawnBefore.markersShown}, dots ${drawnBefore.stepsShown.length}; others ${othersBefore.length} -> ${off.others.length}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
check(
|
check(
|
||||||
@@ -834,7 +908,7 @@ try {
|
|||||||
check('harness completed', false, error?.stack || String(error));
|
check('harness completed', false, error?.stack || String(error));
|
||||||
await shot(`99-${LABEL}-failure.jpg`).catch(() => {});
|
await shot(`99-${LABEL}-failure.jpg`).catch(() => {});
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(SHOTS, `qa-directions-${LABEL}.log`),
|
path.join(SHOTS, `qa-directions-${LABEL}.log`),
|
||||||
`${lines.join('\n')}\n${failures} failure(s)\n`,
|
`${lines.join('\n')}\n${failures} failure(s)\n`,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { pollPage } from './qa/ready.mjs';
|
||||||
|
import { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
|
||||||
|
|
||||||
|
// Evaluate the browser wait without launching the executable scenario.
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL('./qa-directions.mjs', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const waitSource = source.slice(
|
||||||
|
source.indexOf('const waitForFlight ='),
|
||||||
|
source.indexOf('const waitForRoute ='),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const startsAt of [50, 12000, null]) {
|
||||||
|
test(`directions FLY waits for owned route progress (${startsAt ?? 'never'} ms)`, async (t) => {
|
||||||
|
const engine = createFakeEngine();
|
||||||
|
t.after(() => engine.destroy());
|
||||||
|
let motion = null;
|
||||||
|
let disposed = 0;
|
||||||
|
let observedAt;
|
||||||
|
const before = engine.camera.pose().position;
|
||||||
|
const waitForFlight = vm.runInNewContext(`${waitSource}\nwaitForFlight`, {
|
||||||
|
pollPage,
|
||||||
|
window: {
|
||||||
|
__godsEyeView: {
|
||||||
|
engine,
|
||||||
|
dataManager: {
|
||||||
|
layers: new Map([
|
||||||
|
['directions', { module: { getFlightProgress: () => motion } }],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
async waitForFunction(check, options, origin) {
|
||||||
|
const predicate = () => check(origin);
|
||||||
|
assert.equal(options.timeout, 15000);
|
||||||
|
assert.equal(options.polling, 50);
|
||||||
|
assert.equal(predicate(), false, 'no owned flight yet');
|
||||||
|
motion = { kind: 'route', traveledM: 0 };
|
||||||
|
assert.equal(predicate(), false, 'arming is not a mid-flight frame');
|
||||||
|
motion = { kind: 'orbit', traveledM: 20 };
|
||||||
|
assert.equal(predicate(), false, 'another camera motion cannot pass');
|
||||||
|
motion = { kind: 'route', traveledM: 20 };
|
||||||
|
assert.equal(predicate(), false, 'the camera must have moved too');
|
||||||
|
engine.fake.setPose({ position: { ...before, y: before.y + 60 } });
|
||||||
|
for (let time = 0; time < options.timeout; time += options.polling) {
|
||||||
|
motion =
|
||||||
|
startsAt !== null && time >= startsAt
|
||||||
|
? { kind: 'route', traveledM: 20 }
|
||||||
|
: null;
|
||||||
|
if (predicate()) {
|
||||||
|
observedAt = time;
|
||||||
|
return {
|
||||||
|
dispose: async () => {
|
||||||
|
disposed++;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const error = new Error('flight did not start');
|
||||||
|
error.name = 'TimeoutError';
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(await waitForFlight(before), startsAt !== null);
|
||||||
|
assert.equal(observedAt, startsAt ?? undefined);
|
||||||
|
assert.equal(disposed, startsAt === null ? 0 : 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/** Verify imported camera directions through real rendering and camera authority handoffs. */
|
/** Verify imported camera directions through real rendering and camera authority handoffs. */
|
||||||
|
import { whenSettled } from './qa/ready.mjs';
|
||||||
|
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
const output = path.resolve('qa-shots/director-camera');
|
const output = path.resolve('qa-shots/director-camera');
|
||||||
fs.mkdirSync(output, { recursive: true });
|
fs.mkdirSync(output, { recursive: true });
|
||||||
const browser = await puppeteer.launch({
|
const browser = await launchBrowser({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||||
});
|
});
|
||||||
@@ -23,12 +24,10 @@ try {
|
|||||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||||
{ waitUntil: 'domcontentloaded' },
|
{ waitUntil: 'domcontentloaded' },
|
||||||
);
|
);
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(() => window.__godsEyeView?.sceneDirector, {
|
||||||
() =>
|
timeout: 60000,
|
||||||
window.__godsEyeView?.sceneDirector &&
|
});
|
||||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
await whenSettled(page);
|
||||||
{ timeout: 60000 },
|
|
||||||
);
|
|
||||||
const result = await page.evaluate(async () => {
|
const result = await page.evaluate(async () => {
|
||||||
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
|
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
|
||||||
const file = {
|
const file = {
|
||||||
@@ -137,7 +136,7 @@ try {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
||||||
await new Promise((r) => setTimeout(r, 6000));
|
await whenSettled(page);
|
||||||
await page.screenshot({ path: path.join(output, 'austin.png') });
|
await page.screenshot({ path: path.join(output, 'austin.png') });
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const d = window.__godsEyeView.sceneDirector;
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
@@ -145,7 +144,7 @@ try {
|
|||||||
d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 });
|
d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 });
|
||||||
d.viewer.scene.requestRender();
|
d.viewer.scene.requestRender();
|
||||||
});
|
});
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
await whenSettled(page);
|
||||||
await page.screenshot({ path: path.join(output, 'angle.png') });
|
await page.screenshot({ path: path.join(output, 'angle.png') });
|
||||||
const inputStarted = await page.evaluate(async () => {
|
const inputStarted = await page.evaluate(async () => {
|
||||||
const d = window.__godsEyeView.sceneDirector;
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
@@ -205,6 +204,6 @@ try {
|
|||||||
);
|
);
|
||||||
check('no uncaught browser errors', errors.length === 0);
|
check('no uncaught browser errors', errors.length === 0);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await closeBrowser(browser);
|
||||||
}
|
}
|
||||||
if (failed) process.exitCode = 1;
|
if (failed) process.exitCode = 1;
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { whenPickable, whenSettled } from './qa/ready.mjs';
|
||||||
|
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||||
|
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
|
const url = localPreview(process.argv[2]);
|
||||||
|
url.search = '?engine=navara&welcome=0';
|
||||||
|
url.hash =
|
||||||
|
'v=2&lat=30.2672&lon=-97.742&alt=4000&pitch=-90&heading=0&map=sentinel2&l=';
|
||||||
|
const png = await sharp({
|
||||||
|
create: { width: 64, height: 64, channels: 4, background: '#fd2233' },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
id: 'point',
|
||||||
|
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'line',
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: [
|
||||||
|
[-97.745, 30.268, 400],
|
||||||
|
[-97.735, 30.268, 400],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'polygon',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[-97.747, 30.264, 450],
|
||||||
|
[-97.74, 30.264, 450],
|
||||||
|
[-97.74, 30.267, 450],
|
||||||
|
[-97.747, 30.264, 450],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].map((f) => ({ ...f, type: 'Feature' }));
|
||||||
|
const wav = Buffer.alloc(44 + 16000);
|
||||||
|
wav.write('RIFF');
|
||||||
|
wav.writeUInt32LE(wav.length - 8, 4);
|
||||||
|
wav.write('WAVEfmt ', 8);
|
||||||
|
wav.writeUInt32LE(16, 16);
|
||||||
|
wav.writeUInt16LE(1, 20);
|
||||||
|
wav.writeUInt16LE(1, 22);
|
||||||
|
wav.writeUInt32LE(8000, 24);
|
||||||
|
wav.writeUInt32LE(16000, 28);
|
||||||
|
wav.writeUInt16LE(2, 32);
|
||||||
|
wav.writeUInt16LE(16, 34);
|
||||||
|
wav.write('data', 36);
|
||||||
|
wav.writeUInt32LE(16000, 40);
|
||||||
|
const pack = (id, format, path, placement) => ({
|
||||||
|
id,
|
||||||
|
version: 1,
|
||||||
|
format,
|
||||||
|
source: { adapter: 'assets', path },
|
||||||
|
attribution: { text: 'Local fixture', license: 'CC0-1.0' },
|
||||||
|
placement,
|
||||||
|
});
|
||||||
|
const camera = {
|
||||||
|
lat: 30.2672,
|
||||||
|
lon: -97.742,
|
||||||
|
alt: 4000,
|
||||||
|
heading: 0,
|
||||||
|
pitch: -90,
|
||||||
|
roll: 0,
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
};
|
||||||
|
const shot = {
|
||||||
|
id: 'one',
|
||||||
|
title: 'Pack view',
|
||||||
|
camera,
|
||||||
|
durationSec: 5,
|
||||||
|
holdSec: 3,
|
||||||
|
visual: { style: 'normal', mapStack: 'sentinel2' },
|
||||||
|
layers: { earthquakes: true },
|
||||||
|
dataPackIds: ['data', 'image', 'audio'],
|
||||||
|
interactions: features.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
label: 'Show ' + f.id,
|
||||||
|
target: { packId: 'data', featureId: f.id },
|
||||||
|
action: { type: 'card', text: f.id },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const project = {
|
||||||
|
version: 6,
|
||||||
|
scenes: [
|
||||||
|
{
|
||||||
|
id: 'parity',
|
||||||
|
title: 'Test project',
|
||||||
|
anchors: [
|
||||||
|
{
|
||||||
|
id: 'station',
|
||||||
|
lat: 30.271,
|
||||||
|
lon: -97.735,
|
||||||
|
alt: 500,
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataPacks: [
|
||||||
|
pack('data', 'geojson', 'data.json', {
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
}),
|
||||||
|
pack('image', 'image', 'image.png', {
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
bounds: [-97.738, 30.264, -97.732, 30.267],
|
||||||
|
height: 500,
|
||||||
|
}),
|
||||||
|
pack('audio', 'media', 'audio.wav', { anchorId: 'station' }),
|
||||||
|
],
|
||||||
|
shots: [
|
||||||
|
shot,
|
||||||
|
{
|
||||||
|
...shot,
|
||||||
|
id: 'two',
|
||||||
|
title: 'Next shot',
|
||||||
|
camera: { ...camera, heading: 15 },
|
||||||
|
move: { from: camera, easing: 'linear' },
|
||||||
|
dataPackIds: [],
|
||||||
|
interactions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// Scene Director is not a catalog layer; the scenario answers the scene's
|
||||||
|
// earthquake feed itself.
|
||||||
|
const strict = createStrict({
|
||||||
|
origin: url.origin,
|
||||||
|
layers: [],
|
||||||
|
fixtures: ['https://earthquake.usgs.gov/*'],
|
||||||
|
});
|
||||||
|
await strict.run({}, async (b) => {
|
||||||
|
const p = await b.newPage();
|
||||||
|
await p.setViewport({ width: 1280, height: 900 });
|
||||||
|
const errors = [];
|
||||||
|
p.on('pageerror', (e) => {
|
||||||
|
errors.push(e.message);
|
||||||
|
console.log('error', e.message);
|
||||||
|
});
|
||||||
|
const reads = [];
|
||||||
|
await p.setRequestInterception(true);
|
||||||
|
p.on('request', (req) => {
|
||||||
|
const path = new URL(req.url()).pathname;
|
||||||
|
if (path.startsWith('/scene-assets/')) {
|
||||||
|
reads.push(path);
|
||||||
|
const file = path.split('/').pop();
|
||||||
|
return req.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType:
|
||||||
|
file === 'data.json'
|
||||||
|
? 'application/geo+json'
|
||||||
|
: file === 'audio.wav'
|
||||||
|
? 'audio/wav'
|
||||||
|
: 'image/png',
|
||||||
|
body:
|
||||||
|
file === 'data.json'
|
||||||
|
? JSON.stringify({ type: 'FeatureCollection', features })
|
||||||
|
: file === 'audio.wav'
|
||||||
|
? wav
|
||||||
|
: png,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (new URL(req.url()).hostname === 'earthquake.usgs.gov')
|
||||||
|
return req.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||||
|
body: '{"type":"FeatureCollection","features":[]}',
|
||||||
|
});
|
||||||
|
return req.continue();
|
||||||
|
});
|
||||||
|
await p.goto(url.href);
|
||||||
|
await whenSettled(p);
|
||||||
|
await assertKeylessPreview(p);
|
||||||
|
await p.evaluate(async (project) => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
|
||||||
|
await a.styleManager.initialRestorePromise;
|
||||||
|
window.director = a.sceneDirector;
|
||||||
|
await window.director.importProjectFile(
|
||||||
|
new File([JSON.stringify(project)], 'qa.json'),
|
||||||
|
);
|
||||||
|
const out = await window.director.loadShot('parity', 'one', {
|
||||||
|
flyDuration: 0.2,
|
||||||
|
});
|
||||||
|
if (!out.started)
|
||||||
|
throw Error(
|
||||||
|
'Pack load failed: ' +
|
||||||
|
JSON.stringify(out) +
|
||||||
|
' ' +
|
||||||
|
window.director._presentation.status,
|
||||||
|
);
|
||||||
|
}, project);
|
||||||
|
await p.waitForSelector('[data-director-pack="audio"] audio');
|
||||||
|
await p.evaluate(() => {
|
||||||
|
window.qaAudio = document.querySelector(
|
||||||
|
'[data-director-pack="audio"] audio',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const at = (world) =>
|
||||||
|
p.evaluate((world) => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
const xy = a.engine.projector.worldToWindow(world),
|
||||||
|
rect = a.engine.host.canvas.getBoundingClientRect();
|
||||||
|
return { x: xy.x + rect.x, y: xy.y + rect.y };
|
||||||
|
}, world);
|
||||||
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
|
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind)
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
await whenSettled(p);
|
||||||
|
const st = await p.evaluate(() => ({
|
||||||
|
same: window.director === window.__godsEyeView.sceneDirector,
|
||||||
|
media:
|
||||||
|
window.qaAudio ===
|
||||||
|
document.querySelector('[data-director-pack="audio"] audio'),
|
||||||
|
packs: window.director.getDataPackState(),
|
||||||
|
actions: window.director.getInteractionState(),
|
||||||
|
}));
|
||||||
|
assert.ok(
|
||||||
|
st.same && st.media && st.packs.count === 3 && st.actions.count === 3,
|
||||||
|
);
|
||||||
|
const xy = await at(fromDegrees(-97.742, 30.27, 450));
|
||||||
|
await whenPickable(p, xy, (picked) => picked?.id === '["data","point"]');
|
||||||
|
await p.mouse.click(xy.x, xy.y);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => document.activeElement?.dataset.directorAction === 'point',
|
||||||
|
);
|
||||||
|
await p.keyboard.press('Enter');
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.director.getInteractionState().selected === 'point',
|
||||||
|
);
|
||||||
|
for (const [id, coords] of [
|
||||||
|
['line', [-97.74, 30.268, 400]],
|
||||||
|
['polygon', [-97.742, 30.265, 450]],
|
||||||
|
]) {
|
||||||
|
const hit = await at(fromDegrees(...coords));
|
||||||
|
await p.mouse.click(hit.x, hit.y);
|
||||||
|
await p.waitForFunction(
|
||||||
|
(id) => document.activeElement?.dataset.directorAction === id,
|
||||||
|
{ timeout: 10000 },
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const imageAt = await at(fromDegrees(-97.735, 30.2655, 500));
|
||||||
|
const sample = await sharp(await p.screenshot())
|
||||||
|
.extract({
|
||||||
|
left: Math.round(imageAt.x),
|
||||||
|
top: Math.round(imageAt.y),
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
})
|
||||||
|
.removeAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
console.log(kind, st.packs.count, st.actions.count, Array.from(sample));
|
||||||
|
assert.ok(
|
||||||
|
sample[0] > 100 && sample[0] > sample[1] * 2,
|
||||||
|
'authored image really draws',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
reads.length,
|
||||||
|
3,
|
||||||
|
'packs are not acquired again on a globe change',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await p.evaluate(() => {
|
||||||
|
window.qaRun = window.director.startScene('parity', {
|
||||||
|
single: true,
|
||||||
|
preview: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.director.running &&
|
||||||
|
window.director.getPlaybackTimingState().snapshot?.shotId === 'one',
|
||||||
|
);
|
||||||
|
await strict.switchTo(p, 'cesium');
|
||||||
|
assert.ok(
|
||||||
|
await p.evaluate(
|
||||||
|
() =>
|
||||||
|
window.director.running &&
|
||||||
|
window.director === window.__godsEyeView.sceneDirector,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.director.getPlaybackTimingState().snapshot?.shotId === 'two',
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
await strict.switchTo(p, 'navara');
|
||||||
|
const complete = await p.evaluate(() => window.qaRun);
|
||||||
|
assert.ok(complete.started && complete.shots === 2);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS authored packs, geographic picks, same player, original multi-shot timeline',
|
||||||
|
);
|
||||||
|
});
|
||||||
Loaded 100 of 1637 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user