mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Compare commits
602
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c64713ee | ||
|
|
7abf74518f | ||
|
|
c502404e1d | ||
|
|
350a7f5abb | ||
|
|
0ee92eb864 | ||
|
|
909be62ae7 | ||
|
|
8bea1419f6 | ||
|
|
e1d3a588fc | ||
|
|
843c4a111d | ||
|
|
43b4e9348b | ||
|
|
727bb29cae | ||
|
|
0c0df50a40 | ||
|
|
db9bf64821 | ||
|
|
cd7c5d5a17 | ||
|
|
a2f5b8fca3 | ||
|
|
908cce915a | ||
|
|
06ae95ceb3 | ||
|
|
bc1b56b63a | ||
|
|
ac28a11e41 | ||
|
|
11fb45b31b | ||
|
|
22946b075d | ||
|
|
f79e9328d6 | ||
|
|
b3efcfb7a2 | ||
|
|
bd28f2d802 | ||
|
|
20a7016987 | ||
|
|
353ca04b61 | ||
|
|
e66b99c8ff | ||
|
|
2d917c5909 | ||
|
|
1100cdcff5 | ||
|
|
4859ba4c94 | ||
|
|
f89aee1eac | ||
|
|
b8305de14b | ||
|
|
0b91938f7d | ||
|
|
73c14ac749 | ||
|
|
19e85941e6 | ||
|
|
37e751f1e3 | ||
|
|
6ce9498d18 | ||
|
|
66fb4b6aca | ||
|
|
eed6162753 | ||
|
|
51b561815c | ||
|
|
829db19f60 | ||
|
|
4f4af9c4e0 | ||
|
|
b6967a3dd9 | ||
|
|
7322ce5ebd | ||
|
|
c66f8f039b | ||
|
|
f120989a2b | ||
|
|
8cd0cc82f5 | ||
|
|
09bdc314d0 | ||
|
|
6fd072bf79 | ||
|
|
329ef7e01c | ||
|
|
266825eddb | ||
|
|
add905e6b9 | ||
|
|
585392cbcf | ||
|
|
ca17412c70 | ||
|
|
8e63420f06 | ||
|
|
6bdedd8523 | ||
|
|
f87da8523e | ||
|
|
91685c94d9 | ||
|
|
a56f2f9352 | ||
|
|
43ab3ecaaf | ||
|
|
ebc717df8b | ||
|
|
08b8be26fe | ||
|
|
726e201314 | ||
|
|
90d64e6916 | ||
|
|
cbe1d841ea | ||
|
|
b49f9dddf0 | ||
|
|
125fbc5a3b | ||
|
|
1facc09f8d | ||
|
|
a04bfbe184 | ||
|
|
340ba840ac | ||
|
|
9cc108835e | ||
|
|
8d7f055572 | ||
|
|
0e3b1f67cf | ||
|
|
976ea38a7f | ||
|
|
a4e1763b53 | ||
|
|
227e3c2051 | ||
|
|
ae13a56f6d | ||
|
|
9f8381ff71 | ||
|
|
01ae045346 | ||
|
|
4fc889ad19 | ||
|
|
e29d3bd871 | ||
|
|
39165bb199 | ||
|
|
fe78b3ebc9 | ||
|
|
02a6739466 | ||
|
|
59a2e31aa8 | ||
|
|
038b4f3ef6 | ||
|
|
de634952ec | ||
|
|
790b8fc860 | ||
|
|
56a7cacbec | ||
|
|
fe3d17df02 | ||
|
|
e8735163c7 | ||
|
|
6cede0f2e2 | ||
|
|
daba7f93a7 | ||
|
|
39a697aa3c | ||
|
|
0d32f59b40 | ||
|
|
b20579b06a | ||
|
|
acc9531b3f | ||
|
|
a9282b30bb | ||
|
|
1a3f6f2486 | ||
|
|
c2d7a6dbb0 | ||
|
|
553f0c06ee | ||
|
|
7618be4930 | ||
|
|
89339cdd96 | ||
|
|
5983c663bd | ||
|
|
ef174b0ff7 | ||
|
|
48146f8e5b | ||
|
|
6ba67d01af | ||
|
|
dda9a48e5b | ||
|
|
4f95763a28 | ||
|
|
d8ef366ab1 | ||
|
|
f675938412 | ||
|
|
6b92602589 | ||
|
|
b531dd545b | ||
|
|
cc3d61a960 | ||
|
|
c8fa3a7875 | ||
|
|
c09de47d19 | ||
|
|
acd87e6cf1 | ||
|
|
f608ae81e7 | ||
|
|
f0d38897f4 | ||
|
|
a851199f5b | ||
|
|
d8c63c69d8 | ||
|
|
16b383ab6f | ||
|
|
eee4f48745 | ||
|
|
3d2896cf27 | ||
|
|
68667e2e3c | ||
|
|
c392d6fb50 | ||
|
|
9e3786716d | ||
|
|
074f4af10e | ||
|
|
dcb8de9d2c | ||
|
|
b817d5802b | ||
|
|
e4b3933166 | ||
|
|
08ff7f3e9d | ||
|
|
540629a0fc | ||
|
|
9a1bc34042 | ||
|
|
4efba8ccca | ||
|
|
51637a5e96 | ||
|
|
d5a568e55f | ||
|
|
93804d5dbd | ||
|
|
0a8d824124 | ||
|
|
4c28cc0212 | ||
|
|
00dfed7959 | ||
|
|
f7945d6844 | ||
|
|
96b5b0bb1f | ||
|
|
ceb3fe1b2b | ||
|
|
582bfce660 | ||
|
|
1e12d5ba26 | ||
|
|
f01200b467 | ||
|
|
5b7171f485 | ||
|
|
f5c14ec30d | ||
|
|
dea10cac4f | ||
|
|
60a230b2ff | ||
|
|
9d4d7b3ee0 | ||
|
|
057dbe24af | ||
|
|
da8b0dd8cd | ||
|
|
f0d7def9c7 | ||
|
|
fa8db45d63 | ||
|
|
a0924c00d7 | ||
|
|
1773494116 | ||
|
|
3144169057 | ||
|
|
2ebde97fb3 | ||
|
|
37ea4e1f93 | ||
|
|
31b1f27a2e | ||
|
|
f2117a98f2 | ||
|
|
40a81c58e6 | ||
|
|
11cd0142ac | ||
|
|
3c6c342b2b | ||
|
|
df73c5b21a | ||
|
|
c96d4ecfb1 | ||
|
|
d5218c4722 | ||
|
|
4f8a9f2615 | ||
|
|
1c09c90e13 | ||
|
|
dae4011604 | ||
|
|
299a5943a0 | ||
|
|
bc6af1ccd1 | ||
|
|
f7cd6797ec | ||
|
|
1910a3b2d5 | ||
|
|
c45ec634ee | ||
|
|
d86047fe8f | ||
|
|
58c8a481ed | ||
|
|
63b2590b9f | ||
|
|
4a9b95e31b | ||
|
|
ea69bb4ce2 | ||
|
|
b977ef29e3 | ||
|
|
293ffb2bd3 | ||
|
|
b62f60482e | ||
|
|
b29e20632b | ||
|
|
736ee8d694 | ||
|
|
03cd379c0e | ||
|
|
57f7edc693 | ||
|
|
c34a2c38fc | ||
|
|
3afbd0c638 | ||
|
|
3ad3cc2ab7 | ||
|
|
8e1a3bab3e | ||
|
|
34906c9abb | ||
|
|
e5d15f24d5 | ||
|
|
686bc4fd0e | ||
|
|
94e4c2176e | ||
|
|
a6d20120a7 | ||
|
|
aff540ad39 | ||
|
|
315c561a7b | ||
|
|
0f56b3147b | ||
|
|
8e40d2689b | ||
|
|
a236504347 | ||
|
|
e4b435568f | ||
|
|
8b363d39f6 | ||
|
|
1c70c6d042 | ||
|
|
a99b862580 | ||
|
|
2bac200c01 | ||
|
|
013cfdf91d | ||
|
|
40d8839589 | ||
|
|
34647758fc | ||
|
|
8366803c32 | ||
|
|
207373389a | ||
|
|
fb74354d30 | ||
|
|
061ed1b682 | ||
|
|
895ef536ca | ||
|
|
e6a6ba0126 | ||
|
|
d1fdb9eb45 | ||
|
|
0c025987ef | ||
|
|
741b6f67eb | ||
|
|
17eea266f1 | ||
|
|
111ba1626c | ||
|
|
05578c61b9 | ||
|
|
35ed499ae4 | ||
|
|
f0c2523ac4 | ||
|
|
b2cc00833d | ||
|
|
0b6cdc8e1f | ||
|
|
03e9db07c1 | ||
|
|
670e952c29 | ||
|
|
552f3de3ff | ||
|
|
5564156c09 | ||
|
|
eed8acc3f7 | ||
|
|
f3adccd01a | ||
|
|
d18fdfa47f | ||
|
|
dfdbb134d5 | ||
|
|
474cce51eb | ||
|
|
451be8e3b7 | ||
|
|
682427ef1d | ||
|
|
4edaf245d0 | ||
|
|
4e950d04e9 | ||
|
|
f4879fc24b | ||
|
|
7f0572cf4c | ||
|
|
1a1eef0e5c | ||
|
|
c664f8eacb | ||
|
|
a066a2d1ca | ||
|
|
9cc34e324f | ||
|
|
cf6d04b7db | ||
|
|
7fb11507d5 | ||
|
|
39c648094b | ||
|
|
342d960582 | ||
|
|
05edae0606 | ||
|
|
65f0467ad8 | ||
|
|
fae50c0791 | ||
|
|
53c114965d | ||
|
|
65d41b971c | ||
|
|
20770e52a9 | ||
|
|
e80f583e66 | ||
|
|
8a50705eea | ||
|
|
2a629f0987 | ||
|
|
be53c338c8 | ||
|
|
3139369673 | ||
|
|
d413362467 | ||
|
|
b80c54b7f1 | ||
|
|
4d2b061e3b | ||
|
|
64c5cd25e6 | ||
|
|
64759c552e | ||
|
|
12cd73bed9 | ||
|
|
baabff6acf | ||
|
|
2853938df7 | ||
|
|
dd040b4f87 | ||
|
|
ac5164b14b | ||
|
|
03c168d7c9 | ||
|
|
ef5c034dc9 | ||
|
|
0c190c3a15 | ||
|
|
ab8691383b | ||
|
|
238a3d0634 | ||
|
|
81ffa5b663 | ||
|
|
00cce1e672 | ||
|
|
33381c5981 | ||
|
|
ba0dd1da96 | ||
|
|
ffd47a3699 | ||
|
|
acbba74d33 | ||
|
|
2a13a06def | ||
|
|
ecaddd838b | ||
|
|
57da1090d0 | ||
|
|
c6de459436 | ||
|
|
9f7a85c67b | ||
|
|
62296a833c | ||
|
|
05b646daa3 | ||
|
|
14f7b32537 | ||
|
|
160e445677 | ||
|
|
a49671a391 | ||
|
|
133ca14119 | ||
|
|
24190a3e35 | ||
|
|
c4a97a653c | ||
|
|
b01f1ee7dc | ||
|
|
b98cbeebf2 | ||
|
|
351a47ee22 | ||
|
|
75df3389f3 | ||
|
|
4e09559337 | ||
|
|
811bcaff85 | ||
|
|
605b9490f3 | ||
|
|
5cea3e8cbb | ||
|
|
2b4955c817 | ||
|
|
d7b3d45135 | ||
|
|
c1d829ef51 | ||
|
|
868d061856 | ||
|
|
cb350fa58d | ||
|
|
11992828fb | ||
|
|
87bbe14748 | ||
|
|
81bcae907f | ||
|
|
e08ddd787b | ||
|
|
1bda760763 | ||
|
|
b7431d7823 | ||
|
|
d7ab85f5c4 | ||
|
|
2ee09c5911 | ||
|
|
1930ca247a | ||
|
|
6fe5bda028 | ||
|
|
99bda8a35e | ||
|
|
c0062c4d3d | ||
|
|
a0e5f04c30 | ||
|
|
0c89a87d97 | ||
|
|
5ab8ddb4ca | ||
|
|
ad171f2854 | ||
|
|
4bd7f97cc4 | ||
|
|
babd55eae1 | ||
|
|
de1c9125d3 | ||
|
|
73ea608881 | ||
|
|
2087b55b47 | ||
|
|
bd7ef69843 | ||
|
|
8b179a7e01 | ||
|
|
c35eba1704 | ||
|
|
96ce795f8c | ||
|
|
fc7ff1055c | ||
|
|
c489e65796 | ||
|
|
ce1ad3b95a | ||
|
|
154f8d204b | ||
|
|
af461febae | ||
|
|
2e9c146060 | ||
|
|
52b609480c | ||
|
|
468d5078e3 | ||
|
|
3a2a53aef9 | ||
|
|
cfc83a6078 | ||
|
|
645e13aeaa | ||
|
|
0d15e80e28 | ||
|
|
314292a006 | ||
|
|
3cbc17a926 | ||
|
|
18d55f58b9 | ||
|
|
5e658d8c9d | ||
|
|
818d2c8295 | ||
|
|
857b007fb1 | ||
|
|
f71c76c306 | ||
|
|
b8f4d097c4 | ||
|
|
02953665de | ||
|
|
0ec6735440 | ||
|
|
a2be947aba | ||
|
|
0d3c1ef1d9 | ||
|
|
1a6687632d | ||
|
|
1da65dc264 | ||
|
|
9caa0947b6 | ||
|
|
1cd4dde26d | ||
|
|
7a39d2d29b | ||
|
|
f19da92610 | ||
|
|
7f69beb4f0 | ||
|
|
c6a83b78df | ||
|
|
78ebb7ae1f | ||
|
|
096383b432 | ||
|
|
b07429df12 | ||
|
|
dbfbfcceb9 | ||
|
|
ebdd4628db | ||
|
|
dc7a7c4d53 | ||
|
|
5ed86200e5 | ||
|
|
a55450e2e7 | ||
|
|
7713fb8ae8 | ||
|
|
15f1f9f5b6 | ||
|
|
7b223d7d7b | ||
|
|
fa840ae3c3 | ||
|
|
a7c16a701c | ||
|
|
4dd5a313b3 | ||
|
|
f967218a04 | ||
|
|
c76872dfaa | ||
|
|
54750a01e5 | ||
|
|
563df3bad9 | ||
|
|
4a74420e7b | ||
|
|
8279af2e6d | ||
|
|
0df0fb8261 | ||
|
|
8328aab779 | ||
|
|
9ba5a3aba9 | ||
|
|
2ca6912cdc | ||
|
|
f59d134928 | ||
|
|
bef47ee7c7 | ||
|
|
5a8bc33fbd | ||
|
|
b775bfed70 | ||
|
|
d32a4f62d3 | ||
|
|
1ef9be2248 | ||
|
|
2c422d2f38 | ||
|
|
520cf4cb4f | ||
|
|
f01393c41d | ||
|
|
52e428955f | ||
|
|
b8560e5b26 | ||
|
|
de87aaf86a | ||
|
|
b778a43f1d | ||
|
|
694616d5c2 | ||
|
|
f392eecbd9 | ||
|
|
ecc0ced47c | ||
|
|
189b30b545 | ||
|
|
6d6ad20ad3 | ||
|
|
692e99729c | ||
|
|
798d6636ab | ||
|
|
93c71ffc86 | ||
|
|
f72bf6a774 | ||
|
|
311e178d0c | ||
|
|
9b918d475a | ||
|
|
d0f6f5c0d8 | ||
|
|
49b65ddc55 | ||
|
|
a72cfc4098 | ||
|
|
19b4a7a7e0 | ||
|
|
46b3aceafc | ||
|
|
911ce59e53 | ||
|
|
9d587c1d37 | ||
|
|
d82a6f1550 | ||
|
|
1a694baf5a | ||
|
|
965a82144c | ||
|
|
f4f0088814 | ||
|
|
c58bc9864b | ||
|
|
5dd05a4a81 | ||
|
|
dc56ada3a5 | ||
|
|
887b261195 | ||
|
|
1dad6b658a | ||
|
|
22a87500c6 | ||
|
|
f2e722c4bf | ||
|
|
4c2159a70a | ||
|
|
a9b3125a99 | ||
|
|
60dad0c35e | ||
|
|
a3152ab719 | ||
|
|
38d29f2beb | ||
|
|
c89185f352 | ||
|
|
e8870a4766 | ||
|
|
0600964249 | ||
|
|
0ac32f82ba | ||
|
|
d026952397 | ||
|
|
01c764d4fd | ||
|
|
8f16d10f7a | ||
|
|
cbedb3bb01 | ||
|
|
0a4b9dbd5a | ||
|
|
6167c88da4 | ||
|
|
dec6cb937e | ||
|
|
72d1b0f055 | ||
|
|
766c786093 | ||
|
|
4baefeb8f9 | ||
|
|
1839d62160 | ||
|
|
6bdb995fe3 | ||
|
|
000ef99464 | ||
|
|
6a7597d344 | ||
|
|
25ec9e10a3 | ||
|
|
83fbe6949f | ||
|
|
c8a4f3ed98 | ||
|
|
7a421c35fc | ||
|
|
09ea8f33b9 | ||
|
|
daedd76acc | ||
|
|
bf2c96db0a | ||
|
|
e3c4e88d6f | ||
|
|
9059cd942f | ||
|
|
55f979c128 | ||
|
|
cbfcaff8df | ||
|
|
14d7ddf06b | ||
|
|
69a78710cc | ||
|
|
620ada6df3 | ||
|
|
1fe8267550 | ||
|
|
6b27b21cc4 | ||
|
|
6eec8f6cb0 | ||
|
|
71b7177dc0 | ||
|
|
5ff8c5d7a6 | ||
|
|
ed9ea7e679 | ||
|
|
ea1e2f9c04 | ||
|
|
65e30546dd | ||
|
|
0b8cb855fa | ||
|
|
01a6734a56 | ||
|
|
941b308c29 | ||
|
|
052435473e | ||
|
|
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 | ||
|
|
a275c2c630 | ||
|
|
ffb332472d | ||
|
|
b234d7b427 | ||
|
|
01979bba1e | ||
|
|
feba5af0b9 |
No files matched your search
+24
-2
@@ -61,8 +61,15 @@ CESIUM_ION_TOKEN=
|
|||||||
# ChatGPT/Codex OAuth session; the browser still receives only a short-lived
|
# ChatGPT/Codex OAuth session; the browser still receives only a short-lived
|
||||||
# Realtime client secret. Do not prefix with VITE_.
|
# Realtime client secret. Do not prefix with VITE_.
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
# Optional: forbid API-key voice sessions. Select ChatGPT OAuth in Provider
|
# Typed chat uses Chat Completions, independently of the voice model. It can
|
||||||
# Settings; missing or expired Codex sign-in then fails closed.
|
# only bill OPENAI_API_KEY, so GEV_PREFER_CODEX_OAUTH below turns it off.
|
||||||
|
OPENAI_CHAT_MODEL=gpt-4.1-mini
|
||||||
|
# Typed chat requests (/api/openai/chat) per IP per minute, counted apart from
|
||||||
|
# voice. Unset, it takes GEV_RATELIMIT_OPENAI_PER_MIN's cap; 0 disables it.
|
||||||
|
# Not a billing cap.
|
||||||
|
# GEV_RATELIMIT_OPENAI_CHAT_PER_MIN=30
|
||||||
|
# Optional: forbid API-key voice sessions and OpenAI typed chat. Select ChatGPT
|
||||||
|
# OAuth in Provider Settings; missing or expired Codex sign-in then fails closed.
|
||||||
# GEV_PREFER_CODEX_OAUTH=true
|
# GEV_PREFER_CODEX_OAUTH=true
|
||||||
OPENAI_REALTIME_MODEL=gpt-realtime-2
|
OPENAI_REALTIME_MODEL=gpt-realtime-2
|
||||||
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
|
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
|
||||||
@@ -256,6 +263,9 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
||||||
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
||||||
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
|
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
|
||||||
|
# CCTV_HOUSTON_ENABLED= # off by default; set 1 to load Houston TranStar cameras only if you hold TxDOT's written permission (TranStar's Content Usage terms prohibit copying, distributing or framing its images without it; see DATA_SOURCES.md)
|
||||||
|
# CCTV_HOUSTON_MAX_SOURCES=250 # cap cameras loaded from the Houston TranStar catalog (nearest metro cores first)
|
||||||
|
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
|
||||||
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
||||||
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||||
@@ -263,6 +273,7 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||||
|
|
||||||
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
|
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
|
||||||
|
# Also enables voice place search (osm_query, "hospitals in Kathmandu").
|
||||||
OVERPASS_UPSTREAMS=
|
OVERPASS_UPSTREAMS=
|
||||||
|
|
||||||
# Optional: place-lookup endpoints. Lookups try bundled data first, then the
|
# Optional: place-lookup endpoints. Lookups try bundled data first, then the
|
||||||
@@ -295,3 +306,14 @@ OVERPASS_UPSTREAMS=
|
|||||||
# frame-ancestors sources such as "https://example.com", or * for any page.
|
# frame-ancestors sources such as "https://example.com", or * for any page.
|
||||||
# Unset, no page may frame it. Other pages of the app can never be framed.
|
# Unset, no page may frame it. Other pages of the app can never be framed.
|
||||||
# GEV_EMBED_FRAME_ANCESTORS=
|
# GEV_EMBED_FRAME_ANCESTORS=
|
||||||
|
|
||||||
|
# Gemini typed chat uses generateContent; voice continues to use Gemini Live.
|
||||||
|
GEMINI_CHAT_MODEL=gemini-3.5-flash-lite
|
||||||
|
# Typed chat requests (/api/gemini/chat) per IP per minute. Unset, it takes
|
||||||
|
# GEV_RATELIMIT_GEMINI_PER_MIN's cap (default 30); 0 disables it.
|
||||||
|
# GEV_RATELIMIT_GEMINI_CHAT_PER_MIN=30
|
||||||
|
|
||||||
|
# Optional keyless Radio Browser coverage: up to 8 ISO alpha-2 countries.
|
||||||
|
# Each reserves up to 40 stations within the existing 750-station directory.
|
||||||
|
# Restart after changing. Leave blank for the existing global selection.
|
||||||
|
# RADIO_PRIORITY_COUNTRIES=IN,PL
|
||||||
@@ -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.
|
||||||
|
|||||||
+21
-7
@@ -33,6 +33,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) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
| **CelesTrak** | Satellite TLEs (SGP4) | 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/) and [imagery licence](https://cloudless.eox.at/) | Sentinel-2 cloudless 2020 by EOX IT Services GmbH (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" |
|
||||||
@@ -40,6 +41,7 @@ How to read this:
|
|||||||
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
||||||
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
|
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
|
||||||
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
||||||
|
| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" |
|
||||||
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
|
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
|
||||||
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
|
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
|
||||||
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
|
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
|
||||||
@@ -69,6 +71,7 @@ How to read this:
|
|||||||
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
||||||
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
||||||
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
||||||
|
| **Houston TranStar** (`traffic.houstontranstar.org`, frames `www.houstontranstar.org`) | CCTV camera catalog + frames, Greater Houston | No licence. The [TranStar disclaimer](https://traffic.houstontranstar.org/disclaimer.aspx) (Content Usage) prohibits copying or distribution, and external framing or linking to specific images, without TxDOT's prior written permission. **Off by default**; enable only if you hold TxDOT's written permission | "Houston TranStar" (courtesy) |
|
||||||
| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) |
|
| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) |
|
||||||
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||||
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
|
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
|
||||||
@@ -98,6 +101,8 @@ How to read this:
|
|||||||
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
||||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||||
|
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||||
|
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
|
||||||
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
||||||
|
|
||||||
### Local receiver input (not fetched from a service)
|
### Local receiver input (not fetched from a service)
|
||||||
@@ -118,6 +123,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||||
|
|
||||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||||
|
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||||
<a id="gnss-integrity"></a>
|
<a id="gnss-integrity"></a>
|
||||||
@@ -177,17 +183,22 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
||||||
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
|
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
|
||||||
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
||||||
|
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
|
||||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
|
||||||
|
|
||||||
|
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||||
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
||||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||||
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
|
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
|
||||||
|
- **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover.
|
||||||
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
|
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
|
||||||
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
|
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
|
||||||
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
|
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
|
||||||
|
- **Voice areas and place search.** Voice areas (`resolve_area`) use the same outline sources as voice outlines. Voice place search (`osm_query`, `/api/osm/features`) runs only on an operator-configured Overpass: a fixed list of kinds, a bounded box and result count, one query at a time, answers cached for a day. It never uses Nominatim.
|
||||||
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
|
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
|
||||||
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
|
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
|
||||||
|
|
||||||
@@ -214,7 +225,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
|||||||
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
|
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
|
||||||
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
|
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
|
||||||
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
|
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
|
||||||
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
|
| **CCTV Camera Database** model-enrichment extract v2.26.0 + v2.29.0 (20 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
|
||||||
|
|
||||||
### ⚠️ TeleGeography is bundled but NonCommercial
|
### ⚠️ TeleGeography is bundled but NonCommercial
|
||||||
|
|
||||||
@@ -242,13 +253,16 @@ fixtures retain their provenance in `src/data/fixtures/README.md`.
|
|||||||
|
|
||||||
### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`)
|
### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`)
|
||||||
|
|
||||||
The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the
|
The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bosch MIC
|
||||||
|
records from v2.29.0) of the
|
||||||
[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of
|
[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of
|
||||||
camera models transcribed from manufacturer datasheets, dedicated to the public domain under
|
camera models transcribed from manufacturer datasheets, dedicated to the public domain under
|
||||||
**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King
|
**CC0-1.0**. Only records for hardware named by the integrated public catalogs (King County,
|
||||||
County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is
|
Sarasota County and Sioux Falls) or by the City of Amsterdam register proposed in
|
||||||
fetched at runtime. The records describe camera products (optics, resolution, night vision)
|
[#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251) are bundled, compiled into the
|
||||||
only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
|
module. Nothing is fetched at runtime. The MIC records keep their specification fields; their
|
||||||
|
titles are shortened to model codes and colour aliases are added. The records describe camera
|
||||||
|
products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
|
||||||
credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders.
|
credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders.
|
||||||
|
|
||||||
### Bundled OpenStreetMap data
|
### Bundled OpenStreetMap data
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
|||||||
|
|
||||||
## ⚡ Quick Start
|
## ⚡ Quick Start
|
||||||
|
|
||||||
**Start without an account or API keys.** Every path opens the same app with
|
**Local setup needs no account or API keys.** The app opens with
|
||||||
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
||||||
unreachable. Flights, military traffic, satellites, earthquakes, public
|
unreachable. Flights, military traffic, satellites, earthquakes, public
|
||||||
cameras, radio, and launches are available without keys.
|
cameras, radio, and launches are available without keys.
|
||||||
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
|
|||||||
|
|
||||||
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
|
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
|
||||||
|
|
||||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
|
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -236,7 +236,11 @@ the container.
|
|||||||
|
|
||||||
### Path 4 — GitHub Codespaces (cloud, no local install)
|
### Path 4 — GitHub Codespaces (cloud, no local install)
|
||||||
|
|
||||||
Runs the same dev server in a cloud workspace, so Node and the dependencies
|
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
|
||||||
|
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
|
||||||
|
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
|
||||||
|
|
||||||
|
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
|
||||||
never touch your machine. The globe still renders in your own browser, so it
|
never touch your machine. The globe still renders in your own browser, so it
|
||||||
needs the same graphics there as any other path. GitHub Free includes 120
|
needs the same graphics there as any other path. GitHub Free includes 120
|
||||||
core-hours a month with no card on file — about 60 hours on the default 2-core
|
core-hours a month with no card on file — about 60 hours on the default 2-core
|
||||||
@@ -265,14 +269,13 @@ ignored on purpose, so pass that exact name.
|
|||||||
|
|
||||||
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
||||||
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
||||||
Settings does not open on the forwarded URL, so put keys in the workspace
|
Settings is unavailable on the forwarded URL. Never commit your workspace
|
||||||
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
|
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
|
||||||
threat model.
|
|
||||||
|
|
||||||
### Then power it up — in the app, not in a file
|
### Then power it up — in the app, not in a file
|
||||||
|
|
||||||
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
For a local setup, keys are upgrades, not prerequisites. When you want one,
|
||||||
chip in the bottom-right corner: Provider Settings lists every supported key,
|
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||||
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
|
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
|
||||||
restarts itself with the new capability on. Once everything is configured the
|
restarts itself with the new capability on. Once everything is configured the
|
||||||
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
|
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
|
||||||
@@ -337,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` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -359,9 +364,11 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
|
|||||||
|
|
||||||
## 🎙️ Talk to It
|
## 🎙️ Talk to It
|
||||||
|
|
||||||
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
The standalone app offers **OpenAI** and **Gemini** under **AI Provider** in Settings, one choice shared by voice and chat. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
||||||
|
|
||||||
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
|
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The AI Provider setting does not change the OpenAI-backed AI HUD summary.
|
||||||
|
|
||||||
|
Next to the mic, **CHAT** opens a typed chat with silent replies. It uses the selected provider and the same map actions, and never asks for the microphone. OpenAI chat needs an API key (`OPENAI_CHAT_MODEL`), so it is off while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth; Gemini chat uses a separate text model (`GEMINI_CHAT_MODEL`). Chat and voice panels share the left panels' glass surface and sit above the dock or dock to the right.
|
||||||
|
|
||||||
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||||
|
|
||||||
@@ -421,7 +428,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
|
|||||||
|
|
||||||
## 🛰️ What's on the Globe
|
## 🛰️ What's on the Globe
|
||||||
|
|
||||||
Twenty layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||||
|
|
||||||
| Layer | What you get | Source | Auth |
|
| Layer | What you get | Source | Auth |
|
||||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
@@ -435,23 +442,25 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
|||||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||||
|
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
|
||||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
||||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||||
|
| 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 |
|
||||||
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
||||||
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
||||||
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
|
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
|
||||||
|
|
||||||
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
|
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
|
||||||
|
|
||||||
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import.
|
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions and OSM Places sit in the separate **Utilities** panel with Draw and Import.
|
||||||
|
|
||||||
**The basemap ladder — what each tier buys you:**
|
**The basemap ladder — what each tier buys you:**
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -74,17 +74,19 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
|||||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||||
|
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||||
- **Response-size caps and timeouts** on proxied responses.
|
- **Response-size caps and timeouts** on proxied responses.
|
||||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||||
|
|
||||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||||
|
- **Typed chat spends the provider keys.** `/api/openai/chat` and `/api/gemini/chat` send the conversation to OpenAI Chat Completions or Gemini `generateContent` with the server's `OPENAI_API_KEY` or `GEMINI_API_KEY`. They require a same-site `Origin` (the Gemini request check), cap request bodies at 512 KB, refuse upstream redirects, cut the upstream call off after 60 s and return only sanitized errors. OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth it refuses with `503` (`CODEX_OAUTH_REQUIRED`) rather than spend the key. After the provider refuses the key or account (a `401`, a `403`, Gemini's `400` `API_KEY_INVALID` or OpenAI's `429` `insufficient_quota`), a route stops sending that key upstream for a minute, or until the key changes.
|
||||||
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
|
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
|
||||||
|
|
||||||
## Network exposure — the operator threat model
|
## Network exposure — the operator threat model
|
||||||
@@ -93,7 +95,7 @@ The dev server is a **key broker**: every server-side key above is spendable by
|
|||||||
|
|
||||||
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
|
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
|
||||||
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
||||||
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Typed chat keeps its own per-IP window, set by `GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` and `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`; left unset, each takes its provider's resolved cap (`GEV_RATELIMIT_OPENAI_PER_MIN`, or `GEV_RATELIMIT_GEMINI_PER_MIN`, default `30`), so lowering a provider throttle lowers chat too, while an explicit chat value, including `0`, wins. Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
||||||
- **Docker trusts one address, and only because the port is on loopback.**
|
- **Docker trusts one address, and only because the port is on loopback.**
|
||||||
Docker delivers the host's own browser to the container from the container
|
Docker delivers the host's own browser to the container from the container
|
||||||
network's gateway, never from `127.0.0.1`, so a loopback-only Provider
|
network's gateway, never from `127.0.0.1`, so a loopback-only Provider
|
||||||
|
|||||||
+21
-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,22 @@ 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`) 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('|')})[\\\\/]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ export function panelBuildConfig(config) {
|
|||||||
cssCodeSplit: false,
|
cssCodeSplit: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
...config.build?.rollupOptions,
|
...config.build?.rollupOptions,
|
||||||
|
// The panel inlines every dynamic import into its one script, the
|
||||||
|
// experimental Navara mode included, so the guard that keeps Navara
|
||||||
|
// out of the app's initial chunks does not apply here.
|
||||||
|
plugins: (config.build?.rollupOptions?.plugins ?? []).filter(
|
||||||
|
(plugin) => plugin?.name !== 'gev-initial-chunk-guard',
|
||||||
|
),
|
||||||
output: { inlineDynamicImports: true },
|
output: { inlineDynamicImports: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+36
-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,
|
||||||
@@ -206,6 +211,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 +275,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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- Add a Prohibited & Restricted Airspace layer for FAA prohibited (P) and
|
||||||
|
restricted (R) special-use airspace. The keyless `/api/drone-airspace` proxy
|
||||||
|
pages the public SUA FeatureServer, keeps a one-hour snapshot, and serves
|
||||||
|
stale data when a refresh fails. Zones draw as ground polygons (red
|
||||||
|
prohibited, orange restricted); selection shows name, altitude range, times
|
||||||
|
of use and agency, and the row legend states the layer is informational only
|
||||||
|
and does not include the UAS Facility Map or UAS flight restrictions. Off by
|
||||||
|
default; share-link token `8` (mvanhorn, #1086).
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
- Add Houston traffic cameras from Houston TranStar as a CCTV pack. The camera
|
||||||
|
list is one keyless JSON request to
|
||||||
|
`traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js`; frames are
|
||||||
|
stills synthesized from each numeric camera id on `www.houstontranstar.org`,
|
||||||
|
so a catalog field cannot steer the proxy off-host. Cameras without a
|
||||||
|
snapshot id, and cameras outside Greater Houston, are dropped. TranStar's
|
||||||
|
`dir` field (the side of the freeway the camera sits on) seeds a
|
||||||
|
low-confidence heading along the nearest lanes' travel direction. TranStar's
|
||||||
|
Content Usage terms (traffic.houstontranstar.org/disclaimer.aspx) prohibit
|
||||||
|
copying, distribution and external framing of its images without TxDOT's
|
||||||
|
written permission, so the pack is off by default; operators should set
|
||||||
|
`CCTV_HOUSTON_ENABLED=1` only if they hold that permission. Frames are
|
||||||
|
relayed on demand with `no-store` and not archived. Once enabled, it loads
|
||||||
|
the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and
|
||||||
|
Galveston; `CCTV_HOUSTON_MAX_SOURCES` changes that cap.
|
||||||
|
Snapshots update about every 3 minutes. The catalog-wide ceiling is
|
||||||
|
unchanged; extra cameras share it round-robin with the other packs
|
||||||
|
(mvanhorn, #1125).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
|
||||||
|
overlay layers under Infrastructure. They drape on the globe stacks and on
|
||||||
|
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Add optional keyless Radio priority-country coverage with up to 40 stations
|
||||||
|
per configured country inside the existing 750-station cap; the UK alias uses
|
||||||
|
the canonical GB code. Country query
|
||||||
|
failures do not block a healthy global catalog. Bound mirror attempts to three
|
||||||
|
and retry failed or partial refreshes after 60 seconds on all installations,
|
||||||
|
including those without priority countries configured (m3ayush, #1206).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
|
||||||
|
now uses the keyless same-site gate, admits forwarding headers and still
|
||||||
|
refuses cross-site requests. Direct loopback requests keep their own
|
||||||
|
admission window; proxied requests, including those a local reverse proxy
|
||||||
|
relays over loopback, are admitted as remote traffic keyed by their socket,
|
||||||
|
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
|
||||||
|
exhaust the owner's admission. The upstream budget stays shared, so an owner
|
||||||
|
cache miss can still be rate-limited (asturksever, #1308).
|
||||||
|
- Give the tracking arrival check a wall-clock budget per observed frame and
|
||||||
|
retry only an inconclusive timeout: a timeout keeps every angle it
|
||||||
|
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
|
||||||
|
frames no longer fail the check and a real violation is never retried into a
|
||||||
|
pass (asturksever, #1308).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Vendor the Bosch MIC PTZ datasheet records that the City of Amsterdam camera
|
||||||
|
register names (MIC-7522, MIC-7502, MIC-7130), with the register's `x` colour
|
||||||
|
wildcard as an alias, from the CCTV Camera Database v2.29.0 (ch-bas, #1319).
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
- Color every Active Fires detection by fire radiative power on a six-stop
|
||||||
|
ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at
|
||||||
|
0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a
|
||||||
|
log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ /
|
||||||
|
High confidence switch and an "N of M shown" counter to the layer row. Far
|
||||||
|
zoom shows a Snap Map–style heatmap in the same colors instead of squares,
|
||||||
|
one distinct spot per fire, empty where nothing burns, and cross-fades with
|
||||||
|
the markers around 4,500 km with no gap, keeping weak fires as markers. The filter applies once before
|
||||||
|
rendering, so markers, cards, the heatmap, context and the top-fires query
|
||||||
|
agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires`
|
||||||
|
takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All
|
||||||
|
(gekh, #1426).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Military aircraft that leave the adsb.lol feed fade and are removed again.
|
||||||
|
The feed's routine position-less rows no longer mark every snapshot partial,
|
||||||
|
which kept vanished aircraft at full opacity for up to five minutes; those
|
||||||
|
rows keep only their own aircraft, and a likely-landed one still leaves on
|
||||||
|
the landed limit (Aditya, @Rohilalala, #1435).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- A terrain request the proxy can only partly answer (a 429 pause, or a point
|
||||||
|
the upstream returns without a height) now keeps the real heights it has
|
||||||
|
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Ground-sample Data Centers and Dams markers only when the camera sees the
|
||||||
|
ground under them. Unseen ground read through the Earth and retried every
|
||||||
|
2 s; over Ashburn, Virginia on the Esri map, slow frames reached 2 s
|
||||||
|
(quanticsoul4772, #1464).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
|
||||||
|
before Play is pressed; while a stream plays, it follows the new station.
|
||||||
|
Play reads Pause while a stream connects, and a tuner release onto a station
|
||||||
|
the directory removed or changed no longer leaves a Resume for another
|
||||||
|
station (gekh, #1474).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Write the AI HUD summary in the interface language, led by the HUD's own
|
||||||
|
translated feed-state warning. A partial layer no longer reads as off or
|
||||||
|
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Street Traffic gives the two TomTom `one_side` lines of one two-way street
|
||||||
|
a two-way direction's half share of simulated vehicles each, so the street
|
||||||
|
is no longer drawn twice as dense as the same street from OpenStreetMap
|
||||||
|
(quanticsoul4772, #1495).
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
- Add a typed AI chat beside the voice mic in the command dock. Replies are
|
||||||
|
silent and never use the microphone; messages run the same map actions as
|
||||||
|
voice through OpenAI Chat Completions (`OPENAI_CHAT_MODEL`) or Gemini
|
||||||
|
`generateContent` (`GEMINI_CHAT_MODEL`). Chat is throttled per IP by
|
||||||
|
`GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` or `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`,
|
||||||
|
which default to that provider's voice throttle, and `GEV_PREFER_CODEX_OAUTH`
|
||||||
|
turns OpenAI chat off. The chat and voice panels minimize to a bar or dock
|
||||||
|
beside the right rail, and step aside for the Location and Visual Presets
|
||||||
|
trays (Manjunath, @manjunath22466, with Darren Dignam, @darrendignam, #1507).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Draw the Global Context 250 km range ring as a 4 px line with a dark halo,
|
||||||
|
so it stays visible over desert, snow, ocean and forest and under the FLIR,
|
||||||
|
NVG and CRT styles, where the old 1 px outline disappeared (manjunath22466,
|
||||||
|
#1518).
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- The Global Context range ring pulses like a sonar: three dim rings with a
|
||||||
|
soft inward glow grow from the subject to 250 km, and the ring flashes as
|
||||||
|
each one arrives. Flights, military aircraft and AIS vessels a pulse crosses
|
||||||
|
get a brief echo around their brackets. The pulses run for about 9 s after
|
||||||
|
a selection or camera move, dimming the ring between flashes, then rest
|
||||||
|
with the ring steady on screen, so a parked view stops repainting. Under
|
||||||
|
reduced motion there are no pulses and the ring stays on screen
|
||||||
|
(manjunath22466, #1519).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Keep the globe on screen when zooming out from a tilted view. User zoom is
|
||||||
|
capped at 40,000 km, and above 300 km outward zoom pitches the view down so
|
||||||
|
Earth stays framed. Correction finishes when zoom stops, respects tilt
|
||||||
|
gestures and stricter host limits, and yields to other camera actions
|
||||||
|
(asturksever, #1522).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Keep retained Active Fires heat imagery hidden when an empty filtered layer is
|
||||||
|
re-enabled while a new snapshot is pending (radianx, #1535).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Finish the typed AI chat translations. The "Reset to globe view" and "Show
|
||||||
|
earthquakes" quick commands are now translated, Traditional Chinese follows
|
||||||
|
Simplified Chinese again, Arabic docks the panel to the side it actually
|
||||||
|
lands on, and the new strings use each language's glossary terms
|
||||||
|
(German, @gekh, #1545).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Add Navara, an experimental three.js/WASM globe, as a second engine next to
|
||||||
|
Cesium, switchable live under Display → Globe Engine or with
|
||||||
|
`?engine=navara`. 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).
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
- Voice can reuse what it outlines. `resolve_area` turns a named place into a
|
||||||
|
reusable area, keeping every part and hole, from the same sources as voice
|
||||||
|
outlines; a name several countries or states share ("Georgia", "Punjab") is
|
||||||
|
asked about unless the view is inside one of them. "Draw an area around
|
||||||
|
<landmark>" outlines the mapped area enclosing it, or a 250 m buffer marked
|
||||||
|
approximate. `analyst_query` counts inside an area, the area you drew or an
|
||||||
|
outline on the map, and `find_imagery` shows the best recent acquisition for
|
||||||
|
an area, the view or the spot under the pointer (manjunath22466, #846).
|
||||||
|
- `osm_query` ("hospitals in Kathmandu") lists places of one kind into a
|
||||||
|
session-only OSM Places layer. It is offered only when `OVERPASS_UPSTREAMS`
|
||||||
|
is set, and `/api/osm/features` refuses before fetching otherwise; public
|
||||||
|
Nominatim is never used for it (manjunath22466, #846).
|
||||||
|
- Analyst counts that combine flights, military aircraft and local ADS-B are
|
||||||
|
named "aircraft". Follow-ups keep the displayed scope and the loaded-data
|
||||||
|
caveat until a new scope replaces them (manjunath22466, #846).
|
||||||
+16
-4
@@ -32,6 +32,8 @@ export inventory. Use declared exports rather than reaching into internal files.
|
|||||||
| `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 Cesium resources with explicit controller/renderer 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,15 +55,25 @@ 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; a module entry ending in `/` (such as `src/geo/`) owns every
|
||||||
|
file under that directory. Unused imports still count. Node exports have only a `node`
|
||||||
condition; browser groups cannot use build-only dependency exceptions.
|
condition; browser groups cannot use build-only dependency exceptions.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
+391
-59
@@ -44,6 +44,27 @@ pinching traverses globe and street scales with fewer gestures. Cesium retains
|
|||||||
its distance-aware zoom, collision handling and minimum/maximum distance limits.
|
its distance-aware zoom, collision handling and minimum/maximum distance limits.
|
||||||
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
|
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
|
||||||
|
|
||||||
|
User zoom is capped at 40,000 km (or a stricter host limit). Explicit camera
|
||||||
|
flight, share-link and story destinations can go farther; from there, user zoom
|
||||||
|
can't climb any higher and zooms in gradually from the current height, with no
|
||||||
|
jump to the cap. Cesium can still use the limit when it computes an automatic
|
||||||
|
bounding-sphere framing distance.
|
||||||
|
Above 300 km, outward movement from the controller's configured zoom bindings
|
||||||
|
(wheel, pinch distance and unmodified right-drag by default) pitches the camera
|
||||||
|
down towards 8° below the Earth's limb. Large steps reach the limb immediately;
|
||||||
|
the remaining margin eases into place, including after zooming stops or reaches
|
||||||
|
the ceiling. Continuous outward inertia remains guarded without a time cutoff.
|
||||||
|
Two-finger tilts, modifier-specific tilts and other camera gestures cancel the
|
||||||
|
correction. A two-finger gesture counts as a pinch zoom only while the finger
|
||||||
|
spacing changes more than the fingers move up or down, so spacing drift during
|
||||||
|
a tilt is ignored. Explicit navigation handoffs, including toolbar tilt,
|
||||||
|
cancel it before animation begins. Zooming in, disabled zoom/input, non-3D modes, tracked
|
||||||
|
transforms and flights also cancel it; external pose changes cancel stationary
|
||||||
|
settling. Oblique views below 300 km are left alone.
|
||||||
|
|
||||||
|
`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises
|
||||||
|
the real browser controller at desktop and phone widths with keyless fixtures.
|
||||||
|
|
||||||
Analyst region queries resolve unique Natural Earth names offline without
|
Analyst region queries resolve unique Natural Earth names offline without
|
||||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||||
Duplicate names require a geocoded containment anchor; when several entries for
|
Duplicate names require a geocoded containment anchor; when several entries for
|
||||||
@@ -77,6 +98,90 @@ 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 Display → 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.
|
||||||
|
|
||||||
|
- **The engine contract** is `src/globe/contract.js`. `src/globe/cesium/*` and
|
||||||
|
`src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js` stands
|
||||||
|
in for tests, and `src/globe/testing/contractSuite.mjs` runs the same checks
|
||||||
|
on every engine. `movers` and `lines` are optional capabilities: only
|
||||||
|
Navara has them, and the suite skips their checks elsewhere.
|
||||||
|
- **The session owns the state.** Catalog, layer models, sources, selection,
|
||||||
|
camera mode and URL state outlive a switch. A layer that is a session model
|
||||||
|
gets `attachRenderer(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 is warm.
|
||||||
|
- **Renderers per engine.** Each layer keeps its model and display policy in
|
||||||
|
engine-neutral files and draws through `render/cesium*.js` or
|
||||||
|
`render/navara*.js`. Cesium renderers are listed in
|
||||||
|
`src/app/layers/renderers.js` (`CESIUM_LAYER_RENDERERS`), Navara ones are
|
||||||
|
loaded by `registerNavaraLayerRenderers` in `src/app/navaraMode.js`. Navara
|
||||||
|
ground effects (heat, pulses, trails, pads, drapes) are declared with
|
||||||
|
`defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`);
|
||||||
|
their insert order is pinned by `navaraGroundOrder.test.mjs`.
|
||||||
|
- **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 (traffic, Street Level, the Bhote Koshi
|
||||||
|
event and locator, geo import; `src/app/loadCesium.js`). 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.** Cesium is imported only from `render/cesium*.js` and
|
||||||
|
`src/globe/cesium/*`; three, postprocessing and Navara only from `navara*`
|
||||||
|
modules, which load through `import()`. `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. Point
|
||||||
|
`PUPPETEER_EXECUTABLE_PATH` at an installed Chrome if Puppeteer's pinned
|
||||||
|
build is missing. With an OSM base map Navara lights the globe by the real
|
||||||
|
sun, so scenarios over Austin or San Francisco see a dark frame at local
|
||||||
|
night and fail their pixel checks; run them in local daylight.
|
||||||
|
- **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.
|
||||||
|
- **Navara 0.2.1 workaround.** The first `layer.update({ show: false })` after
|
||||||
|
a vector layer is added is sometimes dropped, so a zone turned off right
|
||||||
|
after a switch stayed drawn. Polygon displays hide by deleting their layers
|
||||||
|
and rebuild them when shown (`src/layers/shared/render/navaraPolygons.js`).
|
||||||
|
|
||||||
|
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||||
|
|
||||||
|
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||||
|
share-link token `8`), off by default. `/api/drone-airspace` pages the public
|
||||||
|
FAA Special Use Airspace FeatureServer for `TYPE_CODE` P (prohibited) and R
|
||||||
|
(restricted), simplifies geometry, caches for an hour, and serves the last
|
||||||
|
good snapshot as stale when a refresh fails. The globe draws ground-clamped
|
||||||
|
outlines with a translucent fill: red for prohibited, orange for restricted.
|
||||||
|
Selecting a zone shows name, an altitude range built from the
|
||||||
|
`LOWER_*`/`UPPER_*` value, unit and reference fields (for example `SFC to
|
||||||
|
5,000 ft MSL` or `20,000 ft MSL to Unlimited`), times of use and controlling
|
||||||
|
agency,
|
||||||
|
plus a notice that the layer is informational only and not a substitute for
|
||||||
|
official UAS flight-planning tools. Pick ownership is unregistered so sibling
|
||||||
|
layer cards still work over a zone. Voice is off for this layer for now
|
||||||
|
(`VOICE_OFF_LAYERS`: the Realtime prompt has no room), so neither voice nor the
|
||||||
|
analyst can toggle or query it. The layer covers FAA prohibited and restricted
|
||||||
|
special-use airspace only, not the UAS Facility Map / LAANC grids, National
|
||||||
|
Security UAS Flight Restrictions or TFRs; the label and legend say so.
|
||||||
|
See the live-layers table below.
|
||||||
|
|
||||||
Vessel sources can report healthy empty coverage using a current, complete
|
Vessel sources can report healthy empty coverage using a current, complete
|
||||||
snapshot with zero raw rows, a healthy transport and a positive last-message
|
snapshot with zero raw rows, a healthy transport and a positive last-message
|
||||||
time. This clears obsolete contacts and settles startup without manufacturing
|
time. This clears obsolete contacts and settles startup without manufacturing
|
||||||
@@ -685,6 +790,52 @@ announces the reason.
|
|||||||
|
|
||||||
Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority.
|
Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority.
|
||||||
|
|
||||||
|
Typed chat sits beside voice in the dock. Chat replies are silent and never
|
||||||
|
request microphone access. The first message connects with the selected
|
||||||
|
provider and runs the same map actions as voice through each provider's text
|
||||||
|
API: OpenAI uses `OPENAI_CHAT_MODEL` (default `gpt-4.1-mini`) and needs an API
|
||||||
|
key; Gemini sends a text-only `generateContent` request with
|
||||||
|
`GEMINI_CHAT_MODEL` (default `gemini-3.5-flash-lite`) and never starts Live.
|
||||||
|
Only one mode runs at a time: the microphone closes chat, and opening chat
|
||||||
|
stops voice. Enter sends, Shift+Enter adds a line, and Up or Down at the start
|
||||||
|
of the field recall recent messages and restore the draft. Attachments are up
|
||||||
|
to four map-layer references or UTF-8 TXT, Markdown, CSV, JSON and GeoJSON
|
||||||
|
files, 10 KB in total, sent only with the message; a layer reference names
|
||||||
|
the layer without exporting its records. The conversation and recent commands
|
||||||
|
stay in memory for the page.
|
||||||
|
|
||||||
|
The chat routes answer a missing key with `503` and code `API_KEY_MISSING`.
|
||||||
|
OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH`
|
||||||
|
pins OpenAI to ChatGPT OAuth it answers `503` with code `CODEX_OAUTH_REQUIRED`
|
||||||
|
instead; Gemini chat is unaffected. An upstream `429` or `503` keeps its status
|
||||||
|
and passes on a `Retry-After` given in seconds, capped at an hour; the chat
|
||||||
|
names the wait, and a throttled reply carries code `RATE_LIMITED`. A refused
|
||||||
|
key or account stops reaching the provider for a minute, unless the configured
|
||||||
|
key changes: `API_KEY_REJECTED` for a `401` or Gemini's `400` with reason
|
||||||
|
`API_KEY_INVALID`, `ACCESS_DENIED` for a `403`, and `QUOTA_EXCEEDED` for
|
||||||
|
OpenAI's `429` `insufficient_quota`. Each refusal answers `502` with its code,
|
||||||
|
and the chat shows a matching message.
|
||||||
|
|
||||||
|
Chat and the voice panel sit above the dock or dock into the right rail's lane,
|
||||||
|
which hides the rail until the panel minimizes or closes, or a rail panel opens.
|
||||||
|
Minimized panels always sit as a bar centred above the dock; restoring returns a
|
||||||
|
docked panel to the lane. A centred panel steps down to its bar when the pointer
|
||||||
|
reaches the Location or Visual Presets button and rests above an open tray,
|
||||||
|
without moving keyboard focus. Expanded over an open tray, it docks into the
|
||||||
|
lane until the trays close; a panel that stepped down expands in the centre
|
||||||
|
again once the trays close and the pointer leaves the wings. Panel height animates when one panel replaces the
|
||||||
|
other or the voice view changes. Minimized voice shows the last sentence from
|
||||||
|
each side, and the empty Voice Preview suggests things to say until voice
|
||||||
|
starts. Settings opened from chat keep chat's frame and show the shared AI
|
||||||
|
provider, the quick-commands option and then the voice preferences (Voice
|
||||||
|
Preview, inactivity timeout and model tier), so they stay in reach while
|
||||||
|
Preview is off, with Back to chat; voice Settings show Back to voice. Escape steps back from either Settings view, closes Voice
|
||||||
|
Preview, and does nothing in chat. Closing chat ends a typed session, and the
|
||||||
|
voice panel's close turns voice off. Asking the assistant from chat to open
|
||||||
|
Voice Settings or Voice Preview puts chat away, minimized or not, and shows the
|
||||||
|
voice panel; the typed conversation and its running reply carry on, and the
|
||||||
|
chat button brings them back.
|
||||||
|
|
||||||
Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even
|
Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even
|
||||||
after the camera moves. An explicit new scope replaces that basis. Provider
|
after the camera moves. An explicit new scope replaces that basis. Provider
|
||||||
instructions require an analyst query for requested lists and use the requested
|
instructions require an analyst query for requested lists and use the requested
|
||||||
@@ -697,8 +848,9 @@ ordinary tool-card limits are unchanged.
|
|||||||
Voice speech: `src/voice/speech.js` adds `{ say, display, referents?, schedule? }`
|
Voice speech: `src/voice/speech.js` adds `{ say, display, referents?, schedule? }`
|
||||||
to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
||||||
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||||
share one deterministic count/scope headline; lower bounds use "At least" and
|
share one bounded, lossless count/scope headline; combined flight, military and
|
||||||
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
local ADS-B answers use the shared noun "aircraft"; lower bounds use "At least"
|
||||||
|
and partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||||
FIRMS and ALPR entity labels remain English in shared context and tool data;
|
FIRMS and ALPR entity labels remain English in shared context and tool data;
|
||||||
voice-card titles and pointer presentation translate those known labels at display
|
voice-card titles and pointer presentation translate those known labels at display
|
||||||
time, while arbitrary names and callsigns remain unchanged.
|
time, while arbitrary names and callsigns remain unchanged.
|
||||||
@@ -826,9 +978,11 @@ Source factories have dedicated `layers/<family>/source` exports. ALPR and earth
|
|||||||
|
|
||||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
|
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
|
||||||
|
|
||||||
The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. 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. 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. 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 30 tools using separate description-only metadata. 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.
|
||||||
|
|
||||||
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
|
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
|
||||||
layer visibility lifecycle. Results distinguish successful changes from
|
layer visibility lifecycle. Results distinguish successful changes from
|
||||||
@@ -896,10 +1050,9 @@ voice disposes the runner.
|
|||||||
Analyst result memory is committed only after any Contacts-window projection
|
Analyst result memory is committed only after any Contacts-window projection
|
||||||
finishes and the call is still current. Cancelled or superseded queries cannot
|
finishes and the call is still current. Cancelled or superseded queries cannot
|
||||||
return success or replace follow-up memory; Contacts follow-ups filter the same
|
return success or replace follow-up memory; Contacts follow-ups filter the same
|
||||||
cohort that was shown, and partial/unanswered coverage remains attached when a
|
cohort that was shown and retain its displayed scope and loaded-data note unless
|
||||||
remembered result is narrowed.
|
the follow-up supplies a new scope. Partial/unanswered coverage remains attached
|
||||||
The narrowed answer retains the remembered scope label and detail, including
|
when a remembered result is narrowed.
|
||||||
the Contacts-window attribution, unless the follow-up supplies a new scope.
|
|
||||||
|
|
||||||
Application startup constructs fresh layer instances from explicit source objects.
|
Application startup constructs fresh layer instances from explicit source objects.
|
||||||
Standalone composition selects the existing providers. Controls, launch orbit
|
Standalone composition selects the existing providers. Controls, launch orbit
|
||||||
@@ -1197,12 +1350,12 @@ addition to the locally accumulated trail, never a promise of complete coverage.
|
|||||||
Snapshot coverage, completeness and freshness are separate fields. A partially
|
Snapshot coverage, completeness and freshness are separate fields. A partially
|
||||||
admitted aircraft snapshot retains absent contacts for up to five minutes before
|
admitted aircraft snapshot retains absent contacts for up to five minutes before
|
||||||
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
|
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
|
||||||
longitude and latitude is a contact without a position, not a malformed row: it
|
longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a
|
||||||
leaves the snapshot complete and is listed in `retainIds`, so civil flights
|
contact without a position, not a malformed row: it leaves the snapshot complete
|
||||||
retain that aircraft under the same five-minute bound, unless it is likely
|
and is listed in `retainIds`, so civil and military flights retain that aircraft
|
||||||
landed, while other absent aircraft fade and leave on the missed-poll limits. A
|
under the same five-minute bound, unless it is likely landed, while other absent
|
||||||
nonempty snapshot with no positioned rows is still malformed. Military readsb
|
aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no
|
||||||
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||||
snapshot retains the previous display. Empty vessel refreshes retain the existing
|
snapshot retains the previous display. Empty vessel refreshes retain the existing
|
||||||
first-connect grace and warm-data behavior. Known source failures have bounded
|
first-connect grace and warm-data behavior. Known source failures have bounded
|
||||||
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
||||||
@@ -1396,6 +1549,33 @@ By default the 250 cameras nearest downtown Vancouver and Victoria load.
|
|||||||
The Open Government Licence – British Columbia attribution is registered in the
|
The Open Government Licence – British Columbia attribution is registered in the
|
||||||
Data attribution popover.
|
Data attribution popover.
|
||||||
|
|
||||||
|
## Houston TranStar CCTV source pack
|
||||||
|
|
||||||
|
The CCTV catalog adds Greater Houston traffic cameras from Houston TranStar
|
||||||
|
alongside the other live packs. The server reads the keyless camera list at
|
||||||
|
`https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` and
|
||||||
|
keeps cameras with a numeric snapshot id inside the metro bounding box. Frame
|
||||||
|
URLs are built from each camera id under
|
||||||
|
`https://www.houstontranstar.org/snapshots/cctv/<id>.jpg`; they are never taken
|
||||||
|
from the payload. TranStar's `dir` is the side of the freeway the camera sits
|
||||||
|
on, not the way it faces; TranStar's own map reads it as the direction of the
|
||||||
|
nearest traffic (South → eastbound, North → westbound, West → southbound,
|
||||||
|
East → northbound). That travel direction seeds a low-confidence heading with
|
||||||
|
the low-confidence pose, and `None` or an unknown value uses the shared id-hash
|
||||||
|
fallback; both stay marked as estimated.
|
||||||
|
Gate: `src/data/cctvHouston.test.mjs`.
|
||||||
|
|
||||||
|
TranStar's Content Usage terms (https://traffic.houstontranstar.org/disclaimer.aspx)
|
||||||
|
prohibit copying, distribution and external framing of specific images without
|
||||||
|
TxDOT's prior written permission. The pack is off by default; operators should
|
||||||
|
set `CCTV_HOUSTON_ENABLED=1` only if they hold that permission. Frames are
|
||||||
|
fetched and relayed on demand, buffered only transiently per request, sent with
|
||||||
|
`Cache-Control: no-store`, and not archived. Once enabled, it loads the 250
|
||||||
|
cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston.
|
||||||
|
`CCTV_HOUSTON_MAX_SOURCES` sets the pack cap (8–400). Snapshots update about
|
||||||
|
every 3 minutes. The catalog-wide ceiling stays at 4,000 and is shared
|
||||||
|
round-robin, so adding this pack does not silently drop another region.
|
||||||
|
|
||||||
## Location control ownership
|
## Location control ownership
|
||||||
|
|
||||||
City/POI rows, search/reset bindings, location readouts and the orbit indicator
|
City/POI rows, search/reset bindings, location readouts and the orbit indicator
|
||||||
@@ -1715,6 +1895,12 @@ unaffected. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` ov
|
|||||||
those; exactly `0` disables the limiter, while a value that cannot be read as a
|
those; exactly `0` disables the limiter, while a value that cannot be read as a
|
||||||
number falls back to the default rather than to unlimited. Over-limit requests
|
number falls back to the default rather than to unlimited. Over-limit requests
|
||||||
receive a sanitized `429` with `Retry-After: 5` and never reach the provider.
|
receive a sanitized `429` with `Retry-After: 5` and never reach the provider.
|
||||||
|
Typed chat (`/api/openai/chat`, `/api/gemini/chat`) counts in its own window,
|
||||||
|
set by `GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` and `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`.
|
||||||
|
Left unset, each takes its provider's resolved cap — `GEV_RATELIMIT_OPENAI_PER_MIN`
|
||||||
|
or `GEV_RATELIMIT_GEMINI_PER_MIN`, both 30 by default — so lowering a provider
|
||||||
|
throttle lowers chat too; an explicit chat value, including `0`, wins. Its
|
||||||
|
over-limit reply carries `Retry-After: 60`.
|
||||||
The client key is the socket peer address only — a forwarded-for header is not
|
The client key is the socket peer address only — a forwarded-for header is not
|
||||||
trusted, so a proxied deployment shares one bucket per upstream hop.
|
trusted, so a proxied deployment shares one bucket per upstream hop.
|
||||||
|
|
||||||
@@ -2068,7 +2254,12 @@ during continuous motion. Disable/re-enable resets selection cleanly.
|
|||||||
Close-up stem sizing uses camera-to-feature distance without a 5 km minimum.
|
Close-up stem sizing uses camera-to-feature distance without a 5 km minimum.
|
||||||
Unchanged overlay entries do not request another frame; moved positions,
|
Unchanged overlay entries do not request another frame; moved positions,
|
||||||
changed membership and re-enabling still publish. Ground sampling waits for
|
changed membership and re-enabling still publish. Ground sampling waits for
|
||||||
visible globe tiles to settle and uses valid loaded terrain as a floor while
|
visible globe tiles to settle, and skips markers whose ground the camera
|
||||||
|
cannot see (unseen ground reads through the Earth and would retry every 2 s).
|
||||||
|
Ground is tested at loaded globe terrain, or across -9,000 to 9,000 m when the
|
||||||
|
globe is hidden, so photoreal views skip only ground well out of view. Until it is
|
||||||
|
seen, a stem keeps its unsampled height, which can show for a stem whose base
|
||||||
|
is just off screen. Sampling uses valid loaded terrain as a floor while
|
||||||
retaining rooftop and below-sea-level elevations. A hidden globe does not
|
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
|
||||||
@@ -2195,6 +2386,62 @@ Generated style sliders use the same name as their visible parameter label.
|
|||||||
The first-run suppression checkbox keeps its native wrapping label, so its
|
The first-run suppression checkbox keeps its native wrapping label, so its
|
||||||
accessible name remains "Don't show this again". Control behavior is unchanged.
|
accessible name remains "Don't show this again". Control behavior is unchanged.
|
||||||
|
|
||||||
|
## Active Fires intensity filter and color ramp
|
||||||
|
|
||||||
|
Every FIRMS detection is colored by fire radiative power on a six-stop ember →
|
||||||
|
white-hot ramp: dark ember from 0 MW, red from 5, flame from 20, orange from
|
||||||
|
50, gold from 100 and pale white-hot from 300 MW (`FRP_RAMP` in
|
||||||
|
`src/data/firmsIntensity.js`). Each stop is lighter than the one below and
|
||||||
|
none is a cold hue, so the brightest marks are the strongest fires; ~90% of
|
||||||
|
detections are under 20 MW and stay dim red. Glow sprites and fire-card
|
||||||
|
accents read that ramp; confidence and brightness do not affect the color.
|
||||||
|
|
||||||
|
At far zoom (camera above ~4,500 km) the layer drapes a Snap Map–style
|
||||||
|
heatmap instead of individual markers: the shown detections are linearly binned
|
||||||
|
(each detection's weight shared between the four nearest pixel centres, so spots
|
||||||
|
sit at the true position and stay round) on a
|
||||||
|
global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is
|
||||||
|
radiated power, and raising the FRP slider does not dim areas where strong fires
|
||||||
|
burn, and a cluster of detections reads brighter than one), Gaussian-blurred by one
|
||||||
|
pixel so each fire stays a distinct spot about the size of its marker,
|
||||||
|
normalized on a log scale against the 99.5th percentile and mapped through the
|
||||||
|
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
|
||||||
|
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
|
||||||
|
tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt
|
||||||
|
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
|
||||||
|
built at every zoom and cross-fade by camera height between 3,600 and
|
||||||
|
5,400 km, every frame, so a fast zoom never shows a gap: the heatmap fades by
|
||||||
|
imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe
|
||||||
|
tiles and re-mapped late when shown), the markers by sprite alpha, and the
|
||||||
|
marker collection is hidden once the heatmap is whole. The 750–4,500 km
|
||||||
|
markers band draws up to 40,000 detections (the strongest in the padded
|
||||||
|
view), so the weak fires the heatmap shows stay on screen as markers when it
|
||||||
|
fades out; it reads each marker's already-built anchor instead of the ground
|
||||||
|
floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest
|
||||||
|
areas; their accent is the strongest fire's FRP color.
|
||||||
|
Re-enabling Active Fires keeps the retained heat imagery hidden when the current
|
||||||
|
filtered set is empty, including while a new snapshot is pending.
|
||||||
|
|
||||||
|
The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale
|
||||||
|
whose track is painted with the ramp and ticked at the same six values (its
|
||||||
|
value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ /
|
||||||
|
High, and an "N of M shown" counter. The default is 0 MW and
|
||||||
|
All, because many of the strongest detections are rated low confidence. The
|
||||||
|
filter runs once in `ingestion.applyFilter()` before rendering, so markers,
|
||||||
|
cards, the heatmap, the shared context store, the top-fires query and the
|
||||||
|
analyst records all see one filtered set; a selected fire that no longer passes
|
||||||
|
is deselected. The layer state keeps both options under the `w` (`local-firms`)
|
||||||
|
share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`),
|
||||||
|
including the short share codec; a link without them restores the defaults.
|
||||||
|
|
||||||
|
The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence`
|
||||||
|
(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and
|
||||||
|
names the applied criteria in its notes. Stale-snapshot and missing-source
|
||||||
|
reporting are unchanged.
|
||||||
|
|
||||||
## FIRMS source status
|
## FIRMS source status
|
||||||
|
|
||||||
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
|
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
|
||||||
@@ -3092,9 +3339,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
|
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
|
||||||
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
|
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
|
||||||
> Its Radio popover exposes compact power,
|
> Its Radio popover exposes compact power,
|
||||||
> transport, station, and volume controls. Cockpit Previous/Next preserves
|
> transport, station, and volume controls. Cockpit Previous/Next changes the
|
||||||
> selection, autoplay, and broadcaster fallback audio without starting the
|
> selection without starting the map-view station flights that compete with
|
||||||
> map-view station flights that compete with the first-person camera. These
|
> the first-person camera; like the Context Previous/Next buttons, it plays
|
||||||
|
> (with broadcaster fallback) only while a stream is already active. These
|
||||||
> are a Cockpit-only accordion:
|
> are a Cockpit-only accordion:
|
||||||
> each static header uses the left accordion's label and divider with a dedicated
|
> each static header uses the left accordion's label and divider with a dedicated
|
||||||
> directional-chevron disclosure button and no full-row hover treatment. Only one
|
> directional-chevron disclosure button and no full-row hover treatment. Only one
|
||||||
@@ -3257,16 +3505,25 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
|
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
|
||||||
> (60 seconds when none, at most 10 minutes). A request whose points are all
|
> (60 seconds when none, at most 10 minutes). A request whose points are all
|
||||||
> cached, fresh or stale, is still served; a request with any uncached point
|
> cached, fresh or stale, is still served; a request with any uncached point
|
||||||
> gets 502 and the page uses its geoid fallback for that chunk. Each request
|
> gets 502, whose `partial` lists the real heights it has (null for the rest)
|
||||||
|
> when it has any, and the page keeps those and uses its geoid fallback only
|
||||||
|
> for the points without one. The same holds when the upstream answers a
|
||||||
|
> point with a null height. Each request
|
||||||
> that was waiting on the upstream when the 429 came back logs one warning;
|
> that was waiting on the upstream when the 429 came back logs one warning;
|
||||||
> requests refused during the pause do not log. Client geoid
|
> requests refused during the pause do not log. Client geoid
|
||||||
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
||||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||||
> camera batches still wait for their sequential requests, and unresolved
|
> camera batches still wait for their sequential requests, and unresolved
|
||||||
> placement continues to use the existing prior until real heights arrive.
|
> placement continues to use the existing prior until real heights arrive.
|
||||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
|
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
|
||||||
> POSTs through the shared local-request gate, then limits requests to 300/min
|
> through the keyless same-site gate (reverse-proxy forwarding headers are
|
||||||
> per socket peer and 900/min globally before reading or validating bodies.
|
> admitted, as for `/api/terrain/heights`), then limits requests to 300/min
|
||||||
|
> per peer — IPv6 peers keyed by /64 — and 900/min across all remote peers
|
||||||
|
> before reading or validating bodies. Direct loopback (no forwarding
|
||||||
|
> headers) has its own 300/min window outside that global; a local reverse
|
||||||
|
> proxy's requests carry forwarding headers and are counted as remote, keyed
|
||||||
|
> `proxied:<socket peer>`, so they cannot spend the owner's window. Upstream
|
||||||
|
> quota stays shared, so owner cache misses can still hit 429.
|
||||||
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
|
||||||
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||||
> before invoking its separate local 90/min upstream limiter. Cache and
|
> before invoking its separate local 90/min upstream limiter. Cache and
|
||||||
@@ -3990,31 +4247,34 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
|
|||||||
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||||
its criteria cannot be silently ignored.
|
its criteria cannot be silently ignored.
|
||||||
|
|
||||||
| Layer | Source | File | Proxy | Update Interval |
|
| Layer | Source | File | Proxy | Update Interval |
|
||||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||||
|
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||||
|
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||||
|
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||||
|
|
||||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||||
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||||
@@ -4042,6 +4302,20 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
|||||||
|
|
||||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||||
|
|
||||||
|
Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in
|
||||||
|
Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA
|
||||||
|
FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last
|
||||||
|
good payload as stale for at most 24 hours when a refresh fails; the layer row
|
||||||
|
then reads STALE, dated by that snapshot's fetch. An answer with no valid zone, or
|
||||||
|
paging that ends while the service still flags more records, counts as a
|
||||||
|
failed refresh. Client-supplied URLs are ignored. The route uses the keyless
|
||||||
|
same-site checks: a cross-site browser request gets `403` before it spends the
|
||||||
|
per-client quota or starts a refresh, and the app still works behind a reverse
|
||||||
|
proxy.
|
||||||
|
Polygons keep holes; pick ownership is deliberately unregistered so sibling
|
||||||
|
cards still work over a zone. The row legend and selected card state that the
|
||||||
|
layer is informational only and not a substitute for B4UFLY / LAANC.
|
||||||
|
|
||||||
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
||||||
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||||
collapsed. It opens itself only when the user, voice or a tool switches the
|
collapsed. It opens itself only when the user, voice or a tool switches the
|
||||||
@@ -4704,8 +4978,9 @@ programmatic activation do not trigger this reveal.
|
|||||||
|
|
||||||
Radio directory admission is atomic on both sides of the proxy boundary. A
|
Radio directory admission is atomic on both sides of the proxy boundary. A
|
||||||
refresh is healthy only when it reaches the minimum accepted-query and station
|
refresh is healthy only when it reaches the minimum accepted-query and station
|
||||||
coverage; schema-valid responses with zero normalized stations count as failed
|
coverage from broad/specialist queries; their schema-valid responses with zero
|
||||||
queries rather than inflating refresh health. Each specialist query also needs
|
normalized stations count as failed queries rather than inflating refresh health.
|
||||||
|
Optional priority-country outcomes do not gate catalog health. Each specialist query also needs
|
||||||
an accepted station whose normalized tags match that requested category; rows
|
an accepted station whose normalized tags match that requested category; rows
|
||||||
tagged only for another category remain usable catalog data but do not earn
|
tagged only for another category remain usable catalog data but do not earn
|
||||||
specialist health credit. A partial cold result remains
|
specialist health credit. A partial cold result remains
|
||||||
@@ -4736,10 +5011,12 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
|
|||||||
ownership tokens plus the session boundary keep late callbacks from a retired
|
ownership tokens plus the session boundary keep late callbacks from a retired
|
||||||
session inert.
|
session inert.
|
||||||
A tuner drag resolves previews against the single accepted snapshot captured at
|
A tuner drag resolves previews against the single accepted snapshot captured at
|
||||||
pointer-down, even while a newer catalog is admitted. Release starts playback
|
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
|
||||||
only when the current same-ID record still exactly matches the frozen
|
release starts playback only if a stream was active when the drag began;
|
||||||
presentation and stream metadata; removal or replacement reports the channel as
|
otherwise it just selects the station. It commits only when the current same-ID
|
||||||
unavailable and never silently retargets the drag. A cold degraded fallback may
|
record still exactly matches the frozen presentation and stream metadata.
|
||||||
|
Removal or replacement reports the channel as unavailable and never silently
|
||||||
|
retargets the drag. A cold degraded fallback may
|
||||||
still populate the directory and globe, but its null accepted generation cannot
|
still populate the directory and globe, but its null accepted generation cannot
|
||||||
populate or begin the tuner.
|
populate or begin the tuner.
|
||||||
|
|
||||||
@@ -4916,7 +5193,9 @@ before the later control succeeds, only its playback handoff is suppressed; its
|
|||||||
result reads enabled state from the manager and audio state from the Radio
|
result reads enabled state from the manager and audio state from the Radio
|
||||||
module. Voice Pause is a playback-only no-op while the layer is disabled; it
|
module. Voice Pause is a playback-only no-op while the layer is disabled; it
|
||||||
never enables Radio and reports a fulfilled-false pause as failure. A
|
never enables Radio and reports a fulfilled-false pause as failure. A
|
||||||
newer-response or user-origin Stop still cancels stale handoff work.
|
newer-response or user-origin Stop still cancels stale handoff work, as do a
|
||||||
|
user Pause, a user Previous/Next that only selects another station, and a tuner
|
||||||
|
release onto a station the directory removed or changed mid-drag.
|
||||||
Only a newer user turn or session teardown aborts the complete active-tool set.
|
Only a newer user turn or session teardown aborts the complete active-tool set.
|
||||||
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
|
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
|
||||||
controls and generic layer-visibility commands both forward cancellation.
|
controls and generic layer-visibility commands both forward cancellation.
|
||||||
@@ -4957,6 +5236,7 @@ silently demoting every later lookup for the session.
|
|||||||
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
||||||
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
|
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
|
||||||
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
|
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
|
||||||
|
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
|
||||||
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
|
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
|
||||||
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
|
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
|
||||||
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
|
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
|
||||||
@@ -4999,6 +5279,10 @@ silently demoting every later lookup for the session.
|
|||||||
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
|
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
|
||||||
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
|
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
|
||||||
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
|
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
|
||||||
|
The table also has Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam
|
||||||
|
register proposed in #251, with its `x` colour aliases (#1319). Their datasheet FOV names
|
||||||
|
no axis, so it neither gives a capability range nor replaces the estimate. The records
|
||||||
|
alone do not enable that source pack.
|
||||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW).
|
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW).
|
||||||
- **CCTV v2 foundation:** a pitched
|
- **CCTV v2 foundation:** a pitched
|
||||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||||
@@ -5364,7 +5648,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
|
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
|
||||||
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
|
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
|
||||||
|
|
||||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
|
- **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. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, the unchanged Globe View destination is checked against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry fails closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
|
||||||
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
|
- **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).
|
||||||
@@ -5405,8 +5689,11 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
### AI HUD Summary (June 2026)
|
### AI HUD Summary (June 2026)
|
||||||
|
|
||||||
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
||||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
- Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown.
|
||||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
- Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
|
||||||
|
- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
|
||||||
|
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that line instead of the fragment.
|
||||||
|
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer, or partial plus nominal, reads partial, not off or nominal (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
|
||||||
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
||||||
|
|
||||||
### Place-search providers
|
### Place-search providers
|
||||||
@@ -5691,7 +5978,7 @@ pins and share links already supply complete boxes.
|
|||||||
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
|
||||||
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
|
||||||
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
|
||||||
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout keeps every angle it measured (the settle frame before a reveal timeout, and the late frame itself, which is read before the deadline check), so a measured wrong angle fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
|
||||||
|
|
||||||
### TR-3B conversion Easter egg (August 2026)
|
### TR-3B conversion Easter egg (August 2026)
|
||||||
|
|
||||||
@@ -5789,7 +6076,7 @@ apply to `public/` assets or built-preview serving.
|
|||||||
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
|
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
|
||||||
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
|
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
|
||||||
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
|
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
|
||||||
- GEV MIC control is a glass capsule (var(--glass-bg), blur(24px) saturate(1.4), 999px radius; panel radius in error state).
|
- The voice and chat panels share the left panels' glass surface (`--glass-bg`, `--glass-border`, blur(24px) saturate(1.4); `src/ui/styles/ai-chat.css`). The docked voice control draws its own blur on a layer behind it, so the panels inside it still blur the map. Cyber keeps its own surfaces.
|
||||||
- The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored.
|
- The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored.
|
||||||
- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded).
|
- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded).
|
||||||
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. One right-rail panel is open at a time in every HUD layout and collapsed launchers are hidden; the panel rail beside the stack switches between them (see Panel rails). The most recently opened right-rail panel owns the lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. When the open panel exceeds the measured corridor, it scrolls internally.
|
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. One right-rail panel is open at a time in every HUD layout and collapsed launchers are hidden; the panel rail beside the stack switches between them (see Panel rails). The most recently opened right-rail panel owns the lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. When the open panel exceeds the measured corridor, it scrolls internally.
|
||||||
@@ -6040,6 +6327,11 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
TomTom draws only flow-tile lines with their own flow; roads without flow
|
TomTom draws only flow-tile lines with their own flow; roads without flow
|
||||||
are not drawn and the status says so. A `full`-coverage flow line carries
|
are not drawn and the status says so. A `full`-coverage flow line carries
|
||||||
both travel directions, a `one_side` line the direction it is drawn in.
|
both travel directions, a `one_side` line the direction it is drawn in.
|
||||||
|
When an opposite-direction `one_side` line runs within 5 m of at least two
|
||||||
|
of three points a quarter, half and three quarters along a `one_side` line,
|
||||||
|
the two are one street's directions and each
|
||||||
|
gets a two-way direction's half share of dots, so the street is as dense as
|
||||||
|
one `full` line (#1455).
|
||||||
OSM draws OpenFreeMap roads with flow matched onto them (below). Hybrid
|
OSM draws OpenFreeMap roads with flow matched onto them (below). Hybrid
|
||||||
draws eligible TomTom motor-road lines and adds OpenFreeMap roads only where no eligible TomTom line
|
draws eligible TomTom motor-road lines and adds OpenFreeMap roads only where no eligible TomTom line
|
||||||
runs within 35 m in the same travel direction (30 degrees), tested every
|
runs within 35 m in the same travel direction (30 degrees), tested every
|
||||||
@@ -6299,6 +6591,46 @@ Existing catalog validation, category filters, tuning and voice playback behavio
|
|||||||
remain unchanged. Audio connects directly to the broadcaster after an explicit
|
remain unchanged. Audio connects directly to the broadcaster after an explicit
|
||||||
play action; the source does not relay or record streams.
|
play action; the source does not relay or record streams.
|
||||||
|
|
||||||
|
### Radio priority-country coverage
|
||||||
|
|
||||||
|
Optional `RADIO_PRIORITY_COUNTRIES` is a server-owned comma-separated list of
|
||||||
|
up to eight ISO alpha-2 codes (for example `IN,PL`). Restart after changing it.
|
||||||
|
The `UK` alias is canonicalized to `GB` before deduplication and querying.
|
||||||
|
It needs no API key and adds no POWER UP entry. An empty list preserves the
|
||||||
|
existing global/tag-based directory. Invalid configuration fails at startup.
|
||||||
|
|
||||||
|
Each country adds one Radio Browser search capped at 200 rows, with existing
|
||||||
|
HTTPS/geolocation/codec validation and an additional matching-country check.
|
||||||
|
Up to 40 unique accepted stations per country are seeded before the specialist
|
||||||
|
tag queries and global popularity fill, within the unchanged 750-station cap.
|
||||||
|
No country gets exhaustive coverage. A valid, in-limit country response with
|
||||||
|
zero eligible stations succeeds with zero selected stations.
|
||||||
|
The response's `coverage.priorityCountries` reports accepted query status and
|
||||||
|
selected counts when configured. The existing broad/specialist health policy
|
||||||
|
still applies separately. Country outcomes are advisory: failed, malformed or
|
||||||
|
oversized country queries report unsuccessful coverage but cannot degrade or
|
||||||
|
block admission of an otherwise healthy global catalog. Queries share the existing
|
||||||
|
three-worker pool, single-flight refresh and 45-minute cache.
|
||||||
|
Structurally malformed rows are skipped and counted in the upstream
|
||||||
|
`rejectedStationCount` diagnostic; mixed country responses retain their valid
|
||||||
|
rows, while an entirely malformed nonempty country response reports unsuccessful
|
||||||
|
coverage. The diagnostic does not block catalog admission.
|
||||||
|
|
||||||
|
For every installation, including those with no priority countries configured,
|
||||||
|
at most three mirrors are tried per request; fallback mirrors beyond the first
|
||||||
|
three candidates are not attempted. Failed or partial catalog refreshes retry
|
||||||
|
after one minute, so a cold-start failure can continue returning 503 for that
|
||||||
|
minute even if upstream recovers sooner. Healthy catalogs retain the 45-minute
|
||||||
|
cache. The seven-day last-good expiry and known-station-only click reporting
|
||||||
|
remain in force.
|
||||||
|
|
||||||
|
Validation: `node --test src/data/radioProxy.test.mjs` covers configuration,
|
||||||
|
country quotas under global popularity pressure, duplicate records, concurrency,
|
||||||
|
cache reuse, country mismatch/oversize/empty/ineligible responses, failure isolation on cold
|
||||||
|
and warm refreshes beyond seven days, and retry/backoff recovery both with and
|
||||||
|
without configured countries. `node scripts/qa-radio.mjs --url http://localhost:4173` covers the existing
|
||||||
|
750-station browser presentation and playback contract.
|
||||||
|
|
||||||
## Local RTL-SDR and Local ADS-B
|
## Local RTL-SDR and Local ADS-B
|
||||||
|
|
||||||
The Radio panel holds a Local RTL-SDR card below internet radio. One browser
|
The Radio panel holds a Local RTL-SDR card below internet radio. One browser
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
|||||||
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
| 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 | |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
|||||||
| `pointerContext.js` | Pointer position, dwell pick and the current turn's pointer snapshot (session-scoped) |
|
| `pointerContext.js` | Pointer position, dwell pick and the current turn's pointer snapshot (session-scoped) |
|
||||||
| `referents.js` | Numbered items from the latest result for `referent:n` (session-scoped) |
|
| `referents.js` | Numbered items from the latest result for `referent:n` (session-scoped) |
|
||||||
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
||||||
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
| `gevActions.js` runner | Area handles (page lifetime); analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
||||||
|
|
||||||
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
|
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
|
||||||
`backend` option and falls back to `createRealtimeBackend()`. Its two method
|
`backend` option and falls back to `createRealtimeBackend()`. Its two method
|
||||||
|
|||||||
Generated
+1112
-5
File diff suppressed because it is too large.
Load diff
+9
-1
@@ -53,21 +53,27 @@
|
|||||||
"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",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -176,6 +182,7 @@
|
|||||||
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
||||||
"./layers/gnss": "./src/layers/gnss/index.js",
|
"./layers/gnss": "./src/layers/gnss/index.js",
|
||||||
"./layers/ghostmaps": "./src/layers/ghostMaps/index.js",
|
"./layers/ghostmaps": "./src/layers/ghostMaps/index.js",
|
||||||
|
"./layers/drone-airspace": "./src/layers/droneAirspace/index.js",
|
||||||
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
||||||
"./layers/firms": "./src/layers/firms/index.js",
|
"./layers/firms": "./src/layers/firms/index.js",
|
||||||
"./layers/satellites": "./src/layers/satellites/index.js",
|
"./layers/satellites": "./src/layers/satellites/index.js",
|
||||||
@@ -255,6 +262,7 @@
|
|||||||
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
||||||
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
||||||
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
|
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
|
||||||
|
"./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js",
|
||||||
"./layers/firms/source": "./src/layers/firms/source.js",
|
"./layers/firms/source": "./src/layers/firms/source.js",
|
||||||
"./layers/installations/source": "./src/layers/installations/source.js",
|
"./layers/installations/source": "./src/layers/installations/source.js",
|
||||||
"./layers/launches/source": "./src/layers/launches/source.js",
|
"./layers/launches/source": "./src/layers/launches/source.js",
|
||||||
|
|||||||
@@ -30,3 +30,7 @@ PINOKIO_SHARE_VAR=__gev_sharing_disabled__
|
|||||||
# App-level guards, not billing caps. Provider-side budgets remain authoritative.
|
# App-level guards, not billing caps. Provider-side budgets remain authoritative.
|
||||||
GEV_RATELIMIT_OPENAI_PER_MIN=30
|
GEV_RATELIMIT_OPENAI_PER_MIN=30
|
||||||
GEV_RATELIMIT_GOOGLE_PER_MIN=120
|
GEV_RATELIMIT_GOOGLE_PER_MIN=120
|
||||||
|
# Typed chat follows the OpenAI cap above, or Gemini voice's default of 30,
|
||||||
|
# unless set here; 0 disables it.
|
||||||
|
# GEV_RATELIMIT_OPENAI_CHAT_PER_MIN=
|
||||||
|
# GEV_RATELIMIT_GEMINI_CHAT_PER_MIN=
|
||||||
@@ -28,6 +28,8 @@ module.exports = {
|
|||||||
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
||||||
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
||||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
||||||
|
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
|
||||||
|
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
|
||||||
},
|
},
|
||||||
message: 'node scripts/pinokio-install.mjs',
|
message: 'node scripts/pinokio-install.mjs',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ module.exports = {
|
|||||||
PINOKIO_SHARE_VAR: '{{env.PINOKIO_SHARE_VAR || "__gev_sharing_disabled__"}}',
|
PINOKIO_SHARE_VAR: '{{env.PINOKIO_SHARE_VAR || "__gev_sharing_disabled__"}}',
|
||||||
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
||||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
||||||
|
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
|
||||||
|
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
|
||||||
},
|
},
|
||||||
message: 'node scripts/pinokio-start.mjs',
|
message: 'node scripts/pinokio-start.mjs',
|
||||||
on: [{
|
on: [{
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ module.exports = {
|
|||||||
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
||||||
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
||||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
||||||
|
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
|
||||||
|
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
|
||||||
},
|
},
|
||||||
message: 'node scripts/pinokio-update.mjs',
|
message: 'node scripts/pinokio-update.mjs',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// 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 { readFileSync } from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { parse } from '@babel/parser';
|
||||||
|
|
||||||
|
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', 'src'], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
.split('\n')
|
||||||
|
.filter(
|
||||||
|
(path) =>
|
||||||
|
/\.(?:js|mjs)$/.test(path) &&
|
||||||
|
!/\.test\.m?js$/.test(path) &&
|
||||||
|
!path.startsWith('src/testSupport/') &&
|
||||||
|
!path.includes('/testing/'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
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>') {
|
||||||
|
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,42 @@
|
|||||||
|
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, []);
|
||||||
|
});
|
||||||
@@ -19,6 +19,63 @@ 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\/)/;
|
||||||
|
// Layers built on layer renderers keep each engine in their renderers:
|
||||||
|
// cesium only in render/cesium*.js, three and Navara only in
|
||||||
|
// render/navara*.js. The list grows with each layer moved onto renderers.
|
||||||
|
const RENDERED_LAYER_DIRS = [
|
||||||
|
'src/layers/alpr/',
|
||||||
|
'src/layers/airQuality/',
|
||||||
|
'src/layers/gnss/',
|
||||||
|
'src/layers/perimeters/',
|
||||||
|
'src/layers/radio/',
|
||||||
|
'src/layers/bikeshare/',
|
||||||
|
'src/layers/earthquakes/',
|
||||||
|
'src/layers/firms/',
|
||||||
|
'src/layers/flights/',
|
||||||
|
'src/layers/military/',
|
||||||
|
'src/layers/localAdsb/',
|
||||||
|
'src/layers/vessels/',
|
||||||
|
'src/layers/satellites/',
|
||||||
|
'src/layers/launches/',
|
||||||
|
'src/layers/aircraft/',
|
||||||
|
'src/layers/shared/',
|
||||||
|
];
|
||||||
|
const W1_SHARED_FILES = new Set(
|
||||||
|
[
|
||||||
|
'trackedCamera',
|
||||||
|
'trailRenderer',
|
||||||
|
'contactTrailRenderer',
|
||||||
|
'focusDeemphasis',
|
||||||
|
'spriteOrder',
|
||||||
|
'iconOrientation',
|
||||||
|
'modelVisualAnchor',
|
||||||
|
'trackingClickGesture',
|
||||||
|
].map((name) => `src/data/${name}.js`),
|
||||||
|
);
|
||||||
|
const renderedLayer = (file) =>
|
||||||
|
W1_SHARED_FILES.has(file) ||
|
||||||
|
file.startsWith('src/data/render/') ||
|
||||||
|
file === 'src/celestialRing.js' ||
|
||||||
|
file.startsWith('src/celestialRing/') ||
|
||||||
|
RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir));
|
||||||
|
const cesiumLayerRenderer = (file) =>
|
||||||
|
/\/render\/cesium(?:[^/]*|\/[^/]+)\.js$/.test(file);
|
||||||
|
// Navara renderers outside a layer's render/ directory: like those, they
|
||||||
|
// reach the app only through the Navara mode's import().
|
||||||
|
const NAVARA_SCENE_RENDERERS = new Set(['src/scenes/dataPacks/navara.js']);
|
||||||
|
const navaraLayerRenderer = (file) =>
|
||||||
|
/\/render\/navara[^/]*\.js$/.test(file) || NAVARA_SCENE_RENDERERS.has(file);
|
||||||
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(
|
||||||
@@ -87,7 +144,9 @@ export function checkImportDirections(root) {
|
|||||||
}
|
}
|
||||||
let analysis;
|
let analysis;
|
||||||
try {
|
try {
|
||||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
|
analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
|
||||||
|
dynamic: true,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
report(file, error.message);
|
report(file, error.message);
|
||||||
return null;
|
return null;
|
||||||
@@ -109,6 +168,18 @@ export function checkImportDirections(root) {
|
|||||||
target = path.resolve(root, exported);
|
target = path.resolve(root, exported);
|
||||||
} else {
|
} else {
|
||||||
record.external.push(specifier);
|
record.external.push(specifier);
|
||||||
|
if (renderedLayer(file) && !tests(file)) {
|
||||||
|
if (cesiumPackage.test(specifier) && !cesiumLayerRenderer(file))
|
||||||
|
report(
|
||||||
|
file,
|
||||||
|
`cesium only in the layer's render/cesium*.js: ${specifier}`,
|
||||||
|
);
|
||||||
|
if (navaraPackage.test(specifier) && !navaraLayerRenderer(file))
|
||||||
|
report(
|
||||||
|
file,
|
||||||
|
`three and Navara only in the layer's render/navara*.js: ${specifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
file.startsWith('src/') &&
|
file.startsWith('src/') &&
|
||||||
(builtins.has(specifier) || specifier.startsWith('node:'))
|
(builtins.has(specifier) || specifier.startsWith('node:'))
|
||||||
@@ -131,6 +202,32 @@ 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.dynamicImports.includes(specifier)
|
||||||
|
)
|
||||||
|
report(
|
||||||
|
file,
|
||||||
|
`The Navara engine loads only through a dynamic import(): ${to}`,
|
||||||
|
);
|
||||||
|
// Like the engine, a layer's Navara renderer reaches the app only
|
||||||
|
// through the Navara mode's import() (src/app/navaraMode.js).
|
||||||
|
if (
|
||||||
|
navaraLayerRenderer(to) &&
|
||||||
|
!navaraLayerRenderer(file) &&
|
||||||
|
!(
|
||||||
|
file === 'src/app/navaraMode.js' &&
|
||||||
|
analysis.dynamicImports.includes(specifier)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
report(
|
||||||
|
file,
|
||||||
|
`A Navara layer renderer loads only through the Navara mode's 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 +274,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 +301,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 +321,48 @@ 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 start of files.filter(globeEngine)) {
|
||||||
|
const navara = start.startsWith('src/globe/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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -37,7 +37,11 @@ export async function checkPackageBoundaries(root) {
|
|||||||
) {
|
) {
|
||||||
throw new Error(`Invalid package boundary: ${name}`);
|
throw new Error(`Invalid package boundary: ${name}`);
|
||||||
}
|
}
|
||||||
|
// A module entry ending in "/" owns every file under that directory.
|
||||||
const allowed = new Set();
|
const allowed = new Set();
|
||||||
|
const allowedDirectories = [];
|
||||||
|
const owns = (id) =>
|
||||||
|
allowed.has(id) || allowedDirectories.some((dir) => id.startsWith(dir));
|
||||||
for (const module of group.modules) {
|
for (const module of group.modules) {
|
||||||
if (
|
if (
|
||||||
typeof module !== 'string' ||
|
typeof module !== 'string' ||
|
||||||
@@ -56,7 +60,11 @@ 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const external of group.external) {
|
for (const external of group.external) {
|
||||||
if (
|
if (
|
||||||
@@ -82,7 +90,7 @@ export async function checkPackageBoundaries(root) {
|
|||||||
if (
|
if (
|
||||||
typeof target !== 'string' ||
|
typeof target !== 'string' ||
|
||||||
!target.startsWith('./') ||
|
!target.startsWith('./') ||
|
||||||
!allowed.has(normalizePath(path.resolve(root, target)))
|
!owns(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}`);
|
||||||
}
|
}
|
||||||
@@ -109,7 +117,7 @@ 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)) {
|
if (!owns(id)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 ? 'W2 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: `w2-cctv-${String(i).padStart(3, '0')}`,
|
||||||
|
name: i === 12 ? 'Cámara pública — Congress' : `W2 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: 'W2 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: 'w2-cctv-video',
|
||||||
|
name: 'W2 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: 'W2 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 !== 'w2-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,273 @@
|
|||||||
|
import { cameras as alprCameras, cameraTile } from './alpr.mjs';
|
||||||
|
import { cameras as cctvCameras, cameraFrame } from './cctv.mjs';
|
||||||
|
import { stations as radioStations } from './radio.mjs';
|
||||||
|
import { earthquakePerformanceFixture } from './earthquakes.mjs';
|
||||||
|
|
||||||
|
export const COMBINED_EPOCH = Date.parse('2026-10-28T18:00:00Z');
|
||||||
|
export const COMBINED_ORIGIN = { lat: 42.3554, lon: -71.0605 };
|
||||||
|
const grid = (i, cols, spacing) => ({
|
||||||
|
lat: COMBINED_ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing,
|
||||||
|
lon: COMBINED_ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
|
||||||
|
});
|
||||||
|
const radio = radioStations.map((r, i) => ({ ...r, ...grid(i, 28, 0.001) }));
|
||||||
|
const cctv = cctvCameras.map((r, i) => ({
|
||||||
|
...r,
|
||||||
|
...grid(i, 5, 0.002),
|
||||||
|
cityId: 'boston',
|
||||||
|
city: 'Boston',
|
||||||
|
groundElevationM: 20,
|
||||||
|
}));
|
||||||
|
const alpr = alprCameras.map((r, i) => {
|
||||||
|
const p = grid(i, 19, 0.001);
|
||||||
|
return { ...r, latitude: p.lat, longitude: p.lon };
|
||||||
|
});
|
||||||
|
const bikes = Array.from({ length: 200 }, (_, i) => ({
|
||||||
|
station_id: `w2-bike-${i}`,
|
||||||
|
name: `W2 station ${i}`,
|
||||||
|
capacity: 20,
|
||||||
|
...grid(i, 15, 0.0015),
|
||||||
|
}));
|
||||||
|
const earthquakes = earthquakePerformanceFixture(500);
|
||||||
|
earthquakes.features = earthquakes.features.map((r, i) => {
|
||||||
|
const p = grid(i, 23, 0.002);
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
properties: { ...r.properties, time: COMBINED_EPOCH - 3600000 },
|
||||||
|
geometry:
|
||||||
|
i < 5 ? { ...r.geometry, coordinates: [p.lon, p.lat, 5] } : r.geometry,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const fires = Array.from({ length: 6000 }, (_, i) => ({
|
||||||
|
...grid(i, 78, 0.001),
|
||||||
|
frp: 5 + (i % 120),
|
||||||
|
confidence: ['low', 'nominal', 'high'][i % 3],
|
||||||
|
brightness: 280 + (i % 90),
|
||||||
|
daynight: i % 2 ? 'N' : 'D',
|
||||||
|
acqDate: '2026-10-28',
|
||||||
|
acqTime: '1730',
|
||||||
|
instrument: 'VIIRS',
|
||||||
|
satellite: 'NOAA-20',
|
||||||
|
}));
|
||||||
|
const elements = Array.from({ length: 20 }, (_, i) => {
|
||||||
|
const p = grid(i, 5, 0.006),
|
||||||
|
d = 0.0007;
|
||||||
|
return {
|
||||||
|
type: 'way',
|
||||||
|
id: 980000 + i,
|
||||||
|
center: p,
|
||||||
|
tags: { military: 'airfield', name: `W2 facility ${i}` },
|
||||||
|
bounds: {
|
||||||
|
minlat: p.lat - d,
|
||||||
|
minlon: p.lon - d,
|
||||||
|
maxlat: p.lat + d,
|
||||||
|
maxlon: p.lon + d,
|
||||||
|
},
|
||||||
|
geometry: [
|
||||||
|
[p.lon - d, p.lat - d],
|
||||||
|
[p.lon + d, p.lat - d],
|
||||||
|
[p.lon + d, p.lat + d],
|
||||||
|
[p.lon - d, p.lat + d],
|
||||||
|
[p.lon - d, p.lat - d],
|
||||||
|
].map(([lon, lat]) => ({ lon, lat })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
export const combinedPayloads = {
|
||||||
|
radio,
|
||||||
|
cctv,
|
||||||
|
alpr,
|
||||||
|
bikes,
|
||||||
|
earthquakes,
|
||||||
|
fires,
|
||||||
|
elements,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One source transport fixture, shared unchanged by both engines. */
|
||||||
|
export async function installCombinedFixture(
|
||||||
|
page,
|
||||||
|
{ denseQuakes = false } = {},
|
||||||
|
) {
|
||||||
|
const quakePayload = denseQuakes
|
||||||
|
? {
|
||||||
|
...earthquakes,
|
||||||
|
features: earthquakes.features.map((r, i) => {
|
||||||
|
const p = grid(i, 23, 0.002);
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
geometry: { ...r.geometry, coordinates: [p.lon, p.lat, 5] },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: earthquakes;
|
||||||
|
const frame = await cameraFrame(),
|
||||||
|
requests = [];
|
||||||
|
const session = await page.createCDPSession();
|
||||||
|
await session.send('Fetch.enable', {
|
||||||
|
patterns: [
|
||||||
|
...['127.0.0.1', 'localhost'].map((host) => ({
|
||||||
|
urlPattern: `http://${host}:*/api/*`,
|
||||||
|
})),
|
||||||
|
{ urlPattern: 'https://earthquake.usgs.gov/*all_day.geojson' },
|
||||||
|
{ urlPattern: 'https://tiles.dontgetflocked.com/*' },
|
||||||
|
{ urlPattern: '*://*.earthdata.nasa.gov/*' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let disposed = false;
|
||||||
|
session.on('Fetch.requestPaused', async (event) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(event.request.url);
|
||||||
|
let body,
|
||||||
|
type = 'application/json';
|
||||||
|
const p = u.pathname;
|
||||||
|
if (u.hostname === 'earthquake.usgs.gov') body = quakePayload;
|
||||||
|
else if (u.hostname === 'tiles.dontgetflocked.com') {
|
||||||
|
if (p.endsWith('.json'))
|
||||||
|
body = {
|
||||||
|
tiles: [
|
||||||
|
'https://tiles.dontgetflocked.com/cameras-us-hourly/{z}/{x}/{y}.mvt',
|
||||||
|
],
|
||||||
|
bounds: [-160, 17, -64, 59],
|
||||||
|
};
|
||||||
|
else {
|
||||||
|
const m = p.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
|
||||||
|
body = m
|
||||||
|
? cameraTile(...m.slice(1).map(Number), alpr)
|
||||||
|
: Buffer.alloc(0);
|
||||||
|
type = 'application/x-protobuf';
|
||||||
|
}
|
||||||
|
} else if (u.hostname === 'cmr.earthdata.nasa.gov')
|
||||||
|
body = {
|
||||||
|
hits: 2,
|
||||||
|
items: ['2026-10-28', '2026-10-27'].map((day) => ({
|
||||||
|
umm: {
|
||||||
|
TemporalExtent: {
|
||||||
|
RangeDateTime: { BeginningDateTime: `${day}T12:00:00Z` },
|
||||||
|
},
|
||||||
|
AdditionalAttributes: [{ Name: 'CLOUD_COVERAGE', Values: ['0'] }],
|
||||||
|
SpatialExtent: {
|
||||||
|
HorizontalSpatialDomain: {
|
||||||
|
Geometry: {
|
||||||
|
BoundingRectangles: [
|
||||||
|
{
|
||||||
|
WestBoundingCoordinate: -72,
|
||||||
|
EastBoundingCoordinate: -70,
|
||||||
|
SouthBoundingCoordinate: 41,
|
||||||
|
NorthBoundingCoordinate: 43,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
else if (u.hostname.endsWith('.earthdata.nasa.gov')) {
|
||||||
|
body = frame;
|
||||||
|
type = 'image/png';
|
||||||
|
} else if (p === '/api/firms')
|
||||||
|
body = { fires, fetchedAt: COMBINED_EPOCH - 120000, stale: false };
|
||||||
|
else if (p === '/api/radio/stations')
|
||||||
|
body = {
|
||||||
|
stations: radio,
|
||||||
|
updatedAt: new Date(COMBINED_EPOCH).toISOString(),
|
||||||
|
stale: false,
|
||||||
|
degraded: false,
|
||||||
|
acceptedGeneration: 1,
|
||||||
|
catalogInstance: 'w2-combined-fixture',
|
||||||
|
};
|
||||||
|
else 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/military-installations')
|
||||||
|
body = {
|
||||||
|
status: 'ready',
|
||||||
|
retrievedAt: new Date(COMBINED_EPOCH).toISOString(),
|
||||||
|
saturated: false,
|
||||||
|
elements,
|
||||||
|
};
|
||||||
|
else if (p === '/api/cctv/sources') body = { sources: cctv };
|
||||||
|
else if (p === '/api/cctv/health')
|
||||||
|
body = {
|
||||||
|
cameras: cctv.map(({ id }) => ({
|
||||||
|
id,
|
||||||
|
status: 'online',
|
||||||
|
sourceKind: 'image',
|
||||||
|
updatedAt: COMBINED_EPOCH,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
else if (p.startsWith('/api/cctv/frame/')) {
|
||||||
|
body = frame;
|
||||||
|
type = 'image/png';
|
||||||
|
} else if (p.startsWith('/api/transit/'))
|
||||||
|
body = {
|
||||||
|
fetchedAt: COMBINED_EPOCH,
|
||||||
|
vehicles: [],
|
||||||
|
fixes: [],
|
||||||
|
epochs: [],
|
||||||
|
};
|
||||||
|
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/openai/hud-summary')
|
||||||
|
body = { summary: 'W2 performance fixture' };
|
||||||
|
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: type },
|
||||||
|
{ name: 'Access-Control-Allow-Origin', value: '*' },
|
||||||
|
{ name: 'Access-Control-Expose-Headers', value: 'Data-Present' },
|
||||||
|
{ name: 'Data-Present', value: 'true' },
|
||||||
|
],
|
||||||
|
body: (Buffer.isBuffer(body)
|
||||||
|
? 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,83 @@
|
|||||||
|
// USGS-shaped fixed observations; source parsers remain the production ones.
|
||||||
|
export const W2_EPOCH_MS = 1792152000000;
|
||||||
|
export function earthquakeFixture() {
|
||||||
|
const features = [];
|
||||||
|
for (const [region, lon, lat] of [
|
||||||
|
['sf', -122.42, 37.77],
|
||||||
|
['austin', -97.74, 30.27],
|
||||||
|
['alaska', -150.4, 61.0],
|
||||||
|
]) {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
id: `qa-${region}-${i}`,
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [
|
||||||
|
lon + (i - 2) * 0.24,
|
||||||
|
lat + (i % 2) * 0.3,
|
||||||
|
[8, 70, 300, 301, 45][i],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
mag: [2.5, 3, 4, 5, 5.5][i],
|
||||||
|
place: `W2 ${region} event ${i}`,
|
||||||
|
time: W2_EPOCH_MS - i * 60000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
id: 'qa-below',
|
||||||
|
geometry: { type: 'Point', coordinates: [-122.42, 37.77, 5] },
|
||||||
|
properties: { mag: 2.4 },
|
||||||
|
});
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
id: 'qa-missing-mag',
|
||||||
|
geometry: { type: 'Point', coordinates: [-122.4, 37.7, 5] },
|
||||||
|
properties: { mag: null },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
metadata: { generated: W2_EPOCH_MS },
|
||||||
|
features,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dense regional and worldwide USGS-shaped load, separate from the oracle. */
|
||||||
|
export function earthquakePerformanceFixture(count = 500) {
|
||||||
|
let seed = 17;
|
||||||
|
const random = () => {
|
||||||
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||||
|
return seed / 4294967296;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: Array.from({ length: count }, (_, i) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id: `perf-quake-${i}`,
|
||||||
|
properties: {
|
||||||
|
mag: 2.5 + (i % 7) * 0.5,
|
||||||
|
place: `Recorded performance event ${i}`,
|
||||||
|
time: W2_EPOCH_MS - i * 1000,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates:
|
||||||
|
i < Math.min(100, count)
|
||||||
|
? [
|
||||||
|
-122.42 + (random() - 0.5),
|
||||||
|
37.77 + (random() - 0.5),
|
||||||
|
[8, 70, 301][i % 3],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
-180 + random() * 360,
|
||||||
|
-70 + random() * 140,
|
||||||
|
[8, 70, 301][i % 3],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: 'w2-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: `w2-${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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+331
-8
@@ -57,6 +57,9 @@
|
|||||||
"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/app/viewer.js",
|
||||||
|
"src/app/zoomOutGuard.test.mjs",
|
||||||
|
"src/app/controls.test.mjs",
|
||||||
|
"scripts/qa-zoom-out-guard.mjs",
|
||||||
"src/app/atmosphereCompat.test.mjs",
|
"src/app/atmosphereCompat.test.mjs",
|
||||||
"src/standalone/application.js",
|
"src/standalone/application.js",
|
||||||
"src/standalone/controls.js",
|
"src/standalone/controls.js",
|
||||||
@@ -365,8 +368,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",
|
||||||
@@ -402,12 +407,22 @@
|
|||||||
"src/layers/perimeters/cards.test.mjs",
|
"src/layers/perimeters/cards.test.mjs",
|
||||||
"src/layers/perimeters/inciweb.test.mjs",
|
"src/layers/perimeters/inciweb.test.mjs",
|
||||||
"src/layers/perimeters/ownership.test.mjs",
|
"src/layers/perimeters/ownership.test.mjs",
|
||||||
|
"src/layers/droneAirspace/index.js",
|
||||||
|
"src/layers/droneAirspace/records.js",
|
||||||
|
"src/layers/droneAirspace/source.js",
|
||||||
|
"src/layers/droneAirspace/cards.js",
|
||||||
|
"src/layers/droneAirspace/records.test.mjs",
|
||||||
|
"src/layers/droneAirspace/source.test.mjs",
|
||||||
|
"src/layers/droneAirspace/cards.test.mjs",
|
||||||
|
"src/layers/droneAirspace/ownership.test.mjs",
|
||||||
|
"src/layers/droneAirspace/docs.test.mjs",
|
||||||
"src/data/firmsHeatmap.js",
|
"src/data/firmsHeatmap.js",
|
||||||
"src/data/fireAnchors.js",
|
"src/data/fireAnchors.js",
|
||||||
"src/layers/firms/anchors.js",
|
"src/layers/firms/anchors.js",
|
||||||
"src/layers/firms/cards.js",
|
"src/layers/firms/cards.js",
|
||||||
"src/layers/firms/index.js",
|
"src/layers/firms/index.js",
|
||||||
"src/layers/firms/ingestion.js",
|
"src/layers/firms/ingestion.js",
|
||||||
|
"src/layers/firms/heatVisibility.test.mjs",
|
||||||
"src/layers/firms/lifecycle.js",
|
"src/layers/firms/lifecycle.js",
|
||||||
"src/layers/firms/model.js",
|
"src/layers/firms/model.js",
|
||||||
"src/layers/firms/policy.js",
|
"src/layers/firms/policy.js",
|
||||||
@@ -461,17 +476,14 @@
|
|||||||
"src/layers/satellites/stale.test.mjs",
|
"src/layers/satellites/stale.test.mjs",
|
||||||
"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/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",
|
||||||
@@ -507,20 +519,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",
|
||||||
@@ -569,6 +578,7 @@
|
|||||||
"src/data/cctvVegvesen.test.mjs",
|
"src/data/cctvVegvesen.test.mjs",
|
||||||
"src/data/cctvA22.test.mjs",
|
"src/data/cctvA22.test.mjs",
|
||||||
"src/data/cctvNyc.test.mjs",
|
"src/data/cctvNyc.test.mjs",
|
||||||
|
"src/data/cctvHouston.test.mjs",
|
||||||
"src/data/cctvNswSource.test.mjs",
|
"src/data/cctvNswSource.test.mjs",
|
||||||
"src/data/cctvTxdotSource.test.mjs",
|
"src/data/cctvTxdotSource.test.mjs",
|
||||||
"src/layers/cctv/source.test.mjs",
|
"src/layers/cctv/source.test.mjs",
|
||||||
@@ -580,9 +590,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",
|
||||||
@@ -681,6 +691,7 @@
|
|||||||
"src/app/layers/radio.js",
|
"src/app/layers/radio.js",
|
||||||
"src/app/layers/earthquakes.js",
|
"src/app/layers/earthquakes.js",
|
||||||
"src/app/layers/perimeters.js",
|
"src/app/layers/perimeters.js",
|
||||||
|
"src/app/layers/droneAirspace.js",
|
||||||
"src/app/layers/cctv.js",
|
"src/app/layers/cctv.js",
|
||||||
"src/app/layers/firms.js",
|
"src/app/layers/firms.js",
|
||||||
"src/app/layers/satellites.js",
|
"src/app/layers/satellites.js",
|
||||||
@@ -866,6 +877,7 @@
|
|||||||
"src/layers/weather/imageryOrder.test.mjs",
|
"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",
|
||||||
"src/data/cyclonesProxy.test.mjs",
|
"src/data/cyclonesProxy.test.mjs",
|
||||||
"src/data/firePerimetersProxy.test.mjs",
|
"src/data/firePerimetersProxy.test.mjs",
|
||||||
"server/providers/gnssIntegrity.js",
|
"server/providers/gnssIntegrity.js",
|
||||||
@@ -890,6 +902,7 @@
|
|||||||
"src/layers/ghostMaps/records.test.mjs",
|
"src/layers/ghostMaps/records.test.mjs",
|
||||||
"src/layers/ghostMaps/lifecycle.test.mjs",
|
"src/layers/ghostMaps/lifecycle.test.mjs",
|
||||||
"scripts/qa-ghostmaps.mjs",
|
"scripts/qa-ghostmaps.mjs",
|
||||||
|
"src/data/droneAirspaceProxy.test.mjs",
|
||||||
"src/layers/cyclones/index.js",
|
"src/layers/cyclones/index.js",
|
||||||
"src/layers/cyclones/rendering.js",
|
"src/layers/cyclones/rendering.js",
|
||||||
"src/layers/cyclones/labels.js",
|
"src/layers/cyclones/labels.js",
|
||||||
@@ -1048,6 +1061,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",
|
||||||
"scripts/qa-journey-recorder.mjs",
|
"scripts/qa-journey-recorder.mjs",
|
||||||
"scripts/qa-alpr-journey.mjs",
|
"scripts/qa-alpr-journey.mjs",
|
||||||
"scripts/qa-context-arrivals.mjs",
|
"scripts/qa-context-arrivals.mjs",
|
||||||
@@ -1102,6 +1116,7 @@
|
|||||||
"scripts/qa-anime-sky.mjs",
|
"scripts/qa-anime-sky.mjs",
|
||||||
"scripts/qa-sky-model-lighting.mjs",
|
"scripts/qa-sky-model-lighting.mjs",
|
||||||
"server/providers/overpass/requestAdmission.test.mjs",
|
"server/providers/overpass/requestAdmission.test.mjs",
|
||||||
|
"src/tooling/trackArrivalObservation.mjs",
|
||||||
"src/tooling/trackArrivalObservation.test.mjs",
|
"src/tooling/trackArrivalObservation.test.mjs",
|
||||||
"src/ui/styles/status-badge.css",
|
"src/ui/styles/status-badge.css",
|
||||||
"src/ui/cctvBadges.test.mjs",
|
"src/ui/cctvBadges.test.mjs",
|
||||||
@@ -1111,6 +1126,9 @@
|
|||||||
"src/voice/globeResetConfirmation.test.mjs",
|
"src/voice/globeResetConfirmation.test.mjs",
|
||||||
"src/hudDms.test.mjs",
|
"src/hudDms.test.mjs",
|
||||||
"src/hudSummaryGate.test.mjs",
|
"src/hudSummaryGate.test.mjs",
|
||||||
|
"src/hudSummaryLine.test.mjs",
|
||||||
|
"src/testSupport/stubMgrs.mjs",
|
||||||
|
"src/tools/queries/hud-caption-feed-state.test.mjs",
|
||||||
"src/voice/hudPlaceLabels.test.mjs",
|
"src/voice/hudPlaceLabels.test.mjs",
|
||||||
"src/voice/panel/preferences.test.mjs",
|
"src/voice/panel/preferences.test.mjs",
|
||||||
"src/voice/panel/preferencesView.test.mjs",
|
"src/voice/panel/preferencesView.test.mjs",
|
||||||
@@ -1177,6 +1195,9 @@
|
|||||||
"src/overlays/worldOverlayActionsView.test.mjs",
|
"src/overlays/worldOverlayActionsView.test.mjs",
|
||||||
"src/ui/stores/awarenessDirection.js",
|
"src/ui/stores/awarenessDirection.js",
|
||||||
"src/layers/awareness/directionOverlay.test.mjs",
|
"src/layers/awareness/directionOverlay.test.mjs",
|
||||||
|
"src/awarenessEcho.js",
|
||||||
|
"src/awarenessEcho.test.mjs",
|
||||||
|
"src/data/detectionAwarenessEcho.test.mjs",
|
||||||
"src/hudReadouts.js",
|
"src/hudReadouts.js",
|
||||||
"src/ui/stores/hud.js",
|
"src/ui/stores/hud.js",
|
||||||
"src/ui/views/intelHudView.test.mjs",
|
"src/ui/views/intelHudView.test.mjs",
|
||||||
@@ -1187,6 +1208,272 @@
|
|||||||
"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/cesium.js",
|
||||||
|
"src/layers/flights/render/navara.js",
|
||||||
|
"src/layers/aircraft/render/navara.test.mjs",
|
||||||
|
"src/layers/aircraft/render/navara.js",
|
||||||
|
"src/layers/flights/renderer.test.mjs",
|
||||||
|
"src/layers/military/renderer.test.mjs",
|
||||||
|
"src/layers/military/policy.test.mjs",
|
||||||
|
"src/testSupport/aircraftRecordingRenderer.mjs",
|
||||||
|
"src/layers/aircraft/render/cesium.test.mjs",
|
||||||
|
"src/layers/aircraft/render/cesium.js",
|
||||||
|
"src/layers/aircraft/render/contract.js",
|
||||||
|
"src/layers/shared/color.test.mjs",
|
||||||
|
"src/layers/shared/color.js",
|
||||||
|
"src/layers/shared/viewRectangle.js",
|
||||||
|
"src/layers/military/render/cesium.js",
|
||||||
|
"src/layers/military/render/navara.js",
|
||||||
|
"src/layers/aircraft/qaDrawn.js",
|
||||||
|
"src/app/navaraLayers.test.mjs",
|
||||||
|
"src/layers/vessels/icons.js",
|
||||||
|
"src/layers/vessels/render/cesium.js",
|
||||||
|
"src/layers/vessels/render/contract.js",
|
||||||
|
"src/layers/vessels/renderer.test.mjs",
|
||||||
|
"src/layers/vessels/testFixtures.mjs",
|
||||||
|
"src/layers/vessels/render/navara.js",
|
||||||
|
"src/layers/shared/render/engineView.js",
|
||||||
|
"src/layers/shared/render/navaraGroundEffect.js",
|
||||||
|
"src/layers/shared/render/navaraMoverBank.js",
|
||||||
|
"src/layers/shared/render/imageAtlas.js",
|
||||||
|
"src/layers/installations/render/cesium/lifecycle.js",
|
||||||
|
"src/layers/installations/render/cesium/rendering.js",
|
||||||
|
"src/layers/installations/render/cesium/selection.js",
|
||||||
|
"src/layers/bikeshare/render/cesium/lifecycle.js",
|
||||||
|
"src/layers/bikeshare/presentation.js",
|
||||||
|
"src/layers/bikeshare/stationPoints.js",
|
||||||
|
"src/layers/bikeshare/presentation.test.mjs",
|
||||||
|
"src/layers/bikeshare/selectionModel.js",
|
||||||
|
"src/layers/bikeshare/render/cesium.js",
|
||||||
|
"src/layers/bikeshare/render/cesium/viewport.js",
|
||||||
|
"src/layers/bikeshare/render/cesium/lifecycle.test.mjs",
|
||||||
|
"src/layers/bikeshare/render/navara.js",
|
||||||
|
"src/layers/bikeshare/render/navara.test.mjs",
|
||||||
|
"src/layers/bikeshare/render/cesium/rendering.js",
|
||||||
|
"src/layers/bikeshare/render/cesium/selection.js",
|
||||||
|
"src/layers/cctv/projectionScheduling.test.mjs",
|
||||||
|
"src/layers/cctv/gizmo.test.mjs",
|
||||||
|
"src/layers/cctv/render/cesiumGizmo.js",
|
||||||
|
"src/layers/cctv/render/navara.js",
|
||||||
|
"src/layers/cctv/render/navara.test.mjs",
|
||||||
|
"src/layers/cctv/render/navaraMeshes.js",
|
||||||
|
"src/layers/cctv/render/navaraMeshes.test.mjs",
|
||||||
|
"src/layers/cctv/render/navaraGizmo.js",
|
||||||
|
"src/layers/cctv/render/navaraGizmo.test.mjs",
|
||||||
|
"src/layers/cctv/render/navaraLine.js",
|
||||||
|
"src/layers/cctv/render/cesium.js",
|
||||||
|
"src/layers/cctv/render/cesiumViewshed.js",
|
||||||
|
"src/layers/cctv/render/cesium.test.mjs",
|
||||||
|
"src/layers/cctv/lifecycle.test.mjs",
|
||||||
|
"src/layers/cctv/projectionMedia.test.mjs",
|
||||||
|
"src/layers/cctv/ingestion.test.mjs",
|
||||||
|
"src/layers/alpr/render/cesiumPresentation.js",
|
||||||
|
"src/layers/alpr/render/cesium.js",
|
||||||
|
"src/layers/alpr/render/navara.js",
|
||||||
|
"src/layers/alpr/render/navara.test.mjs",
|
||||||
|
"src/layers/alpr/render/navaraOverlay.js",
|
||||||
|
"src/layers/alpr/render/navaraOverlay.test.mjs",
|
||||||
|
"scripts/fixtures/w2/alpr.mjs",
|
||||||
|
"scripts/fixtures/w2/cctv.mjs",
|
||||||
|
"src/layers/alpr/render/cesiumOverlay.js",
|
||||||
|
"src/layers/radio/render/cesium.js",
|
||||||
|
"src/layers/radio/render/navara.js",
|
||||||
|
"src/layers/radio/render/navara.test.mjs",
|
||||||
|
"src/layers/radio/screenClusters.js",
|
||||||
|
"src/layers/radio/screenClusters.test.mjs",
|
||||||
|
"src/maps/sceneApply.js",
|
||||||
|
"src/maps/sceneApply.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/engineFromViewer.js",
|
||||||
|
"src/globe/cesium/engineFromViewer.test.mjs",
|
||||||
|
"src/globe/cesium/testing/fakeViewer.mjs",
|
||||||
|
"src/globe/cesium/testing/cameraViewer.mjs",
|
||||||
|
"src/app/globeEngine.js",
|
||||||
|
"src/app/globeEngine.test.mjs",
|
||||||
|
"src/layers/transit/render/cesium.js",
|
||||||
|
"src/layers/transit/render/cesium.test.mjs",
|
||||||
|
"src/layers/transit/render/navara.js",
|
||||||
|
"src/layers/transit/render/navaraView.js",
|
||||||
|
"src/layers/transit/render/navaraSprites.js",
|
||||||
|
"src/layers/transit/render/navaraTrail.js",
|
||||||
|
"src/layers/transit/render/navara.test.mjs",
|
||||||
|
"scripts/fixtures/w2/transit.mjs",
|
||||||
|
"src/layers/localAdsb/render/cesium.js",
|
||||||
|
"src/layers/localAdsb/render/navara.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/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/layers/satellites/render/cesium.js",
|
||||||
|
"src/celestialRing/render/cesium.js",
|
||||||
|
"src/celestialRing/view.js",
|
||||||
|
"src/layers/satellites/render/navara.js",
|
||||||
|
"src/layers/satellites/render/navara.test.mjs",
|
||||||
|
"src/layers/satellites/render/cesium.test.mjs",
|
||||||
|
"src/geo/ellipsoidGeodesic.test.mjs",
|
||||||
|
"src/bench/launchFixture.js",
|
||||||
|
"src/layers/launches/replayClock.test.mjs",
|
||||||
|
"src/geo/boundingSphere.test.mjs",
|
||||||
|
"src/layers/launches/render/cesium.js",
|
||||||
|
"src/layers/launches/launchPadFrame.test.mjs",
|
||||||
|
"src/layers/launches/render/navara.js",
|
||||||
|
"src/layers/launches/render/navara.test.mjs",
|
||||||
|
"src/layers/satellites/interaction.test.mjs",
|
||||||
|
"src/layers/launches/lifecycleSnapshot.test.mjs",
|
||||||
|
"src/data/render/cesiumProjection.js",
|
||||||
|
"src/data/render/cesiumTrailRenderer.js",
|
||||||
|
"src/data/render/cesiumContactTrailRenderer.js",
|
||||||
|
"src/layers/flights/trackingRefresh.test.mjs",
|
||||||
|
"scripts/qa-heading-navara.mjs",
|
||||||
|
"src/layers/aircraft/qaDrawn.test.mjs",
|
||||||
|
"src/data/render/cesiumConstants.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/w1RuntimeEvidence.test.mjs",
|
||||||
|
"scripts/fixtures/w2/earthquakes.mjs",
|
||||||
|
"src/layers/earthquakes/render/cesium.js",
|
||||||
|
"src/layers/earthquakes/render/cesium.test.mjs",
|
||||||
|
"src/layers/earthquakes/render/navara.js",
|
||||||
|
"src/layers/earthquakes/render/navaraGeometry.js",
|
||||||
|
"src/layers/earthquakes/render/navaraGeometry.test.mjs",
|
||||||
|
"src/layers/earthquakes/render/navaraGrid.js",
|
||||||
|
"src/layers/earthquakes/render/navaraGrid.test.mjs",
|
||||||
|
"src/layers/earthquakes/render/navara.test.mjs",
|
||||||
|
"src/layers/firms/render/cesium.js",
|
||||||
|
"src/layers/firms/render/cesium.test.mjs",
|
||||||
|
"src/layers/firms/rectangle.js",
|
||||||
|
"src/layers/firms/rectangle.test.mjs",
|
||||||
|
"src/layers/firms/viewport.test.mjs",
|
||||||
|
"scripts/fixtures/w2/firms.mjs",
|
||||||
|
"src/layers/firms/render/navara.test.mjs",
|
||||||
|
"src/layers/firms/render/navaraGeometry.test.mjs",
|
||||||
|
"src/layers/firms/render/navaraSprites.test.mjs",
|
||||||
|
"src/layers/firms/lifecycle.test.mjs",
|
||||||
|
"scripts/fixtures/w2/radio.mjs",
|
||||||
|
"scripts/qa-map-policy.mjs",
|
||||||
|
"scripts/qa-map-policy.test.mjs",
|
||||||
|
"src/layers/recentImagery/render/tiles.test.mjs",
|
||||||
|
"src/layers/recentImagery/render/navara.test.mjs",
|
||||||
|
"src/ui/imageryBoxNavara.test.mjs",
|
||||||
|
"scripts/qa-w2-imagery.mjs",
|
||||||
|
"src/globe/navara/postSamplingPass.test.mjs",
|
||||||
|
"src/globe/navara/emptySceneRender.test.mjs",
|
||||||
|
"src/layers/installations/render/navaraStencilBatch.test.mjs",
|
||||||
|
"src/data/firmsIntensity.js",
|
||||||
|
"src/data/firmsIntensity.test.mjs",
|
||||||
|
"src/ui/layerPanel/RowSwitch.vue",
|
||||||
|
"src/ui/layerPanel/RowSlider.vue",
|
||||||
"src/sources/phaseTiming.test.mjs",
|
"src/sources/phaseTiming.test.mjs",
|
||||||
"scripts/build-census-places.mjs",
|
"scripts/build-census-places.mjs",
|
||||||
"src/data/placeBoundaries.test.mjs",
|
"src/data/placeBoundaries.test.mjs",
|
||||||
@@ -1203,5 +1490,41 @@
|
|||||||
"src/standalone/layerSources.test.mjs",
|
"src/standalone/layerSources.test.mjs",
|
||||||
"src/tooling/nominatimGuardLifecycle.test.mjs",
|
"src/tooling/nominatimGuardLifecycle.test.mjs",
|
||||||
"src/tooling/nominatimLockOwnership.test.mjs",
|
"src/tooling/nominatimLockOwnership.test.mjs",
|
||||||
"src/tooling/nominatimOutlineRoute.test.mjs"
|
"src/tooling/nominatimOutlineRoute.test.mjs",
|
||||||
|
"server/providers/common/rateLimitKeys.test.mjs",
|
||||||
|
"src/voice/areaCandidateContract.js",
|
||||||
|
"src/voice/layerActionOwnership.js",
|
||||||
|
"src/data/areaGeometry.test.mjs",
|
||||||
|
"src/data/areaResolver.test.mjs",
|
||||||
|
"src/sources/osmFeatures.test.mjs",
|
||||||
|
"src/layers/osmPlaces/index.test.mjs",
|
||||||
|
"src/tooling/boundedCache.test.mjs",
|
||||||
|
"src/tooling/osmFeaturesRoute.test.mjs",
|
||||||
|
"src/voice/areaActions.test.mjs",
|
||||||
|
"src/voice/imageryActions.test.mjs",
|
||||||
|
"src/voice/osmActions.test.mjs",
|
||||||
|
"src/voice/realtimeProtocol.test.mjs",
|
||||||
|
"src/layers/awareness/rangeRing.test.mjs",
|
||||||
|
"src/voice/voiceMiniPreview.js",
|
||||||
|
"src/voice/voiceMiniPreview.test.mjs",
|
||||||
|
"src/voice/chatPreferences.test.mjs",
|
||||||
|
"src/voice/textChat.test.mjs",
|
||||||
|
"src/voice/chatAttachments.test.mjs",
|
||||||
|
"src/voice/chatControlView.test.mjs",
|
||||||
|
"src/textChatServer.test.mjs",
|
||||||
|
"src/geminiChatServer.test.mjs",
|
||||||
|
"src/voice/geminiTextChat.test.mjs",
|
||||||
|
"server/providers/common/textChat.js",
|
||||||
|
"server/providers/common/textInstructions.js",
|
||||||
|
"server/providers/common/textTools.js",
|
||||||
|
"server/providers/gemini/chat.js",
|
||||||
|
"server/providers/openai/chat.js",
|
||||||
|
"src/ui/styles/ai-chat.css",
|
||||||
|
"src/voice/chatAttachments.js",
|
||||||
|
"src/voice/chatControl.js",
|
||||||
|
"src/voice/chatPreferences.js",
|
||||||
|
"src/voice/geminiTextChat.js",
|
||||||
|
"src/voice/panelLayout.js",
|
||||||
|
"src/voice/textChat.js",
|
||||||
|
"src/voice/textProtocol.js"
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"gzipBytes": 2021566,
|
||||||
|
"allowanceBytes": 20216,
|
||||||
|
"measuredAt": "merge of origin/main ac28a11e, keyless build (GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN empty), entry index-ChmLBAMR.js, 6 220 779 bytes, gzip level 9; main itself grew 93 873 bytes gzip-9 from the merge base (1 853 283 to 1 947 156)",
|
||||||
|
"allowance": "1% of the baseline; growth past it fails npm run check:main-chunk until the baseline is raised on purpose"
|
||||||
|
}
|
||||||
@@ -15,9 +15,13 @@ 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 import() specifiers apart.
|
||||||
|
*/
|
||||||
|
export function analyzeModule(source, { dynamic = false } = {}) {
|
||||||
const imports = [];
|
const imports = [];
|
||||||
|
const dynamicImports = [];
|
||||||
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 +39,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')
|
||||||
|
dynamicImports.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');
|
||||||
@@ -71,7 +77,11 @@ export function analyzeModule(source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(parsers.babel.parse(source));
|
walk(parsers.babel.parse(source));
|
||||||
return { imports, browser: [...browser] };
|
return {
|
||||||
|
imports,
|
||||||
|
browser: [...browser],
|
||||||
|
...(dynamic && { dynamicImports }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return static imports, literal dynamic imports and re-exports. */
|
/** Return static imports, literal dynamic imports and re-exports. */
|
||||||
|
|||||||
+662
-78
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,8 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([
|
|||||||
'LL2_API_TOKEN',
|
'LL2_API_TOKEN',
|
||||||
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
||||||
'GEV_RATELIMIT_GOOGLE_PER_MIN',
|
'GEV_RATELIMIT_GOOGLE_PER_MIN',
|
||||||
|
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
|
||||||
|
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
|
||||||
'PINOKIO_SHARE_CLOUDFLARE',
|
'PINOKIO_SHARE_CLOUDFLARE',
|
||||||
'PINOKIO_SHARE_LOCAL',
|
'PINOKIO_SHARE_LOCAL',
|
||||||
'PINOKIO_SHARE_VAR',
|
'PINOKIO_SHARE_VAR',
|
||||||
|
|||||||
@@ -0,0 +1,612 @@
|
|||||||
|
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js';
|
||||||
|
import {
|
||||||
|
createStrict,
|
||||||
|
ENGINES,
|
||||||
|
localPreview,
|
||||||
|
waitForEngine,
|
||||||
|
} from './qa-strict-harness.mjs';
|
||||||
|
|
||||||
|
// The strict acceptance: every browser scenario against one keyless local
|
||||||
|
// preview, under the strict network policy (scripts/qa-strict-harness.mjs),
|
||||||
|
// writing <output>/acceptance.json, one log and one assertion manifest per
|
||||||
|
// scenario.
|
||||||
|
//
|
||||||
|
// node scripts/qa-all-layer-switches.mjs http://127.0.0.1:4208 <output>
|
||||||
|
// [--serve[=<commit>]] build <commit> (default HEAD) keyless in a clean
|
||||||
|
// worktree and serve it with every .env key blank
|
||||||
|
// [--scratch=DIR] where --serve puts its checkout and build
|
||||||
|
// [--only=a,b] run only these labels
|
||||||
|
// [--quick] the soak with 2 runs of 10 switches and 100
|
||||||
|
// switches for the long run
|
||||||
|
//
|
||||||
|
// The run fails when a scenario fails, when any catalog layer (read from
|
||||||
|
// the live page and cross-checked with src/app/layers/directory.js) has no
|
||||||
|
// assertion on either engine, or when any network policy violation shows.
|
||||||
|
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
const output = process.argv[3]?.startsWith('--')
|
||||||
|
? mkdtempSync(join(tmpdir(), 'gev-acceptance-'))
|
||||||
|
: resolve(process.argv[3] ?? mkdtempSync(join(tmpdir(), 'gev-acceptance-')));
|
||||||
|
const flag = (name) =>
|
||||||
|
process.argv
|
||||||
|
.find((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`))
|
||||||
|
?.split('=')[1] ?? (process.argv.includes(`--${name}`) ? true : null);
|
||||||
|
const only = flag('only') ? String(flag('only')).split(',') : null;
|
||||||
|
const quick = Boolean(flag('quick'));
|
||||||
|
mkdirSync(join(output, 'evidence'), { recursive: true });
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const git = (...args) =>
|
||||||
|
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||||
|
const dirty = () =>
|
||||||
|
git('status', '--porcelain')
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => line.slice(3));
|
||||||
|
|
||||||
|
// Fail once, with the fix, instead of once per scenario.
|
||||||
|
let chrome;
|
||||||
|
try {
|
||||||
|
const browser = await puppeteer.launch({ headless: true });
|
||||||
|
chrome = await browser.version();
|
||||||
|
await browser.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Puppeteer cannot start Chrome: ' +
|
||||||
|
String(error?.message)
|
||||||
|
.split('\n')[0]
|
||||||
|
.replace(/ This can occur.*/, '') +
|
||||||
|
'\nPoint PUPPETEER_EXECUTABLE_PATH at an installed Chrome, for example\n' +
|
||||||
|
' PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n' +
|
||||||
|
'or download the pinned build with `npx puppeteer browsers install chrome`.',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every name the checkout's .env files define, plus key-like names in this
|
||||||
|
// environment: all blank for the build and the preview.
|
||||||
|
const KEY_LIKE = /(_KEY|_TOKEN|_SECRET|_CLIENT_ID|PASSWORD)$/;
|
||||||
|
/** The [name, value] pairs of the checkout's .env and .env.local, in order. */
|
||||||
|
const dotenv = () =>
|
||||||
|
['.env', '.env.local']
|
||||||
|
.filter((file) => existsSync(join(root, file)))
|
||||||
|
.flatMap((file) => readFileSync(join(root, file), 'utf8').split('\n'))
|
||||||
|
.map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)/))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(([, name, raw]) => [name, raw.trim().replace(/^["']|["']$/g, '')]);
|
||||||
|
const dotenvNames = () => [...new Set(dotenv().map(([name]) => name))].sort();
|
||||||
|
const envNames = () =>
|
||||||
|
[
|
||||||
|
...new Set([
|
||||||
|
...dotenvNames(),
|
||||||
|
...Object.keys(process.env).filter((name) => KEY_LIKE.test(name)),
|
||||||
|
]),
|
||||||
|
].sort();
|
||||||
|
// Credentials only (model names and URLs are settings the code may also
|
||||||
|
// carry as defaults).
|
||||||
|
const envValues = () =>
|
||||||
|
dotenv()
|
||||||
|
.filter(([name, value]) => KEY_LIKE.test(name) && value.length >= 8)
|
||||||
|
.map(([, value]) => value);
|
||||||
|
const scrubbed = () => ({
|
||||||
|
...process.env,
|
||||||
|
...Object.fromEntries(envNames().map((name) => [name, ''])),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The report names no local directory: the output, the served checkout and
|
||||||
|
// this repository become placeholders (logs carry stack traces).
|
||||||
|
const machinePaths = (text) => {
|
||||||
|
let out = String(text);
|
||||||
|
for (const [path, name] of [
|
||||||
|
[served?.scratch, '<scratch>'],
|
||||||
|
[output, '<output>'],
|
||||||
|
[root.replace(/\/$/, ''), '<repo>'],
|
||||||
|
])
|
||||||
|
if (path) out = out.split(path).join(name);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let served = null;
|
||||||
|
let preview = null;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (preview && !preview.killed) preview.kill('SIGTERM');
|
||||||
|
if (served?.worktree)
|
||||||
|
try {
|
||||||
|
git('worktree', 'remove', '--force', served.worktree);
|
||||||
|
served.worktree = null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not remove', served.worktree, error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.on('exit', cleanup);
|
||||||
|
process.on('SIGINT', () => process.exit(130));
|
||||||
|
|
||||||
|
if (flag('serve')) {
|
||||||
|
const commit = git(
|
||||||
|
'rev-parse',
|
||||||
|
flag('serve') === true ? 'HEAD' : String(flag('serve')),
|
||||||
|
).trim();
|
||||||
|
// --scratch=DIR keeps the checkout and build out of the system temp.
|
||||||
|
const scratchRoot = flag('scratch')
|
||||||
|
? resolve(String(flag('scratch')))
|
||||||
|
: tmpdir();
|
||||||
|
mkdirSync(scratchRoot, { recursive: true });
|
||||||
|
const scratch = mkdtempSync(join(scratchRoot, 'gev-acceptance-build-'));
|
||||||
|
const worktree = join(scratch, 'checkout');
|
||||||
|
const dist = join(scratch, 'dist');
|
||||||
|
git('worktree', 'add', '--detach', worktree, commit);
|
||||||
|
symlinkSync(join(root, 'node_modules'), join(worktree, 'node_modules'));
|
||||||
|
served = {
|
||||||
|
scratch,
|
||||||
|
commit,
|
||||||
|
worktree,
|
||||||
|
dist,
|
||||||
|
// Names from .env files only; other key-like variables of this shell
|
||||||
|
// are blanked too but only counted.
|
||||||
|
blankedEnv: dotenvNames(),
|
||||||
|
otherKeyLikeVariablesBlanked: envNames().filter(
|
||||||
|
(name) => !dotenvNames().includes(name),
|
||||||
|
).length,
|
||||||
|
envFilesInCheckout: ['.env', '.env.local'].filter((f) =>
|
||||||
|
existsSync(join(worktree, f)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
console.log('Building', commit, 'keyless into', dist);
|
||||||
|
const build = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[join(root, 'node_modules/vite/bin/vite.js'), 'build', '--outDir', dist],
|
||||||
|
{ cwd: worktree, env: scrubbed(), encoding: 'utf8', maxBuffer: 1 << 26 },
|
||||||
|
);
|
||||||
|
if (build.status !== 0) {
|
||||||
|
console.error(build.stdout + build.stderr);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
// No .env credential value may reach the built files.
|
||||||
|
const values = envValues();
|
||||||
|
let leaks = 0;
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const file = join(dir, name);
|
||||||
|
if (statSync(file).isDirectory()) walk(file);
|
||||||
|
else if (/\.(js|html|json|css|webmanifest)$/.test(name)) {
|
||||||
|
const text = readFileSync(file, 'utf8');
|
||||||
|
for (const value of values) if (text.includes(value)) leaks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(dist);
|
||||||
|
served.distCredentialValuesFound = leaks;
|
||||||
|
served.mainChunk = JSON.parse(
|
||||||
|
spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[join(root, 'scripts/check-main-chunk.mjs'), `--dist=${dist}`, '--json'],
|
||||||
|
{ cwd: root, encoding: 'utf8' },
|
||||||
|
).stdout || 'null',
|
||||||
|
);
|
||||||
|
preview = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
join(root, 'node_modules/vite/bin/vite.js'),
|
||||||
|
'preview',
|
||||||
|
'--outDir',
|
||||||
|
dist,
|
||||||
|
'--host',
|
||||||
|
base.hostname,
|
||||||
|
'--port',
|
||||||
|
base.port,
|
||||||
|
'--strictPort',
|
||||||
|
],
|
||||||
|
{ cwd: worktree, env: scrubbed(), stdio: 'ignore' },
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
try {
|
||||||
|
if ((await fetch(base.origin + '/')).ok) break;
|
||||||
|
} catch {
|
||||||
|
// Not up yet.
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(base.origin + '/', {
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw Error('HTTP ' + response.status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'No preview answers at ' + base.origin + ' (' + error.message + ').',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The preview reports no paid key: its own keyless status routes.
|
||||||
|
const probe = async (path) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(base.origin + path, {
|
||||||
|
headers: { 'Sec-Fetch-Site': 'same-origin' },
|
||||||
|
});
|
||||||
|
return { status: r.status, body: await r.json().catch(() => null) };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const keys = {
|
||||||
|
openaiHudSummary: await probe('/api/openai/hud-summary/status'),
|
||||||
|
firms: await probe('/api/firms/status'),
|
||||||
|
tomtom: await probe('/api/tomtom/status'),
|
||||||
|
mapillary: await probe('/api/mapillary/status'),
|
||||||
|
};
|
||||||
|
const paidKeysConfigured = [
|
||||||
|
keys.openaiHudSummary.body?.configured !== false && 'OpenAI',
|
||||||
|
keys.firms.body?.hasKey !== false && 'FIRMS',
|
||||||
|
keys.tomtom.body?.hasKey !== false && 'TomTom',
|
||||||
|
keys.mapillary.body?.configured !== false && 'Mapillary',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const dirtyFiles = dirty();
|
||||||
|
const soakRuns = quick
|
||||||
|
? ['--runs=2', '--swaps=10']
|
||||||
|
: ['--runs=6', '--swaps=50'];
|
||||||
|
const longRun = quick ? '--swaps=100' : '--swaps=500';
|
||||||
|
// label, script, layers, extra arguments, timeout in seconds.
|
||||||
|
const runs = [
|
||||||
|
['engine-switch', 'engine-switch', ['earthquakes']],
|
||||||
|
['director-engine-switch', 'director-engine-switch', []],
|
||||||
|
['layer-handoff', 'layer-handoff', ['flights']],
|
||||||
|
['layer-handoff--military', 'layer-handoff', ['military'], ['--military']],
|
||||||
|
[
|
||||||
|
'ground-layer-switch',
|
||||||
|
'ground-layer-switch',
|
||||||
|
['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'contact-switch',
|
||||||
|
'contact-switch',
|
||||||
|
['military-awareness', 'military-installations'],
|
||||||
|
],
|
||||||
|
['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']],
|
||||||
|
['ghostmaps-switch', 'ghostmaps-switch', ['s2-ghostmaps']],
|
||||||
|
[
|
||||||
|
'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']],
|
||||||
|
['w2-imagery', 'w2-imagery', ['recent-imagery']],
|
||||||
|
['fire-camera-switch', 'fire-camera-switch', ['alpr-cameras']],
|
||||||
|
[
|
||||||
|
'fire-camera-switch--fires',
|
||||||
|
'fire-camera-switch',
|
||||||
|
['local-firms'],
|
||||||
|
['--fires'],
|
||||||
|
],
|
||||||
|
['urban-engine-switch', 'urban-engine-switch', ['bikeshare']],
|
||||||
|
[
|
||||||
|
'urban-engine-switch--transit',
|
||||||
|
'urban-engine-switch',
|
||||||
|
['transit'],
|
||||||
|
['--transit'],
|
||||||
|
],
|
||||||
|
['cctv-engine-switch', 'cctv-engine-switch', ['cctv']],
|
||||||
|
['space-engine-switch', 'space-engine-switch', ['satellites']],
|
||||||
|
[
|
||||||
|
'space-engine-switch--missions',
|
||||||
|
'space-engine-switch',
|
||||||
|
['rocket-launches'],
|
||||||
|
['--missions'],
|
||||||
|
],
|
||||||
|
// One application per page, and voice across a switch: these block paid
|
||||||
|
// routes themselves but do not run under the strict harness, so they add
|
||||||
|
// no assertion manifest and no network ledger.
|
||||||
|
['app-once', 'app-once', [], [], 600, true],
|
||||||
|
['voice-engine-switch', 'voice-engine-switch', [], [], 300, true],
|
||||||
|
['strict-rapid-switch', 'strict-rapid-switch', []],
|
||||||
|
['strict-hidden-switch', 'strict-hidden-switch', []],
|
||||||
|
['strict-context-loss', 'strict-context-loss', []],
|
||||||
|
['strict-failure-matrix', 'strict-failure-matrix', [], [], 600],
|
||||||
|
['strict-cold-chunks', 'strict-cold-chunks', []],
|
||||||
|
['strict-prod-smoke', 'strict-prod-smoke', []],
|
||||||
|
// The app's visual styles, bloom, contact sonar, cockpit clouds, and the
|
||||||
|
// annotations, Draw, Import and the scope mask with the celestial ring on
|
||||||
|
// Navara (those are tools and display overlays, not catalog layers:
|
||||||
|
// TOOL_IDS).
|
||||||
|
['navara-visual-styles', 'navara-visual-styles', []],
|
||||||
|
['navara-bloom', 'navara-bloom', []],
|
||||||
|
['navara-sonar', 'navara-sonar', []],
|
||||||
|
['navara-cockpit-clouds', 'navara-cockpit-clouds', []],
|
||||||
|
['navara-annotations', 'navara-annotations', ['annotations']],
|
||||||
|
['navara-draw', 'navara-draw', ['draw']],
|
||||||
|
['navara-geo-import', 'navara-geo-import', ['geo-import']],
|
||||||
|
['navara-scope', 'navara-scope', ['scope-mask', 'celestial-ring']],
|
||||||
|
['strict-soak', 'strict-soak', [], soakRuns, 1200],
|
||||||
|
[
|
||||||
|
'strict-soak--long',
|
||||||
|
'strict-soak',
|
||||||
|
[],
|
||||||
|
['--runs=1', longRun, '--assert'],
|
||||||
|
1800,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
// Ids a scenario covers that are tools, not catalog layers: their
|
||||||
|
// assertions are recorded, but the catalog comparison leaves them out.
|
||||||
|
const TOOL_IDS = new Set([
|
||||||
|
'annotations',
|
||||||
|
'draw',
|
||||||
|
'geo-import',
|
||||||
|
'scope-mask',
|
||||||
|
'celestial-ring',
|
||||||
|
]);
|
||||||
|
const scenarioIds = [
|
||||||
|
...new Set(
|
||||||
|
runs.flatMap(([, , ids]) => ids).filter((id) => !TOOL_IDS.has(id)),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
const directoryIds = APPLICATION_LAYER_DIRECTORY.map((l) => l.id).sort();
|
||||||
|
|
||||||
|
// The catalog as the live page has it, on both engines.
|
||||||
|
const liveCatalog = {};
|
||||||
|
{
|
||||||
|
const strict = createStrict({
|
||||||
|
origin: base.origin,
|
||||||
|
layers: [],
|
||||||
|
name: 'catalog',
|
||||||
|
});
|
||||||
|
const browser = await strict.launch();
|
||||||
|
try {
|
||||||
|
for (const engine of ENGINES) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto(
|
||||||
|
`${base.origin}/?engine=${engine}&welcome=0#v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=osm&l=`,
|
||||||
|
);
|
||||||
|
await waitForEngine(page, engine);
|
||||||
|
liveCatalog[engine] = await page.evaluate(() =>
|
||||||
|
[...window.__godsEyeView.dataManager.layers.keys()].sort(),
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
liveCatalog.violations = strict.ledger.violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarios = [];
|
||||||
|
const report = {
|
||||||
|
commit: git('rev-parse', 'HEAD').trim(),
|
||||||
|
dirty: dirtyFiles.length > 0,
|
||||||
|
dirtyFiles,
|
||||||
|
dirtyFilesAtEnd: null,
|
||||||
|
chrome,
|
||||||
|
puppeteerExecutable: process.env.PUPPETEER_EXECUTABLE_PATH || null,
|
||||||
|
node: process.version,
|
||||||
|
platform: process.platform + ' ' + process.arch,
|
||||||
|
baseUrl: base.origin,
|
||||||
|
preview: served
|
||||||
|
? {
|
||||||
|
servedByRunner: true,
|
||||||
|
appCommit: served.commit,
|
||||||
|
appCheckoutClean: true,
|
||||||
|
scenariosFrom: 'the same clean checkout',
|
||||||
|
envFilesInCheckout: served.envFilesInCheckout,
|
||||||
|
blankedEnv: served.blankedEnv,
|
||||||
|
otherKeyLikeVariablesBlanked: served.otherKeyLikeVariablesBlanked,
|
||||||
|
distCredentialValuesFound: served.distCredentialValuesFound,
|
||||||
|
}
|
||||||
|
: { servedByRunner: false },
|
||||||
|
keyProbes: keys,
|
||||||
|
paidKeysConfigured,
|
||||||
|
mainChunk: served?.mainChunk ?? null,
|
||||||
|
networkPolicy: {
|
||||||
|
blockedFirst:
|
||||||
|
'Google (*.googleapis.com, *.google.com, maps.google.*, *.gstatic.com), Cesium ion (*.cesium.com), preview /api/openai/*, /api/realtime/*, /api/google/*',
|
||||||
|
allowed:
|
||||||
|
'the preview origin, each scenario’s declared fixture URLs, keyless probes answered by the harness, and the free tile hosts answered from the disk cache',
|
||||||
|
keylessProbes: (await import('./qa-map-policy.mjs')).KEYLESS_PROBES.map(
|
||||||
|
(p) => p.pattern,
|
||||||
|
),
|
||||||
|
tileHosts: (await import('./qa-map-policy.mjs')).STRICT_TILE_HOSTS,
|
||||||
|
},
|
||||||
|
catalog: {
|
||||||
|
directory: directoryIds,
|
||||||
|
live: liveCatalog,
|
||||||
|
scenarios: scenarioIds,
|
||||||
|
},
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: null,
|
||||||
|
passed: 0,
|
||||||
|
failed: 0,
|
||||||
|
networkViolations: 0,
|
||||||
|
coverage: null,
|
||||||
|
uncovered: null,
|
||||||
|
scenarios,
|
||||||
|
};
|
||||||
|
const save = () =>
|
||||||
|
writeFileSync(
|
||||||
|
join(output, 'acceptance.json'),
|
||||||
|
machinePaths(JSON.stringify(report, null, 2)) + '\n',
|
||||||
|
);
|
||||||
|
save();
|
||||||
|
|
||||||
|
for (const [label, name, layers, extra = [], seconds = 300, external] of runs) {
|
||||||
|
if (only && !only.includes(label)) continue;
|
||||||
|
const script = 'scripts/qa-' + name + '.mjs';
|
||||||
|
const evidence = join(output, 'evidence', label + '.json');
|
||||||
|
rmSync(evidence, { force: true });
|
||||||
|
const args = [base.origin, ...extra];
|
||||||
|
if (external) args.splice(0, 1, base.origin + '/');
|
||||||
|
else args.push(`--layers=${layers.join(',')}`, `--evidence=${evidence}`);
|
||||||
|
const start = Date.now();
|
||||||
|
// With --serve the scenarios run from the same clean checkout the app was
|
||||||
|
// built from, so other work in this tree cannot change them.
|
||||||
|
const scripts = served?.worktree ?? root;
|
||||||
|
const run = spawnSync(process.execPath, [join(scripts, script), ...args], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: seconds * 1000,
|
||||||
|
maxBuffer: 1 << 26,
|
||||||
|
env: scrubbed(),
|
||||||
|
});
|
||||||
|
const status =
|
||||||
|
run.status === 0
|
||||||
|
? 'pass'
|
||||||
|
: run.error?.code === 'ETIMEDOUT'
|
||||||
|
? 'timeout'
|
||||||
|
: 'fail';
|
||||||
|
const log = machinePaths(
|
||||||
|
(run.stdout || '') + (run.stderr || '') + (run.error?.message || ''),
|
||||||
|
);
|
||||||
|
writeFileSync(join(output, label + '.log'), log);
|
||||||
|
const manifest = existsSync(evidence)
|
||||||
|
? JSON.parse(machinePaths(readFileSync(evidence, 'utf8')))
|
||||||
|
: null;
|
||||||
|
if (manifest)
|
||||||
|
writeFileSync(evidence, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
scenarios.push({
|
||||||
|
label,
|
||||||
|
script,
|
||||||
|
args: args.map((arg) =>
|
||||||
|
arg.includes(output) ? arg.replace(output, '<output>') : arg,
|
||||||
|
),
|
||||||
|
layers,
|
||||||
|
external: Boolean(external),
|
||||||
|
status,
|
||||||
|
exitCode: run.status,
|
||||||
|
seconds: Math.round((Date.now() - start) / 100) / 10,
|
||||||
|
log: label + '.log',
|
||||||
|
logSha256: createHash('sha256').update(log).digest('hex'),
|
||||||
|
evidence: manifest ? 'evidence/' + label + '.json' : null,
|
||||||
|
assertions: manifest?.assertions.length ?? 0,
|
||||||
|
coverage: manifest?.coverage ?? null,
|
||||||
|
picks: manifest?.picks ?? [],
|
||||||
|
network: manifest
|
||||||
|
? {
|
||||||
|
requests: manifest.network.requests,
|
||||||
|
violations: manifest.network.violations,
|
||||||
|
external: manifest.network.external,
|
||||||
|
tileCache: manifest.network.tileCache,
|
||||||
|
probes: manifest.network.probes,
|
||||||
|
routes: manifest.network.routes,
|
||||||
|
previewApi: manifest.network.previewApi,
|
||||||
|
fixtures: manifest.network.fixtures,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
contracts: manifest?.contracts ?? null,
|
||||||
|
error: manifest?.error ?? null,
|
||||||
|
});
|
||||||
|
if (manifest?.soak) scenarios.at(-1).soak = manifest.soak;
|
||||||
|
report.passed = scenarios.filter((row) => row.status === 'pass').length;
|
||||||
|
report.failed = scenarios.length - report.passed;
|
||||||
|
report.networkViolations =
|
||||||
|
scenarios.reduce(
|
||||||
|
(sum, row) => sum + (row.network?.violations.length ?? 0),
|
||||||
|
0,
|
||||||
|
) + liveCatalog.violations.length;
|
||||||
|
save();
|
||||||
|
console.log(status.toUpperCase(), label, scenarios.at(-1).seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage that cannot be faked: every catalog id needs an assertion on each
|
||||||
|
// engine from some scenario's manifest.
|
||||||
|
const catalogIds = [
|
||||||
|
...new Set([...directoryIds, ...(liveCatalog.navara ?? [])]),
|
||||||
|
].sort();
|
||||||
|
report.coverage = Object.fromEntries(
|
||||||
|
catalogIds.map((id) => [
|
||||||
|
id,
|
||||||
|
Object.fromEntries(
|
||||||
|
ENGINES.map((engine) => [
|
||||||
|
engine,
|
||||||
|
scenarios.reduce(
|
||||||
|
(sum, row) => sum + (row.coverage?.[id]?.[engine] ?? 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
report.uncovered = Object.entries(report.coverage).flatMap(([id, counts]) =>
|
||||||
|
ENGINES.filter((engine) => !counts[engine]).map(
|
||||||
|
(engine) => `${id}@${engine}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
report.catalogMatches =
|
||||||
|
JSON.stringify(liveCatalog.navara) === JSON.stringify(directoryIds) &&
|
||||||
|
JSON.stringify(liveCatalog.cesium) === JSON.stringify(directoryIds) &&
|
||||||
|
JSON.stringify(scenarioIds) === JSON.stringify(directoryIds);
|
||||||
|
report.finishedAt = new Date().toISOString();
|
||||||
|
report.dirtyFilesAtEnd = dirty();
|
||||||
|
save();
|
||||||
|
console.log(
|
||||||
|
`${catalogIds.length} catalog layers, ${report.uncovered.length} engine slots without an assertion, ` +
|
||||||
|
`${report.networkViolations} network violations, ${report.passed}/${scenarios.length} scenarios, ` +
|
||||||
|
`paid keys configured: ${paidKeysConfigured.join(', ') || 'none'}. ${join(output, 'acceptance.json')}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
report.failed ||
|
||||||
|
(!only && report.uncovered.length) ||
|
||||||
|
report.networkViolations ||
|
||||||
|
paidKeysConfigured.length ||
|
||||||
|
!report.catalogMatches
|
||||||
|
)
|
||||||
|
process.exitCode = 1;
|
||||||
|
// The preview child would keep this process alive: stop it and drop the
|
||||||
|
// checkout now.
|
||||||
|
cleanup();
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* 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 +14,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
|
||||||
@@ -39,7 +39,10 @@
|
|||||||
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 puppeteer from 'puppeteer';
|
||||||
|
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { installAlprFixture } from './fixtures/w2/alpr.mjs';
|
||||||
|
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
|
||||||
import {
|
import {
|
||||||
createScreencast,
|
createScreencast,
|
||||||
frameStats,
|
frameStats,
|
||||||
@@ -60,12 +63,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) => {
|
||||||
@@ -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,14 +192,44 @@ 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 });
|
||||||
@@ -252,6 +295,8 @@ try {
|
|||||||
await page
|
await page
|
||||||
.waitForFunction(
|
.waitForFunction(
|
||||||
() => {
|
() => {
|
||||||
|
if (window.__godsEyeView.engine.kind === 'navara')
|
||||||
|
return window.__godsEyeView.engine.surface.tilesLoaded();
|
||||||
const { scene } = window.__godsEyeView.viewer;
|
const { scene } = window.__godsEyeView.viewer;
|
||||||
if (scene.globe.show) return scene.globe.tilesLoaded;
|
if (scene.globe.show) return scene.globe.tilesLoaded;
|
||||||
for (let i = 0; i < scene.primitives.length; i++) {
|
for (let i = 0; i < scene.primitives.length; i++) {
|
||||||
@@ -308,8 +353,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 +385,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));
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
// 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,
|
||||||
|
previewBase,
|
||||||
|
readSession,
|
||||||
|
requestEngine,
|
||||||
|
sessionAddress,
|
||||||
|
switchEngine,
|
||||||
|
waitReady,
|
||||||
|
} from './qa/appSessionHarness.mjs';
|
||||||
|
|
||||||
|
const base = previewBase(process.argv[2]);
|
||||||
|
const reportOnly = process.argv.includes('--report');
|
||||||
|
const report = { switches: [], failures: [] };
|
||||||
|
const check = (condition, message, detail) => {
|
||||||
|
if (condition) return;
|
||||||
|
report.failures.push({ message, detail });
|
||||||
|
if (!reportOnly)
|
||||||
|
assert.fail(`${message}${detail ? ` ${JSON.stringify(detail)}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD = '#location-search';
|
||||||
|
const PANEL = 'data-panel';
|
||||||
|
|
||||||
|
// The page's visible UI state the switches must leave alone.
|
||||||
|
const readUi = (page) =>
|
||||||
|
page.evaluate(
|
||||||
|
(field, panel) => ({
|
||||||
|
value: document.querySelector(field)?.value ?? null,
|
||||||
|
focused: document.activeElement?.id || null,
|
||||||
|
panelOpen: !document
|
||||||
|
.getElementById(panel)
|
||||||
|
?.classList.contains('collapsed'),
|
||||||
|
}),
|
||||||
|
FIELD,
|
||||||
|
PANEL,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Focus the field, press Escape and read what changed; then put the state
|
||||||
|
// back. The same press before and after the switches must have the same
|
||||||
|
// effect.
|
||||||
|
async function escapeEffect(page) {
|
||||||
|
await page.focus(FIELD);
|
||||||
|
const before = await readUi(page);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||||
|
const after = await readUi(page);
|
||||||
|
return { before, after };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareUi(page) {
|
||||||
|
await page.evaluate((panel) => {
|
||||||
|
const style = window.__godsEyeView.styleManager;
|
||||||
|
style.setPanelCollapsed(panel, false, { explicit: true });
|
||||||
|
style.setPanelCollapsed('location-bar', false, { explicit: true });
|
||||||
|
}, PANEL);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(field) => document.querySelector(field)?.offsetParent !== null,
|
||||||
|
{},
|
||||||
|
FIELD,
|
||||||
|
);
|
||||||
|
await page.evaluate((field) => {
|
||||||
|
const input = document.querySelector(field);
|
||||||
|
input.focus();
|
||||||
|
input.value = 'Reykjav';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}, FIELD);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session's tools: a mark on the annotation board, an imported file and
|
||||||
|
// the Draw shape, set on Cesium before the switches.
|
||||||
|
async function prepareTools(page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const annotated = await window.__gevAnnotations.annotate([
|
||||||
|
{ type: 'pin', latitude: 42.3554, longitude: -71.0605, label: 'QA mark' },
|
||||||
|
]);
|
||||||
|
const file = new File(
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { name: 'QA area' },
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[-71.07, 42.35],
|
||||||
|
[-71.05, 42.35],
|
||||||
|
[-71.05, 42.36],
|
||||||
|
[-71.07, 42.35],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'qa-area.geojson',
|
||||||
|
{ type: 'application/geo+json' },
|
||||||
|
);
|
||||||
|
const imported = await window.__gevGeoImport.importFiles([file]);
|
||||||
|
window.__gevDrawTool.setShape('line');
|
||||||
|
return { annotated: annotated.drawn, imported: imported[0]?.ok };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const readTools = (page) =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
marks: window.__gevAnnotations?.count() ?? null,
|
||||||
|
marksDrawn: window.__gevAnnotations?.attached ?? null,
|
||||||
|
files: window.__gevGeoImport?.list().length ?? null,
|
||||||
|
filesDrawn: window.__gevGeoImport?.diagnostics().drawn ?? null,
|
||||||
|
drawShape: window.__gevDrawTool?.shape ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Listeners whose difference is the parked renderer's: the Navara engine
|
||||||
|
// keeps its own input, resize and attribution listeners while it is parked
|
||||||
|
// for a switch back (scripts that are not the session's).
|
||||||
|
const PARKED_RENDERER = /@navara-engine\.js$/;
|
||||||
|
|
||||||
|
const browser = await launch();
|
||||||
|
try {
|
||||||
|
const { page, errors } = await openSessionPage(
|
||||||
|
browser,
|
||||||
|
sessionAddress(base, { engine: 'cesium' }),
|
||||||
|
);
|
||||||
|
await waitReady(page, 'cesium');
|
||||||
|
// Let the first boot's deferred listeners land before counting.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
const boot = await readSession(page);
|
||||||
|
const bootListeners = await listenerCounts(page, { bySource: true });
|
||||||
|
report.boot = boot;
|
||||||
|
report.toolsPrepared = await prepareTools(page);
|
||||||
|
check(
|
||||||
|
report.toolsPrepared.annotated === 1 && report.toolsPrepared.imported,
|
||||||
|
'the tools could not be prepared',
|
||||||
|
report.toolsPrepared,
|
||||||
|
);
|
||||||
|
// Let the imported file finish drawing.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||||
|
);
|
||||||
|
const toolsBefore = await readTools(page);
|
||||||
|
report.toolsBefore = toolsBefore;
|
||||||
|
|
||||||
|
await prepareUi(page);
|
||||||
|
const escapeBefore = await escapeEffect(page);
|
||||||
|
report.escapeBefore = escapeBefore;
|
||||||
|
await prepareUi(page);
|
||||||
|
const uiBefore = await readUi(page);
|
||||||
|
report.uiBefore = uiBefore;
|
||||||
|
|
||||||
|
const sequence = ['navara', 'cesium', 'navara', 'cesium', 'navara', 'cesium'];
|
||||||
|
// Dev guard: once an engine is parked, nothing may move its camera or
|
||||||
|
// ask it to draw until it is attached again (its own resume aside).
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaParkedCalls = [];
|
||||||
|
window.__qaWatchParked = (engine) => {
|
||||||
|
if (!engine || engine.__qaWatched) return;
|
||||||
|
engine.__qaWatched = true;
|
||||||
|
const watch = (owner, names, label) => {
|
||||||
|
for (const name of names) {
|
||||||
|
const original = owner[name];
|
||||||
|
if (typeof original !== 'function') continue;
|
||||||
|
owner[name] = function (...args) {
|
||||||
|
const parked = window.__godsEyeView?.engine !== engine;
|
||||||
|
const resume = name === 'request' && args[0] === 'engine-resume';
|
||||||
|
if (parked && !resume && !window.__qaSwitching)
|
||||||
|
window.__qaParkedCalls.push({
|
||||||
|
call: `${engine.kind}.${label}.${name}`,
|
||||||
|
reason: typeof args[0] === 'string' ? args[0] : null,
|
||||||
|
stack: new Error().stack.split('\n').slice(2, 6).join(' | '),
|
||||||
|
});
|
||||||
|
return original.apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
engine.camera,
|
||||||
|
['setView', 'flyTo', 'lookAt', 'flyToBoundingSphere', 'release'],
|
||||||
|
'camera',
|
||||||
|
);
|
||||||
|
watch(engine.render, ['request', 'setContinuous'], 'render');
|
||||||
|
};
|
||||||
|
window.__qaWatchParked(window.__godsEyeView.engine);
|
||||||
|
});
|
||||||
|
for (const kind of sequence) {
|
||||||
|
const started = Date.now();
|
||||||
|
// The switch itself parks and resumes engines; only the time an engine
|
||||||
|
// sits parked is watched.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaSwitching = true;
|
||||||
|
});
|
||||||
|
await switchEngine(page, kind);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaSwitching = false;
|
||||||
|
window.__qaWatchParked(window.__godsEyeView.engine);
|
||||||
|
});
|
||||||
|
const state = await readSession(page);
|
||||||
|
const ui = await readUi(page);
|
||||||
|
if (kind === 'navara')
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaParkedNavara = window.__godsEyeView.engine;
|
||||||
|
});
|
||||||
|
if (report.switches.length === 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
report.firstRoundTrip = await listenerCounts(page, { bySource: true });
|
||||||
|
}
|
||||||
|
// Both engines draw the session's marks and files (Navara included).
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
const tools = await readTools(page);
|
||||||
|
check(
|
||||||
|
tools.marks === toolsBefore.marks &&
|
||||||
|
tools.files === toolsBefore.files &&
|
||||||
|
tools.drawShape === toolsBefore.drawShape &&
|
||||||
|
tools.marksDrawn === true &&
|
||||||
|
tools.filesDrawn === 1,
|
||||||
|
`the session tools changed on the switch to ${kind}`,
|
||||||
|
{ toolsBefore, tools },
|
||||||
|
);
|
||||||
|
report.switches.push({
|
||||||
|
kind,
|
||||||
|
ms: Date.now() - started,
|
||||||
|
ids: idDifference(boot.ids, state.ids),
|
||||||
|
counters: state.counters,
|
||||||
|
ui,
|
||||||
|
tools,
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
|
||||||
|
`session ids changed on the switch to ${kind}`,
|
||||||
|
idDifference(boot.ids, state.ids),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
ui.value === uiBefore.value,
|
||||||
|
`typed text lost on the switch to ${kind}`,
|
||||||
|
ui,
|
||||||
|
);
|
||||||
|
check(ui.panelOpen, `open panel closed on the switch to ${kind}`, ui);
|
||||||
|
check(
|
||||||
|
ui.focused === uiBefore.focused,
|
||||||
|
`focus moved on the switch to ${kind}`,
|
||||||
|
ui,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
const after = await readSession(page);
|
||||||
|
const afterListeners = await listenerCounts(page, { bySource: true });
|
||||||
|
report.after = after;
|
||||||
|
// After three round trips the page holds exactly the listeners it held
|
||||||
|
// after the first boot, apart from the parked Navara renderer's own (it
|
||||||
|
// stays alive for the switch back), which the first round trip added and
|
||||||
|
// the later ones did not grow.
|
||||||
|
report.listeners = listenerDifference(bootListeners, afterListeners);
|
||||||
|
report.listenersSinceFirstRoundTrip = listenerDifference(
|
||||||
|
report.firstRoundTrip,
|
||||||
|
afterListeners,
|
||||||
|
);
|
||||||
|
const sessionListeners = Object.keys(report.listeners).filter(
|
||||||
|
(key) => !PARKED_RENDERER.test(key),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
sessionListeners.length === 0,
|
||||||
|
'session listeners on window/document changed after three round trips',
|
||||||
|
report.listeners,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
|
||||||
|
'listeners on window/document grew after the first round trip',
|
||||||
|
report.listenersSinceFirstRoundTrip,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
after.counters?.attachments === 1 + sequence.length &&
|
||||||
|
after.counters?.detaches === sequence.length,
|
||||||
|
'each switch detaches once and attaches once',
|
||||||
|
after.counters,
|
||||||
|
);
|
||||||
|
report.parkedEngineCalls = await page.evaluate(() => window.__qaParkedCalls);
|
||||||
|
check(
|
||||||
|
report.parkedEngineCalls.length === 0,
|
||||||
|
'a parked engine had its camera moved or a frame requested',
|
||||||
|
report.parkedEngineCalls,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
after.counters?.applications === 1,
|
||||||
|
'the application must be created once',
|
||||||
|
after.counters,
|
||||||
|
);
|
||||||
|
|
||||||
|
const escapeAfter = await escapeEffect(page);
|
||||||
|
report.escapeAfter = escapeAfter;
|
||||||
|
check(
|
||||||
|
JSON.stringify(escapeAfter) === JSON.stringify(escapeBefore),
|
||||||
|
'Escape behaves differently after the switches',
|
||||||
|
{ escapeBefore, escapeAfter },
|
||||||
|
);
|
||||||
|
|
||||||
|
// A failed candidate: the parked Navara renderer refuses to resume (as in
|
||||||
|
// qa-layer-handoff.mjs). The working Cesium attaches again on the same
|
||||||
|
// session objects.
|
||||||
|
await prepareUi(page);
|
||||||
|
const beforeRollback = await readSession(page);
|
||||||
|
const historyLength = await page.evaluate(() => history.length);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaRollback = false;
|
||||||
|
const parked = window.__qaParkedNavara;
|
||||||
|
const request = parked.render.request;
|
||||||
|
parked.render.request = function (...args) {
|
||||||
|
parked.render.request = request;
|
||||||
|
window.__qaRollback = true;
|
||||||
|
throw new Error('QA parked renderer failure');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const sabotaged = true;
|
||||||
|
if (sabotaged) {
|
||||||
|
await requestEngine(page, 'navara');
|
||||||
|
await page.waitForFunction(() => window.__qaRollback === true, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await waitReady(page, 'cesium');
|
||||||
|
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 waitReady(page, 'cesium');
|
||||||
|
const afterDataFailure = await readSession(page);
|
||||||
|
report.dataFailure = {
|
||||||
|
ids: idDifference(boot.ids, afterDataFailure.ids),
|
||||||
|
counters: afterDataFailure.counters,
|
||||||
|
history: (await page.evaluate(() => history.length)) - beforeDataFailure,
|
||||||
|
};
|
||||||
|
check(
|
||||||
|
Object.keys(report.dataFailure.ids).length === 0 &&
|
||||||
|
report.dataFailure.history === 0,
|
||||||
|
'a candidate failing in the data part changed the session',
|
||||||
|
report.dataFailure,
|
||||||
|
);
|
||||||
|
await switchEngine(page, 'navara');
|
||||||
|
await switchEngine(page, 'cesium');
|
||||||
|
check(
|
||||||
|
Object.keys(idDifference(boot.ids, (await readSession(page)).ids))
|
||||||
|
.length === 0,
|
||||||
|
'ids changed after the data-part failure',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
report.errors = errors;
|
||||||
|
check(errors.length === 0, 'page errors', errors);
|
||||||
|
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
if (!reportOnly) console.log('PASS application created once; session kept');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -23,6 +23,70 @@ 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 watchW1Page(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)
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { installCctvFixture } from './fixtures/w2/cctv.mjs';
|
||||||
|
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||||
|
const url = localPreview(process.argv[2]);
|
||||||
|
url.search = '?engine=navara&welcome=0';
|
||||||
|
url.hash =
|
||||||
|
'v=2&lat=30.2718&lon=-97.744&alt=900&pitch=-70&heading=0&map=sentinel2&l=';
|
||||||
|
const { stdout: video } = await promisify(execFile)(
|
||||||
|
'ffmpeg',
|
||||||
|
[
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
'testsrc2=size=320x180:rate=12',
|
||||||
|
'-t',
|
||||||
|
'60',
|
||||||
|
'-an',
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-pix_fmt',
|
||||||
|
'yuv420p',
|
||||||
|
'-preset',
|
||||||
|
'ultrafast',
|
||||||
|
'-threads',
|
||||||
|
'1',
|
||||||
|
'-movflags',
|
||||||
|
'frag_keyframe+empty_moov',
|
||||||
|
'-f',
|
||||||
|
'mp4',
|
||||||
|
'pipe:1',
|
||||||
|
],
|
||||||
|
{ encoding: 'buffer', maxBuffer: 16000000 },
|
||||||
|
);
|
||||||
|
const strict = createStrict({ origin: url.origin, layers: ['cctv'] });
|
||||||
|
await strict.run({}, async (b) => {
|
||||||
|
const p = await b.newPage();
|
||||||
|
const errors = [];
|
||||||
|
p.on('pageerror', (e) => {
|
||||||
|
errors.push(e.message);
|
||||||
|
console.log('error', e.message);
|
||||||
|
});
|
||||||
|
await installCctvFixture(p, { video });
|
||||||
|
let catalogs = 0;
|
||||||
|
p.on('request', (req) => {
|
||||||
|
if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++;
|
||||||
|
});
|
||||||
|
await p.goto(url.href);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||||
|
document.querySelector('#loading-screen.hidden'),
|
||||||
|
);
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||||
|
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||||
|
throw Error('Expected keyless preview');
|
||||||
|
await styleManager.initialRestorePromise;
|
||||||
|
await dataManager.setEnabled('cctv', true, { origin: 'user' });
|
||||||
|
window.qaCctv = dataManager.layers.get('cctv').module;
|
||||||
|
window.qaCctv.setParams({
|
||||||
|
selectedCameraId: 'w2-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 === 'w2-cctv-video').projection?.planeEntity,
|
||||||
|
projection: (() => {
|
||||||
|
const r = window.qaCctv
|
||||||
|
._cctvRecordsForTest()
|
||||||
|
.find((r) => r.camera.id === 'w2-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, 'w2-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');
|
||||||
|
});
|
||||||
+222
-64
@@ -40,12 +40,10 @@
|
|||||||
* issuing zero transient samples, without touching the save-gated store;
|
* issuing zero transient samples, without touching the save-gated store;
|
||||||
* camera-controller inputs restore and parts hide on mode off.
|
* camera-controller inputs restore and parts hide on mode off.
|
||||||
*
|
*
|
||||||
* SwiftShader caveat (proven in Task 3): under headless software GL, plane
|
* This legacy harness is the geometry, lifecycle and real-input oracle.
|
||||||
* image materials do not visually FILL — screenshotting for a pixel-based
|
* Migration runs use headless Metal and strict runtime error observation.
|
||||||
* pass/fail is not viable here. All four assertion groups therefore check
|
* Screenshots remain visual-review evidence here; a separate pixel gate must
|
||||||
* entity/counter/localStorage/network STATE (GL-independent), never rendered
|
* independently prove actual marker, image-plane and video pixels.
|
||||||
* pixels. Screenshots are still captured for human visual review but do not
|
|
||||||
* gate the exit code.
|
|
||||||
*
|
*
|
||||||
* No `cesium` package import for in-page math: the app does not expose a
|
* No `cesium` package import for in-page math: the app does not expose a
|
||||||
* `window.Cesium` global, so every in-page evaluate() either (a) reads plain
|
* `window.Cesium` global, so every in-page evaluate() either (a) reads plain
|
||||||
@@ -71,12 +69,17 @@
|
|||||||
* so the coincide check is deterministic whichever camera activates, but it
|
* so the coincide check is deterministic whichever camera activates, but it
|
||||||
* is no longer needed to avoid a plane/wireframe disagreement.
|
* is no longer needed to avoid a plane/wireframe disagreement.
|
||||||
*
|
*
|
||||||
* Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173
|
* Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview]
|
||||||
|
* Routine keyless builds also pass --terrain-globe: assert zero scene floor
|
||||||
|
* samples instead of claiming mesh-floor coverage on a globe-only map.
|
||||||
*
|
*
|
||||||
* Exit 0 = all asserts passed. Non-zero = a hard failure.
|
* Exit 0 = all asserts passed. Non-zero = a hard failure.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
|
||||||
|
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||||
|
import { installCctvFixture } from './fixtures/w2/cctv.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';
|
||||||
@@ -95,12 +98,33 @@ const getOpt = (name, dflt) => {
|
|||||||
};
|
};
|
||||||
const getFlag = (name) => argv.includes(name);
|
const getFlag = (name) => argv.includes(name);
|
||||||
|
|
||||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
const explicitUrl = getOpt('--url', process.env.QA_BASE_URL);
|
||||||
const CCTV_URL = new URL(getOpt('--url', BASE_URL));
|
const output = getOpt('--out');
|
||||||
|
if (!explicitUrl || !output)
|
||||||
|
throw new Error('Explicit --url and --out are required');
|
||||||
|
const mapPolicy = { allowPaidMaps: getFlag('--allow-paid-maps') };
|
||||||
|
const terrainGlobe = getFlag('--terrain-globe');
|
||||||
|
if (terrainGlobe && mapPolicy.allowPaidMaps)
|
||||||
|
throw new Error('Terrain-globe QA must keep paid maps blocked');
|
||||||
|
const CCTV_URL = new URL(explicitUrl);
|
||||||
|
if (
|
||||||
|
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||||
|
CCTV_URL.port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('CCTV QA may not use a reserved port');
|
||||||
|
if (getFlag('--headful')) throw new Error('CCTV migration QA is headless only');
|
||||||
CCTV_URL.searchParams.set('welcome', '0');
|
CCTV_URL.searchParams.set('welcome', '0');
|
||||||
const APP_URL = CCTV_URL.href;
|
const APP_URL = CCTV_URL.href;
|
||||||
const HEADFUL = getFlag('--headful');
|
const SHOTS_DIR = path.resolve(output);
|
||||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
|
const evidence = { checks: [], browserErrors: [], pass: false };
|
||||||
|
const saveEvidence = () => {
|
||||||
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(SHOTS_DIR, 'result.json'),
|
||||||
|
JSON.stringify(evidence, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||||
@@ -127,8 +151,9 @@ function findChromeExecutable() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = evidence.checks;
|
||||||
function record(name, ok, detail) {
|
function record(name, ok, detail) {
|
||||||
|
detail = redact(detail);
|
||||||
results.push({ name, ok, detail });
|
results.push({ name, ok, detail });
|
||||||
const tag =
|
const tag =
|
||||||
ok === null
|
ok === null
|
||||||
@@ -425,24 +450,24 @@ function findSafeCalibrationPatch(basePose, groundAltM) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`\nCCTV v2 Subsystem Proof`);
|
console.log(`\nCCTV v2 Subsystem Proof`);
|
||||||
console.log(` App URL : ${APP_URL}`);
|
console.log(` App URL : ${redact(APP_URL)}`);
|
||||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
console.log(' Mode : headless Metal\n');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL, { method: 'GET' });
|
const res = await fetch(APP_URL, { method: 'GET' });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
|
redact(`Dev server not reachable: ${e.message}`),
|
||||||
);
|
);
|
||||||
process.exit(2);
|
throw new Error('CCTV prerequisite unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
|
||||||
const chromeExecutable = findChromeExecutable();
|
const chromeExecutable = findChromeExecutable();
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
headless: HEADFUL ? false : 'new',
|
headless: true,
|
||||||
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
||||||
// Talk CDP over a pipe, so no debugging port stays open on localhost for
|
// Talk CDP over a pipe, so no debugging port stays open on localhost for
|
||||||
// the length of a headful run.
|
// the length of a headful run.
|
||||||
@@ -451,32 +476,46 @@ async function main() {
|
|||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-setuid-sandbox',
|
'--disable-setuid-sandbox',
|
||||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
'--use-angle=metal',
|
||||||
|
'--enable-gpu',
|
||||||
'--disable-dev-shm-usage',
|
'--disable-dev-shm-usage',
|
||||||
'--disable-web-security',
|
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
'--disable-renderer-backgrounding',
|
'--disable-renderer-backgrounding',
|
||||||
'--window-size=1280,800',
|
'--window-size=1280,800',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const consoleErrors = [];
|
const consoleErrors = evidence.browserErrors;
|
||||||
let exitCode = 0;
|
let exitCode = 0;
|
||||||
|
let fixture = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
await blockPaidQaMaps(page, mapPolicy);
|
||||||
|
const terrainRequests = new Set();
|
||||||
|
let terrainActivityAt = 0;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (new URL(request.url()).pathname !== '/api/terrain/heights') return;
|
||||||
|
terrainRequests.add(request);
|
||||||
|
terrainActivityAt = Date.now();
|
||||||
|
});
|
||||||
|
for (const event of ['requestfinished', 'requestfailed'])
|
||||||
|
page.on(event, (request) => {
|
||||||
|
if (terrainRequests.delete(request)) terrainActivityAt = Date.now();
|
||||||
|
});
|
||||||
await page.setViewport({ width: 1280, height: 800 });
|
await page.setViewport({ width: 1280, height: 800 });
|
||||||
page.on('console', (msg) => {
|
watchW1Page(page, evidence, {
|
||||||
if (msg.type() === 'error') {
|
name: 'cctv-legacy',
|
||||||
const t = msg.text();
|
baseUrl: APP_URL,
|
||||||
if (
|
preview: getFlag('--preview'),
|
||||||
!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(
|
});
|
||||||
t,
|
fixture = getFlag('--fixture')
|
||||||
)
|
? await installCctvFixture(page)
|
||||||
) {
|
: null;
|
||||||
consoleErrors.push(t);
|
await page.evaluateOnNewDocument(() => {
|
||||||
}
|
localStorage.clear();
|
||||||
}
|
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||||
|
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
|
||||||
});
|
});
|
||||||
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 });
|
||||||
@@ -490,8 +529,35 @@ async function main() {
|
|||||||
),
|
),
|
||||||
{ timeoutMs: 60000 },
|
{ timeoutMs: 60000 },
|
||||||
);
|
);
|
||||||
if (!appReady) throw new Error('app did not initialise within 60s');
|
await page.evaluate(async () => {
|
||||||
await installRenderFramesHelper(page);
|
const app = window.__godsEyeView;
|
||||||
|
await app.styleManager.initialRestorePromise;
|
||||||
|
app.styleManager.orbitController.stop();
|
||||||
|
for (const [id, layer] of app.dataManager.layers)
|
||||||
|
if (layer.enabled) await app.dataManager.setEnabled(id, false);
|
||||||
|
app.styleManager._setDetectionMode('OFF');
|
||||||
|
});
|
||||||
|
evidence.surface = await page.evaluate(() => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
map: app.mapStackController.getActiveId(),
|
||||||
|
globeVisible: app.viewer.scene.globe.show,
|
||||||
|
tileset: Boolean(app.tileset),
|
||||||
|
googleKeyPresent: Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||||||
|
photorealAvailable:
|
||||||
|
app.mapStackController.isStackAvailable('photoreal'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
evidence.surfaceMode = terrainGlobe ? 'terrain-globe' : 'mesh-floor';
|
||||||
|
if (terrainGlobe) {
|
||||||
|
const keyless =
|
||||||
|
evidence.surface.globeVisible === true &&
|
||||||
|
!evidence.surface.tileset &&
|
||||||
|
!evidence.surface.googleKeyPresent &&
|
||||||
|
!evidence.surface.photorealAvailable;
|
||||||
|
record('keyless terrain-globe startup', keyless);
|
||||||
|
if (!keyless) throw new Error('Expected a keyless globe before CCTV QA');
|
||||||
|
}
|
||||||
// Let the initial fly-to Austin and first tiles settle.
|
// Let the initial fly-to Austin and first tiles settle.
|
||||||
await sleep(4000);
|
await sleep(4000);
|
||||||
|
|
||||||
@@ -529,7 +595,7 @@ async function main() {
|
|||||||
console.error(
|
console.error(
|
||||||
'\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m',
|
'\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m',
|
||||||
);
|
);
|
||||||
process.exit(2);
|
throw new Error('CCTV prerequisite unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const readCounters = () =>
|
const readCounters = () =>
|
||||||
@@ -557,9 +623,20 @@ async function main() {
|
|||||||
// ~800ms per real ground sample measured under SwiftShader (each
|
// ~800ms per real ground sample measured under SwiftShader (each
|
||||||
// scene.sampleHeight forces tile loads at the probe point) — real GPU is
|
// scene.sampleHeight forces tile loads at the probe point) — real GPU is
|
||||||
// far faster, so this is a headless-CI ceiling, not an app expectation.
|
// far faster, so this is a headless-CI ceiling, not an app expectation.
|
||||||
const drainBudgetMs = HEADFUL
|
evidence.cameraIds = await page.evaluate(() =>
|
||||||
? Math.max(30000, camCount * 800 + 30000)
|
window.__godsEyeView.dataManager.layers
|
||||||
: Math.min(120000, Math.max(30000, camCount * 800 + 30000));
|
.get('cctv')
|
||||||
|
.module.getUIState()
|
||||||
|
.cameras.map(({ id }) => id)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
|
if (fixture)
|
||||||
|
record(
|
||||||
|
'real catalog parser retains every fixture camera ID',
|
||||||
|
JSON.stringify(evidence.cameraIds) ===
|
||||||
|
JSON.stringify(fixture.cameras.map(({ id }) => id).sort()),
|
||||||
|
);
|
||||||
|
const drainBudgetMs = Math.max(30000, camCount * 800 + 30000);
|
||||||
console.log(
|
console.log(
|
||||||
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
||||||
);
|
);
|
||||||
@@ -574,12 +651,10 @@ async function main() {
|
|||||||
);
|
);
|
||||||
record(
|
record(
|
||||||
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
||||||
drained ? true : HEADFUL ? false : null,
|
drained,
|
||||||
drained
|
drained
|
||||||
? 'loading.active === false'
|
? 'loading.active === false'
|
||||||
: HEADFUL
|
: 'timed out waiting for the real-GPU geometry drain',
|
||||||
? 'timed out waiting for drain on the real-GPU sign-off path'
|
|
||||||
: 'headless GL geometry sampling did not settle; current headful evidence is required',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const cAfterDrain = await readCounters();
|
const cAfterDrain = await readCounters();
|
||||||
@@ -617,6 +692,7 @@ async function main() {
|
|||||||
.activeCameraId,
|
.activeCameraId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
evidence.activeCameraId = activeId;
|
||||||
const pickDeltaActivation =
|
const pickDeltaActivation =
|
||||||
cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay;
|
cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay;
|
||||||
record(
|
record(
|
||||||
@@ -660,9 +736,32 @@ async function main() {
|
|||||||
// (existence, not just ceiling). The wait below tolerates the update-tick
|
// (existence, not just ceiling). The wait below tolerates the update-tick
|
||||||
// latency of the completion pass (update interval is 10s).
|
// latency of the completion pass (update interval is 10s).
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
const globeMode = await page.evaluate(
|
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||||
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
|
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||||
|
let sampleFloorOk = false;
|
||||||
|
if (tilesSeenLoaded && !terrainGlobe) {
|
||||||
|
sampleFloorOk = await page
|
||||||
|
.waitForFunction(
|
||||||
|
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
c0.sampleHeight,
|
||||||
|
)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
const cFloor = await readCounters();
|
||||||
|
record(
|
||||||
|
terrainGlobe
|
||||||
|
? 'terrain-globe resolves from priors with zero scene ground samples'
|
||||||
|
: '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||||
|
terrainGlobe
|
||||||
|
? cFloor.sampleHeight - c0.sampleHeight === 0
|
||||||
|
: tilesSeenLoaded && sampleFloorOk,
|
||||||
|
tilesSeenLoaded
|
||||||
|
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||||
|
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||||
);
|
);
|
||||||
|
const globeMode = await page.evaluate(() => window.__godsEyeView.viewer.scene.globe?.show !== false);
|
||||||
if (globeMode) {
|
if (globeMode) {
|
||||||
// Globe stacks intentionally perform no scene.sampleHeight query.
|
// Globe stacks intentionally perform no scene.sampleHeight query.
|
||||||
// Require independently fetched DEM heights and the actual rendered
|
// Require independently fetched DEM heights and the actual rendered
|
||||||
@@ -1034,8 +1133,27 @@ async function main() {
|
|||||||
return serializeGeom(camId);
|
return serializeGeom(camId);
|
||||||
})(${JSON.stringify(camId)})`);
|
})(${JSON.stringify(camId)})`);
|
||||||
const geomBefore30s = await serializeGeom(activeId);
|
const geomBefore30s = await serializeGeom(activeId);
|
||||||
await sleep(30000);
|
await sleep(28000);
|
||||||
|
evidence.staticIdle = await page.evaluate(async () => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js');
|
||||||
|
let frames = 0;
|
||||||
|
const remove = app.engine.frames.postRender.addEventListener(() => frames++);
|
||||||
|
const started = performance.now();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
remove();
|
||||||
|
return { frames, durationMs: performance.now() - started,
|
||||||
|
governor: getRenderGovernorDiagnostics() };
|
||||||
|
});
|
||||||
const geomAfter30s = await serializeGeom(activeId);
|
const geomAfter30s = await serializeGeom(activeId);
|
||||||
|
evidence.geometry = {
|
||||||
|
initial: geomInfo,
|
||||||
|
beforeIdle: geomBefore30s,
|
||||||
|
afterIdle: geomAfter30s,
|
||||||
|
};
|
||||||
|
evidence.countersAfterIdle = await readCounters();
|
||||||
|
record('static image projection settles to zero frames over 2s',
|
||||||
|
evidence.staticIdle.frames === 0, JSON.stringify(evidence.staticIdle));
|
||||||
const geomStable =
|
const geomStable =
|
||||||
JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s);
|
JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s);
|
||||||
record(
|
record(
|
||||||
@@ -1207,7 +1325,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat)
|
// Group 4: frame-loop health (state oracle; media pixels have a separate gate)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
console.log(
|
console.log(
|
||||||
'Checking frame-loop health (placeholder -> frame swap, URL family)...',
|
'Checking frame-loop health (placeholder -> frame swap, URL family)...',
|
||||||
@@ -1464,25 +1582,18 @@ async function main() {
|
|||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const viewer = gev.viewer;
|
const viewer = gev.viewer;
|
||||||
const scene = viewer.scene;
|
const scene = viewer.scene;
|
||||||
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
|
const canvasPoint = { x: emptyPoint.canvasX, y: emptyPoint.canvasY };
|
||||||
const canvasPoint = new Cesium.Cartesian2(
|
|
||||||
emptyPoint.canvasX,
|
|
||||||
emptyPoint.canvasY,
|
|
||||||
);
|
|
||||||
const ray = viewer.camera.getPickRay(canvasPoint);
|
const ray = viewer.camera.getPickRay(canvasPoint);
|
||||||
if (!ray) return null;
|
if (!ray) return null;
|
||||||
let position = null;
|
let position = null;
|
||||||
let projected = null;
|
let projected = null;
|
||||||
for (const distance of [1_000, 10_000, 100_000, 1_000_000]) {
|
for (const distance of [1_000, 10_000, 100_000, 1_000_000]) {
|
||||||
const candidate = Cesium.Ray.getPoint(
|
const candidate = ray.constructor.getPoint(
|
||||||
ray,
|
ray,
|
||||||
distance,
|
distance,
|
||||||
new Cesium.Cartesian3(),
|
new ray.origin.constructor(),
|
||||||
);
|
|
||||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
|
||||||
scene,
|
|
||||||
candidate,
|
|
||||||
);
|
);
|
||||||
|
const screen = scene.cartesianToCanvasCoordinates(candidate);
|
||||||
if (
|
if (
|
||||||
screen &&
|
screen &&
|
||||||
Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2
|
Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2
|
||||||
@@ -1498,7 +1609,7 @@ async function main() {
|
|||||||
position,
|
position,
|
||||||
point: {
|
point: {
|
||||||
pixelSize: 64,
|
pixelSize: 64,
|
||||||
color: Cesium.Color.MAGENTA,
|
color: new scene.backgroundColor.constructor(1, 0, 1, 1),
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1537,7 +1648,7 @@ async function main() {
|
|||||||
!siblingTarget
|
!siblingTarget
|
||||||
? null
|
? null
|
||||||
: siblingOwnsPick ||
|
: siblingOwnsPick ||
|
||||||
(siblingRendererStalled && !HEADFUL ? null : false),
|
(siblingRendererStalled ? null : false),
|
||||||
siblingTarget
|
siblingTarget
|
||||||
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||||
: 'no previously verified empty canvas ray was available',
|
: 'no previously verified empty canvas ray was available',
|
||||||
@@ -1675,6 +1786,24 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cBeforeViewshed = await readCounters();
|
const cBeforeViewshed = await readCounters();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||||
|
const events = [];
|
||||||
|
window.__qaViewshedTrace = events;
|
||||||
|
for (const method of ['add', 'remove']) {
|
||||||
|
const original = primitives[method].bind(primitives);
|
||||||
|
primitives[method] = (primitive, ...args) => {
|
||||||
|
if (primitive?._gevViewshed)
|
||||||
|
events.push({
|
||||||
|
method,
|
||||||
|
camera: primitive._gevViewshed,
|
||||||
|
at: performance.now(),
|
||||||
|
stack: new Error().stack,
|
||||||
|
});
|
||||||
|
return original(primitive, ...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
const modeAfterSet = await page.evaluate(() => {
|
const modeAfterSet = await page.evaluate(() => {
|
||||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||||
mod.setParams({ coverageMode: 'viewshed' });
|
mod.setParams({ coverageMode: 'viewshed' });
|
||||||
@@ -1725,6 +1854,18 @@ async function main() {
|
|||||||
mod.setParams({ coverageMode: 'viewshed' });
|
mod.setParams({ coverageMode: 'viewshed' });
|
||||||
});
|
});
|
||||||
await sleep(400);
|
await sleep(400);
|
||||||
|
// NEXT starts an asynchronous footprint-height lookup. Its completion is
|
||||||
|
// allowed to update geometry; the unchanged 8s idle window starts after it.
|
||||||
|
const terrainDeadline = Date.now() + 35000;
|
||||||
|
while (
|
||||||
|
(terrainRequests.size || Date.now() - terrainActivityAt < 250) &&
|
||||||
|
Date.now() < terrainDeadline
|
||||||
|
)
|
||||||
|
await sleep(50);
|
||||||
|
const terrainSettled =
|
||||||
|
terrainRequests.size === 0 && Date.now() - terrainActivityAt >= 250;
|
||||||
|
record('footprint requests settle before viewshed idle', terrainSettled);
|
||||||
|
if (!terrainSettled) throw new Error('Footprint requests did not settle');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||||
window.__qaViewshedRefs = [];
|
window.__qaViewshedRefs = [];
|
||||||
@@ -1746,6 +1887,9 @@ async function main() {
|
|||||||
return { sameCount, sameIdentity, count: now.length };
|
return { sameCount, sameIdentity, count: now.length };
|
||||||
});
|
});
|
||||||
const cAfterViewshed = await readCounters();
|
const cAfterViewshed = await readCounters();
|
||||||
|
evidence.viewshedTrace = await page.evaluate(
|
||||||
|
() => window.__qaViewshedTrace,
|
||||||
|
);
|
||||||
const lateFloorSamples =
|
const lateFloorSamples =
|
||||||
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
|
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
|
||||||
record(
|
record(
|
||||||
@@ -1772,6 +1916,11 @@ async function main() {
|
|||||||
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...');
|
console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...');
|
||||||
|
// NEXT above selects a different catalog camera without moving the view.
|
||||||
|
// Frame that actual camera before requiring an on-screen physical drag.
|
||||||
|
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv')
|
||||||
|
.module.setParams({ focusSelected: true, focusDurationSec: 0.2 }));
|
||||||
|
await sleep(800);
|
||||||
const GIZMO_PARTS = [
|
const GIZMO_PARTS = [
|
||||||
'ring-heading',
|
'ring-heading',
|
||||||
'ring-pitch',
|
'ring-pitch',
|
||||||
@@ -2020,7 +2169,7 @@ async function main() {
|
|||||||
record(
|
record(
|
||||||
'east-arrow drag changes the east offset (live, unsaved)',
|
'east-arrow drag changes the east offset (live, unsaved)',
|
||||||
null,
|
null,
|
||||||
`${missDetail} — verify on a current headful GPU surface`,
|
`${missDetail} — required physical drag evidence is missing from this headless Metal run`,
|
||||||
);
|
);
|
||||||
record(
|
record(
|
||||||
'east-arrow drag keeps mount elevation frozen until mouse-up',
|
'east-arrow drag keeps mount elevation frozen until mouse-up',
|
||||||
@@ -2044,7 +2193,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const unavailable =
|
const unavailable =
|
||||||
'no on-screen east-arrow shaft found under this GL stack — verify on real GPU';
|
'no on-screen east-arrow shaft found in the headless Metal view';
|
||||||
record(
|
record(
|
||||||
'east-arrow drag changes the east offset (live, unsaved)',
|
'east-arrow drag changes the east offset (live, unsaved)',
|
||||||
null,
|
null,
|
||||||
@@ -2095,8 +2244,8 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Screenshots for human visual review (NOT a pass/fail signal — SwiftShader
|
// Screenshots supplement this state/geometry oracle. Positive rendered
|
||||||
// cannot fill plane image materials under headless software GL).
|
// media pixels are a separate mandatory pixel gate.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
||||||
@@ -2110,10 +2259,16 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.ok === false) exitCode = 1;
|
if (r.ok !== true) exitCode = 1;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
await fixture?.dispose();
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
evidence.pass =
|
||||||
|
results.length > 0 &&
|
||||||
|
results.every((r) => r.ok === true) &&
|
||||||
|
!consoleErrors.length;
|
||||||
|
saveEvidence();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n' + '─'.repeat(60));
|
console.log('\n' + '─'.repeat(60));
|
||||||
@@ -2128,6 +2283,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
evidence.pass = false;
|
||||||
|
evidence.error = redact(e?.stack || e);
|
||||||
|
saveEvidence();
|
||||||
|
console.error('Harness error:', evidence.error);
|
||||||
process.exit(3);
|
process.exit(3);
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,7 @@ 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 puppeteer from 'puppeteer';
|
||||||
|
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
|
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
|
||||||
@@ -29,6 +30,7 @@ const browser = await puppeteer.launch({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
await blockPaidQaMaps(page);
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const consoleErrors = [];
|
const consoleErrors = [];
|
||||||
const localHttpErrors = [];
|
const localHttpErrors = [];
|
||||||
@@ -827,6 +829,7 @@ try {
|
|||||||
const readinessPage = await browser.newPage();
|
const readinessPage = await browser.newPage();
|
||||||
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);
|
||||||
@@ -1150,6 +1153,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;
|
||||||
@@ -1511,6 +1522,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)));
|
||||||
@@ -1531,6 +1545,7 @@ try {
|
|||||||
before,
|
before,
|
||||||
during,
|
during,
|
||||||
after: read(),
|
after: read(),
|
||||||
|
startsClear,
|
||||||
exited,
|
exited,
|
||||||
clearancePx,
|
clearancePx,
|
||||||
contactAutoCollapsed,
|
contactAutoCollapsed,
|
||||||
@@ -1540,7 +1555,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)
|
||||||
@@ -1605,6 +1620,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;
|
||||||
@@ -1616,8 +1632,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();
|
||||||
@@ -1692,6 +1710,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');
|
||||||
@@ -1877,7 +1898,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;
|
||||||
@@ -1890,9 +1911,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,
|
||||||
@@ -1903,6 +1926,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;
|
||||||
@@ -1927,11 +1951,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',
|
||||||
@@ -1941,7 +1965,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,
|
||||||
@@ -1952,7 +1976,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',
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
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, tileset } = window.__godsEyeView;
|
||||||
|
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||||
|
throw Error('Require keyless preview');
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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 { tileset, dataManager } = window.__godsEyeView;
|
||||||
|
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||||
|
throw Error('Keyless preview required');
|
||||||
|
window.qaRoute = dataManager.layers.get('directions').module;
|
||||||
|
await dataManager.setEnabled('directions', true, { origin: 'user' });
|
||||||
|
});
|
||||||
|
for (const [letter, x] of [
|
||||||
|
['a', 480],
|
||||||
|
['b', 645],
|
||||||
|
]) {
|
||||||
|
await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter);
|
||||||
|
await p.mouse.click(x, 440);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||||
|
}
|
||||||
|
assert.equal(routeReads, 1);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'A and B set by clicks on the globe; one route request',
|
||||||
|
);
|
||||||
|
await strict.switchTo(p, 'cesium');
|
||||||
|
release();
|
||||||
|
await p.waitForFunction(() => window.qaRoute.getStats().count === 3);
|
||||||
|
await p.waitForFunction(() => {
|
||||||
|
window.qaRoute.setParams({ step: 1 });
|
||||||
|
return window.qaRoute.getRowControls().list.items[1]?.active;
|
||||||
|
});
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'the route asked for on Navara arrives on Cesium; a step is selectable',
|
||||||
|
);
|
||||||
|
const transfer = async (kind) => {
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
assert.equal(
|
||||||
|
await p.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers.get('directions').module ===
|
||||||
|
window.qaRoute,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(routeReads, 1);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'same model; the switch requests no route',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await transfer('navara');
|
||||||
|
assert.equal(
|
||||||
|
await p.evaluate(
|
||||||
|
() => window.qaRoute.getRowControls().list.items[1].active,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await p.evaluate(() => window.qaRoute.setParams({ fly: true }));
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')?.active,
|
||||||
|
);
|
||||||
|
const pos = () =>
|
||||||
|
p.evaluate(() => window.__godsEyeView.engine.camera.pose().position);
|
||||||
|
const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||||
|
let progress = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||||
|
for (const kind of ['cesium', 'navara']) {
|
||||||
|
const before = await pos();
|
||||||
|
await transfer(kind);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||||
|
?.active,
|
||||||
|
);
|
||||||
|
const at = await pos();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
const next = await pos();
|
||||||
|
const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||||
|
assert.equal(resumed.motionId, progress.motionId);
|
||||||
|
assert.ok(resumed.traveledM > progress.traveledM);
|
||||||
|
progress = resumed;
|
||||||
|
console.log(kind, 'moved', d(at, next), 'handoff', d(at, before));
|
||||||
|
assert.ok(d(at, next) > 1, 'dolly advances after switch to ' + kind);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'FLY continues with the same motion and advances',
|
||||||
|
{ moved: d(at, next) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await p.evaluate(() => window.qaRoute.setParams({ clear: true }));
|
||||||
|
assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0);
|
||||||
|
// CLEAR releases the camera: the route's motion ends, the camera stays
|
||||||
|
// where it is, and a wheel zoom by the person moves it and is kept.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
const released = await p.evaluate(() => ({
|
||||||
|
motion: window.qaRoute.getFlightProgress(),
|
||||||
|
fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||||
|
?.active,
|
||||||
|
}));
|
||||||
|
const still = await pos();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
const later = await pos();
|
||||||
|
const height = () =>
|
||||||
|
p.evaluate(() => window.__godsEyeView.engine.camera.height());
|
||||||
|
const beforeZoom = await height();
|
||||||
|
await p.mouse.move(512, 300);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await p.mouse.wheel({ deltaY: -240 });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
const zoomed = await height();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
const kept = await height();
|
||||||
|
await strict.check(
|
||||||
|
p,
|
||||||
|
'directions',
|
||||||
|
'CLEAR removes the route and releases the camera: no route motion, the camera stays put, a wheel zoom moves it and is kept',
|
||||||
|
released.motion === null &&
|
||||||
|
!released.fly &&
|
||||||
|
d(still, later) < 1 &&
|
||||||
|
zoomed < beforeZoom * 0.95 &&
|
||||||
|
Math.abs(kept - zoomed) < 0.02 * zoomed,
|
||||||
|
{ released, drift: d(still, later), beforeZoom, zoomed, kept },
|
||||||
|
);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS route delivery during handoff, A/B clicks, step selection, ongoing FLY, clear and single route request',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -35,8 +35,9 @@ const check = (name, passed, detail = '') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
headless: false,
|
headless: process.env.QA_HEADFUL === '1' ? false : true,
|
||||||
executablePath: chromeExecutablePath(),
|
executablePath:
|
||||||
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||||
|
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
|
const url = localPreview(process.argv[2]);
|
||||||
|
url.search = '?engine=navara&welcome=0';
|
||||||
|
url.hash =
|
||||||
|
'v=2&lat=30.2672&lon=-97.742&alt=4000&pitch=-90&heading=0&map=sentinel2&l=';
|
||||||
|
const png = await sharp({
|
||||||
|
create: { width: 64, height: 64, channels: 4, background: '#fd2233' },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
id: 'point',
|
||||||
|
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'line',
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: [
|
||||||
|
[-97.745, 30.268, 400],
|
||||||
|
[-97.735, 30.268, 400],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'polygon',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[-97.747, 30.264, 450],
|
||||||
|
[-97.74, 30.264, 450],
|
||||||
|
[-97.74, 30.267, 450],
|
||||||
|
[-97.747, 30.264, 450],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].map((f) => ({ ...f, type: 'Feature' }));
|
||||||
|
const wav = Buffer.alloc(44 + 16000);
|
||||||
|
wav.write('RIFF');
|
||||||
|
wav.writeUInt32LE(wav.length - 8, 4);
|
||||||
|
wav.write('WAVEfmt ', 8);
|
||||||
|
wav.writeUInt32LE(16, 16);
|
||||||
|
wav.writeUInt16LE(1, 20);
|
||||||
|
wav.writeUInt16LE(1, 22);
|
||||||
|
wav.writeUInt32LE(8000, 24);
|
||||||
|
wav.writeUInt32LE(16000, 28);
|
||||||
|
wav.writeUInt16LE(2, 32);
|
||||||
|
wav.writeUInt16LE(16, 34);
|
||||||
|
wav.write('data', 36);
|
||||||
|
wav.writeUInt32LE(16000, 40);
|
||||||
|
const pack = (id, format, path, placement) => ({
|
||||||
|
id,
|
||||||
|
version: 1,
|
||||||
|
format,
|
||||||
|
source: { adapter: 'assets', path },
|
||||||
|
attribution: { text: 'Local fixture', license: 'CC0-1.0' },
|
||||||
|
placement,
|
||||||
|
});
|
||||||
|
const camera = {
|
||||||
|
lat: 30.2672,
|
||||||
|
lon: -97.742,
|
||||||
|
alt: 4000,
|
||||||
|
heading: 0,
|
||||||
|
pitch: -90,
|
||||||
|
roll: 0,
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
};
|
||||||
|
const shot = {
|
||||||
|
id: 'one',
|
||||||
|
title: 'Pack view',
|
||||||
|
camera,
|
||||||
|
durationSec: 5,
|
||||||
|
holdSec: 3,
|
||||||
|
visual: { style: 'normal', mapStack: 'sentinel2' },
|
||||||
|
layers: { earthquakes: true },
|
||||||
|
dataPackIds: ['data', 'image', 'audio'],
|
||||||
|
interactions: features.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
label: 'Show ' + f.id,
|
||||||
|
target: { packId: 'data', featureId: f.id },
|
||||||
|
action: { type: 'card', text: f.id },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const project = {
|
||||||
|
version: 6,
|
||||||
|
scenes: [
|
||||||
|
{
|
||||||
|
id: 'parity',
|
||||||
|
title: 'Test project',
|
||||||
|
anchors: [
|
||||||
|
{
|
||||||
|
id: 'station',
|
||||||
|
lat: 30.271,
|
||||||
|
lon: -97.735,
|
||||||
|
alt: 500,
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataPacks: [
|
||||||
|
pack('data', 'geojson', 'data.json', {
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
}),
|
||||||
|
pack('image', 'image', 'image.png', {
|
||||||
|
altitudeReference: 'ellipsoid',
|
||||||
|
bounds: [-97.738, 30.264, -97.732, 30.267],
|
||||||
|
height: 500,
|
||||||
|
}),
|
||||||
|
pack('audio', 'media', 'audio.wav', { anchorId: 'station' }),
|
||||||
|
],
|
||||||
|
shots: [
|
||||||
|
shot,
|
||||||
|
{
|
||||||
|
...shot,
|
||||||
|
id: 'two',
|
||||||
|
title: 'Next shot',
|
||||||
|
camera: { ...camera, heading: 15 },
|
||||||
|
move: { from: camera, easing: 'linear' },
|
||||||
|
dataPackIds: [],
|
||||||
|
interactions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// Scene Director is not a catalog layer; the scenario answers the scene's
|
||||||
|
// earthquake feed itself.
|
||||||
|
const strict = createStrict({
|
||||||
|
origin: url.origin,
|
||||||
|
layers: [],
|
||||||
|
fixtures: ['https://earthquake.usgs.gov/*'],
|
||||||
|
});
|
||||||
|
await strict.run({}, async (b) => {
|
||||||
|
const p = await b.newPage();
|
||||||
|
await p.setViewport({ width: 1280, height: 900 });
|
||||||
|
const errors = [];
|
||||||
|
p.on('pageerror', (e) => {
|
||||||
|
errors.push(e.message);
|
||||||
|
console.log('error', e.message);
|
||||||
|
});
|
||||||
|
const reads = [];
|
||||||
|
await p.setRequestInterception(true);
|
||||||
|
p.on('request', (req) => {
|
||||||
|
const path = new URL(req.url()).pathname;
|
||||||
|
if (path.startsWith('/scene-assets/')) {
|
||||||
|
reads.push(path);
|
||||||
|
const file = path.split('/').pop();
|
||||||
|
return req.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType:
|
||||||
|
file === 'data.json'
|
||||||
|
? 'application/geo+json'
|
||||||
|
: file === 'audio.wav'
|
||||||
|
? 'audio/wav'
|
||||||
|
: 'image/png',
|
||||||
|
body:
|
||||||
|
file === 'data.json'
|
||||||
|
? JSON.stringify({ type: 'FeatureCollection', features })
|
||||||
|
: file === 'audio.wav'
|
||||||
|
? wav
|
||||||
|
: png,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (new URL(req.url()).hostname === 'earthquake.usgs.gov')
|
||||||
|
return req.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||||
|
body: '{"type":"FeatureCollection","features":[]}',
|
||||||
|
});
|
||||||
|
return req.continue();
|
||||||
|
});
|
||||||
|
await p.goto(url.href);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.sceneDirector &&
|
||||||
|
document.querySelector('#loading-screen.hidden'),
|
||||||
|
);
|
||||||
|
await p.evaluate(async (project) => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset)
|
||||||
|
throw Error('Expected keyless preview');
|
||||||
|
await a.styleManager.initialRestorePromise;
|
||||||
|
window.director = a.sceneDirector;
|
||||||
|
await window.director.importProjectFile(
|
||||||
|
new File([JSON.stringify(project)], 'qa.json'),
|
||||||
|
);
|
||||||
|
const out = await window.director.loadShot('parity', 'one', {
|
||||||
|
flyDuration: 0.2,
|
||||||
|
});
|
||||||
|
if (!out.started)
|
||||||
|
throw Error(
|
||||||
|
'Pack load failed: ' +
|
||||||
|
JSON.stringify(out) +
|
||||||
|
' ' +
|
||||||
|
window.director._presentation.status,
|
||||||
|
);
|
||||||
|
}, project);
|
||||||
|
await p.waitForSelector('[data-director-pack="audio"] audio');
|
||||||
|
await p.evaluate(() => {
|
||||||
|
window.qaAudio = document.querySelector(
|
||||||
|
'[data-director-pack="audio"] audio',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const at = (world) =>
|
||||||
|
p.evaluate((world) => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
const xy = a.engine.projector.worldToWindow(world),
|
||||||
|
rect = a.engine.host.canvas.getBoundingClientRect();
|
||||||
|
return { x: xy.x + rect.x, y: xy.y + rect.y };
|
||||||
|
}, world);
|
||||||
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
|
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind)
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
const st = await p.evaluate(() => ({
|
||||||
|
same: window.director === window.__godsEyeView.sceneDirector,
|
||||||
|
media:
|
||||||
|
window.qaAudio ===
|
||||||
|
document.querySelector('[data-director-pack="audio"] audio'),
|
||||||
|
packs: window.director.getDataPackState(),
|
||||||
|
actions: window.director.getInteractionState(),
|
||||||
|
}));
|
||||||
|
assert.ok(
|
||||||
|
st.same && st.media && st.packs.count === 3 && st.actions.count === 3,
|
||||||
|
);
|
||||||
|
const xy = await at(fromDegrees(-97.742, 30.27, 450));
|
||||||
|
await p.mouse.click(xy.x, xy.y);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => document.activeElement?.dataset.directorAction === 'point',
|
||||||
|
);
|
||||||
|
await p.keyboard.press('Enter');
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.director.getInteractionState().selected === 'point',
|
||||||
|
);
|
||||||
|
for (const [id, coords] of [
|
||||||
|
['line', [-97.74, 30.268, 400]],
|
||||||
|
['polygon', [-97.742, 30.265, 450]],
|
||||||
|
]) {
|
||||||
|
const hit = await at(fromDegrees(...coords));
|
||||||
|
await p.mouse.click(hit.x, hit.y);
|
||||||
|
await p.waitForFunction(
|
||||||
|
(id) => document.activeElement?.dataset.directorAction === id,
|
||||||
|
{ timeout: 10000 },
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const imageAt = await at(fromDegrees(-97.735, 30.2655, 500));
|
||||||
|
const sample = await sharp(await p.screenshot())
|
||||||
|
.extract({
|
||||||
|
left: Math.round(imageAt.x),
|
||||||
|
top: Math.round(imageAt.y),
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
})
|
||||||
|
.removeAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
console.log(kind, st.packs.count, st.actions.count, Array.from(sample));
|
||||||
|
assert.ok(
|
||||||
|
sample[0] > 100 && sample[0] > sample[1] * 2,
|
||||||
|
'authored image really draws',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
reads.length,
|
||||||
|
3,
|
||||||
|
'packs are not acquired again on a globe change',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await p.evaluate(() => {
|
||||||
|
window.qaRun = window.director.startScene('parity', {
|
||||||
|
single: true,
|
||||||
|
preview: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.director.running &&
|
||||||
|
window.director.getPlaybackTimingState().snapshot?.shotId === 'one',
|
||||||
|
);
|
||||||
|
await strict.switchTo(p, 'cesium');
|
||||||
|
assert.ok(
|
||||||
|
await p.evaluate(
|
||||||
|
() =>
|
||||||
|
window.director.running &&
|
||||||
|
window.director === window.__godsEyeView.sceneDirector,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.director.getPlaybackTimingState().snapshot?.shotId === 'two',
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
await strict.switchTo(p, 'navara');
|
||||||
|
const complete = await p.evaluate(() => window.qaRun);
|
||||||
|
assert.ok(complete.started && complete.shots === 2);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS authored packs, geographic picks, same player, original multi-shot timeline',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||||
|
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
base.search = '?welcome=0&look=cinematic';
|
||||||
|
base.hash =
|
||||||
|
'v=2&lat=42.3554&lon=-71.0605&alt=6000&heading=0&pitch=-90&map=osm&l=e';
|
||||||
|
const strict = createStrict({ origin: base.origin, layers: ['earthquakes'] });
|
||||||
|
await strict.run({}, async (browser) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.evaluateOnNewDocument(() => {
|
||||||
|
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||||
|
const fetch = window.fetch.bind(window);
|
||||||
|
// Two Boston vessels for the Contacts session; empty air and site feeds.
|
||||||
|
const fixtures = {
|
||||||
|
'/api/vessels': () => ({
|
||||||
|
status: 'live',
|
||||||
|
rows: ['123456789', '223456789'].map((mmsi, i) => ({
|
||||||
|
mmsi,
|
||||||
|
name: 'Boston ' + i,
|
||||||
|
lat: 42.3554,
|
||||||
|
lon: -71.0605 + i * 0.05,
|
||||||
|
last_position_epoch: Date.now() / 1000,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
'/api/vessels/track': () => ({ samples: [] }),
|
||||||
|
'/api/flights': () => ({ time: Date.now() / 1000, states: [] }),
|
||||||
|
'/api/military': () => ({ now: Date.now(), ac: [] }),
|
||||||
|
'/api/military-installations': () => ({ elements: [], status: 'ok' }),
|
||||||
|
};
|
||||||
|
window.fetch = (url, options) => {
|
||||||
|
if (
|
||||||
|
String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/')
|
||||||
|
)
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
id: 'qa-boston',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [-71.0605, 42.3554, 8],
|
||||||
|
},
|
||||||
|
properties: { mag: 4, time: Date.now(), place: 'QA Boston' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const fixture =
|
||||||
|
fixtures[new URL(url.url ?? url, document.baseURI).pathname];
|
||||||
|
return fixture
|
||||||
|
? Promise.resolve(Response.json(fixture()))
|
||||||
|
: fetch(url, options);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.setViewport({ width: 1280, height: 800 });
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
async function ready(kind) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(kind) =>
|
||||||
|
window.__godsEyeView?.engine?.kind === kind &&
|
||||||
|
document.querySelector('#globe-engine')?.value === kind &&
|
||||||
|
!document.querySelector('#globe-engine').disabled &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 90000 },
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.__godsEyeView.styleManager.initialRestorePromise;
|
||||||
|
if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset)
|
||||||
|
throw new Error('Keyless preview required');
|
||||||
|
});
|
||||||
|
assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind);
|
||||||
|
}
|
||||||
|
async function verifyQuakeSurface(kind) {
|
||||||
|
const meanRed = async (enabled) => {
|
||||||
|
await page.evaluate(async (enabled) => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
await a.dataManager.setEnabled('earthquakes', enabled, {
|
||||||
|
origin: 'user',
|
||||||
|
});
|
||||||
|
a.engine.render.request('qa-quake-surface');
|
||||||
|
}, enabled);
|
||||||
|
if (enabled)
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('earthquakes')
|
||||||
|
.module.getAnalystRecords().length === 1,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||||
|
const bytes = await page.screenshot({
|
||||||
|
clip: { x: 675, y: 403, width: 20, height: 20 },
|
||||||
|
});
|
||||||
|
const { channels } = await sharp(bytes).stats();
|
||||||
|
return channels[0].mean - channels[1].mean;
|
||||||
|
};
|
||||||
|
const off = await meanRed(false),
|
||||||
|
on = await meanRed(true);
|
||||||
|
assert.ok(
|
||||||
|
on > off + 8,
|
||||||
|
kind +
|
||||||
|
' must draw the shallow red earthquake footprint: ' +
|
||||||
|
off +
|
||||||
|
' -> ' +
|
||||||
|
on,
|
||||||
|
);
|
||||||
|
await strict.passed(page, 'earthquakes', 'shallow red footprint drawn', {
|
||||||
|
off,
|
||||||
|
on,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
'PASS',
|
||||||
|
kind,
|
||||||
|
'earthquake footprint red minus green:',
|
||||||
|
off,
|
||||||
|
'->',
|
||||||
|
on,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const stored = () => page.evaluate(() => localStorage.getItem('gev.engine'));
|
||||||
|
async function switchTo(kind) {
|
||||||
|
if (
|
||||||
|
await page.$eval(
|
||||||
|
'#control-panel-toggle',
|
||||||
|
(el) => el.getAttribute('aria-expanded') !== 'true',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await page.focus('#control-panel-toggle');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
}
|
||||||
|
await page.waitForSelector('#globe-engine', { visible: true });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__switchDocument = document;
|
||||||
|
window.__quakeEntry =
|
||||||
|
window.__godsEyeView.dataManager.layers.get('earthquakes');
|
||||||
|
window.__quakeTimer = window.__quakeEntry.intervalId;
|
||||||
|
window.__renderers ??= new Map();
|
||||||
|
window.__renderers.set(
|
||||||
|
window.__godsEyeView.engine.kind,
|
||||||
|
window.__godsEyeView.engine,
|
||||||
|
);
|
||||||
|
window.__switchSplash = false;
|
||||||
|
window.__switchObserver = new MutationObserver(() => {
|
||||||
|
if (!document.querySelector('#loading-screen.hidden'))
|
||||||
|
window.__switchSplash = true;
|
||||||
|
});
|
||||||
|
window.__switchObserver.observe(
|
||||||
|
document.querySelector('#loading-screen'),
|
||||||
|
{ attributes: true },
|
||||||
|
);
|
||||||
|
window.__outgoingEngine = window.__godsEyeView.engine;
|
||||||
|
});
|
||||||
|
await strict.pick(page, kind);
|
||||||
|
await ready(kind);
|
||||||
|
assert.ok(await page.evaluate(() => window.__switchDocument === document));
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__switchObserver.disconnect();
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const previous = window.__renderers.get(engine.kind);
|
||||||
|
return (
|
||||||
|
!window.__switchSplash &&
|
||||||
|
window.__quakeEntry ===
|
||||||
|
window.__godsEyeView.dataManager.layers.get('earthquakes') &&
|
||||||
|
window.__quakeTimer === window.__quakeEntry.intervalId &&
|
||||||
|
(!previous || previous === engine) &&
|
||||||
|
!window.__outgoingEngine.isDestroyed() &&
|
||||||
|
!window.__outgoingEngine.host.canvas.isConnected &&
|
||||||
|
engine.host.canvas.isConnected &&
|
||||||
|
window.__godsEyeView.dataManager
|
||||||
|
.getEnabledLayerIds()
|
||||||
|
.has('earthquakes')
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
page,
|
||||||
|
'earthquakes',
|
||||||
|
'same layer entry, model and poll timer; no splash; outgoing canvas detached',
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('.engine-switch-frame'),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const parked = window.__outgoingEngine;
|
||||||
|
let frames = 0;
|
||||||
|
const remove = parked.frames.postRender.addEventListener(() => {
|
||||||
|
frames++;
|
||||||
|
});
|
||||||
|
parked.render.request('qa-parked');
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||||
|
);
|
||||||
|
remove();
|
||||||
|
return frames;
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const url = new URL(page.url());
|
||||||
|
assert.equal(url.searchParams.get('engine'), kind);
|
||||||
|
assert.equal(url.searchParams.get('look'), 'cinematic');
|
||||||
|
const hash = new URLSearchParams(url.hash.slice(1));
|
||||||
|
assert.equal(hash.get('map'), 'osm');
|
||||||
|
assert.ok(Math.abs(Number(hash.get('lat')) - 42.3554) < 0.01);
|
||||||
|
assert.ok(Math.abs(Number(hash.get('lon')) + 71.0605) < 0.01);
|
||||||
|
assert.ok(Math.abs(Number(hash.get('alt')) - 6000) < 30);
|
||||||
|
const view = await page.evaluate(() => {
|
||||||
|
const { engine, mapStackController } = window.__godsEyeView;
|
||||||
|
const { latitude, longitude, height } = engine.camera.pose().cartographic;
|
||||||
|
return {
|
||||||
|
latitude: latitude * (180 / Math.PI),
|
||||||
|
longitude: longitude * (180 / Math.PI),
|
||||||
|
height,
|
||||||
|
map: mapStackController.getActiveId(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(view.map, 'osm');
|
||||||
|
assert.ok(Math.abs(view.latitude - 42.3554) < 0.01);
|
||||||
|
assert.ok(Math.abs(view.longitude + 71.0605) < 0.01);
|
||||||
|
assert.ok(Math.abs(view.height - 6000) < 30);
|
||||||
|
await strict.passed(
|
||||||
|
page,
|
||||||
|
'earthquakes',
|
||||||
|
'parked engine draws no frame; pose, map and layers carried over',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await ready('cesium');
|
||||||
|
await verifyQuakeSurface('cesium');
|
||||||
|
const sharpen = () =>
|
||||||
|
page.evaluate(
|
||||||
|
() => window.__godsEyeView.styleManager.getControlState().sharpen,
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const style = window.__godsEyeView.styleManager;
|
||||||
|
style.setStyle('retro');
|
||||||
|
style.setStyle('normal');
|
||||||
|
});
|
||||||
|
assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 49 });
|
||||||
|
await switchTo('navara');
|
||||||
|
await verifyQuakeSurface('navara');
|
||||||
|
assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 80 });
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
Math.abs(window.__godsEyeView.engine.render.sharpen().amount - 1.7) <
|
||||||
|
1e-6,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(await stored(), 'navara');
|
||||||
|
await page.goBack({ waitUntil: 'domcontentloaded' });
|
||||||
|
await ready('cesium');
|
||||||
|
assert.equal((await sharpen()).intensityPct, 49);
|
||||||
|
await page.goForward();
|
||||||
|
await ready('navara');
|
||||||
|
assert.equal((await sharpen()).intensityPct, 80);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.setSharpen({
|
||||||
|
enabled: false,
|
||||||
|
intensityPct: 49,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await switchTo('cesium');
|
||||||
|
await switchTo('navara');
|
||||||
|
assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 });
|
||||||
|
// The address carries the running layer set once its debounced write lands.
|
||||||
|
const settledAddress = async () => {
|
||||||
|
let previous;
|
||||||
|
for (;;) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
const current = await page.evaluate(() =>
|
||||||
|
new URLSearchParams(location.hash.slice(1)).get('l'),
|
||||||
|
);
|
||||||
|
if (current === previous) return current;
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pick = async (kind) => {
|
||||||
|
await strict.pick(page, kind);
|
||||||
|
await ready(kind);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('.engine-switch-frame'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const visit = async (direction, kind) => {
|
||||||
|
await (direction === 'back'
|
||||||
|
? page.goBack({ waitUntil: 'domcontentloaded' })
|
||||||
|
: page.goForward());
|
||||||
|
await ready(kind);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('.engine-switch-frame'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const quakes = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const { dataManager } = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
model: window.__quakeModel === dataManager.layers.get('earthquakes'),
|
||||||
|
enabled: dataManager.getEnabledLayerIds().has('earthquakes'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Like a same-engine Back, each entry brings back its own layer set, on
|
||||||
|
// the same layer models.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__quakeModel =
|
||||||
|
window.__godsEyeView.dataManager.layers.get('earthquakes');
|
||||||
|
});
|
||||||
|
const navaraLayers = await settledAddress();
|
||||||
|
await pick('cesium');
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('earthquakes', false, {
|
||||||
|
origin: 'user',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cesiumLayers = await settledAddress();
|
||||||
|
assert.notEqual(cesiumLayers, navaraLayers);
|
||||||
|
await visit('back', 'navara');
|
||||||
|
assert.deepEqual(await quakes(), { model: true, enabled: true });
|
||||||
|
await strict.passed(
|
||||||
|
page,
|
||||||
|
'earthquakes',
|
||||||
|
'Back restores the enabled layer on the same model',
|
||||||
|
);
|
||||||
|
assert.equal(await settledAddress(), navaraLayers, 'Back keeps its entry');
|
||||||
|
await visit('forward', 'cesium');
|
||||||
|
assert.deepEqual(await quakes(), { model: true, enabled: false });
|
||||||
|
await strict.passed(
|
||||||
|
page,
|
||||||
|
'earthquakes',
|
||||||
|
'Forward restores the disabled layer on the same model',
|
||||||
|
);
|
||||||
|
assert.equal(await settledAddress(), cesiumLayers, 'Forward keeps its entry');
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('earthquakes', true, {
|
||||||
|
origin: 'user',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await pick('navara');
|
||||||
|
console.log(
|
||||||
|
"PASS Back/Forward restore each entry's layers on the same models",
|
||||||
|
);
|
||||||
|
// Back/Forward hand the session off like the picker: the Contacts subject
|
||||||
|
// and its navigation history, its layers and the Director survive.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
||||||
|
await dataManager.setEnabled('ais-live-vessels', true, {
|
||||||
|
origin: 'user',
|
||||||
|
});
|
||||||
|
const outcome = await styleManager.setContextMode('contacts');
|
||||||
|
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||||
|
window.qaSession = {
|
||||||
|
director: sceneDirector,
|
||||||
|
context: dataManager.layers.get('military-awareness').module,
|
||||||
|
vessels: dataManager.layers.get('ais-live-vessels'),
|
||||||
|
};
|
||||||
|
if (!window.qaSession.vessels.module.selectById('123456789'))
|
||||||
|
throw Error('Missing first vessel');
|
||||||
|
if (
|
||||||
|
!window.qaSession.context.navigateNext({
|
||||||
|
targetLayer: 'ais-live-vessels',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
throw Error('Cannot navigate to the second vessel');
|
||||||
|
});
|
||||||
|
const session = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
||||||
|
const { director, context, vessels } = window.qaSession;
|
||||||
|
return {
|
||||||
|
director: director === sceneDirector && !director._destroyed,
|
||||||
|
owners:
|
||||||
|
context === dataManager.layers.get('military-awareness').module &&
|
||||||
|
vessels === dataManager.layers.get('ais-live-vessels'),
|
||||||
|
mode: styleManager.getContextModeState().mode,
|
||||||
|
subject: context.getContextSnapshot()?.subject?.id,
|
||||||
|
history: context._getAwarenessNavigationStateForTest().historyKeys,
|
||||||
|
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const live = await session();
|
||||||
|
assert.equal(live.mode, 'flights');
|
||||||
|
assert.equal(live.subject, '223456789');
|
||||||
|
assert.ok(live.history.includes('ais-live-vessels:123456789'));
|
||||||
|
await settledAddress();
|
||||||
|
const historyLength = await page.evaluate(() => history.length);
|
||||||
|
await pick('cesium');
|
||||||
|
assert.deepEqual(await session(), live, 'a pick keeps the session');
|
||||||
|
await settledAddress();
|
||||||
|
await visit('back', 'navara');
|
||||||
|
assert.deepEqual(await session(), live, 'Back keeps the session');
|
||||||
|
await visit('forward', 'cesium');
|
||||||
|
assert.deepEqual(await session(), live, 'Forward keeps the session');
|
||||||
|
assert.equal(await page.evaluate(() => history.length), historyLength + 1);
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { dataManager, styleManager } = window.__godsEyeView;
|
||||||
|
const result = await styleManager.setContextMode('off');
|
||||||
|
await dataManager.setEnabled('ais-live-vessels', false, {
|
||||||
|
origin: 'user',
|
||||||
|
});
|
||||||
|
return result.ok;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await pick('navara');
|
||||||
|
console.log(
|
||||||
|
'PASS Back/Forward keep Contacts subject, history, layers and Director',
|
||||||
|
);
|
||||||
|
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await ready('navara');
|
||||||
|
await switchTo('cesium');
|
||||||
|
assert.equal(await stored(), 'cesium');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const write = Storage.prototype.setItem;
|
||||||
|
Storage.prototype.setItem = function (key, value) {
|
||||||
|
if (key === 'gev.engine')
|
||||||
|
throw new DOMException('Blocked', 'SecurityError');
|
||||||
|
return write.call(this, key, value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await switchTo('navara');
|
||||||
|
assert.equal(await stored(), 'cesium');
|
||||||
|
// The real Map Source chips move into daylight inspection and back to live lighting.
|
||||||
|
await page.$eval('[data-stack-id="sentinel2"]', (el) => el.click());
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.mapStackController.getActiveId() === 'sentinel2' &&
|
||||||
|
window.__godsEyeView.engine.host.container.hasAttribute(
|
||||||
|
'data-terrain-daylight',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Zooming out on the satellite map must keep the same inspection sun.
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
engine.camera.zoomOut(2_000_000);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const remove = engine.frames.postRender.addEventListener(() => {
|
||||||
|
remove();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
engine.render.request('qa-orbital-daylight');
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
engine.camera.height() > 1_500_000 &&
|
||||||
|
engine.host.container.hasAttribute('data-terrain-daylight')
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
'the orbital frame keeps daylight on the inspected globe',
|
||||||
|
);
|
||||||
|
await page.$eval('[data-stack-id="osm"]', (el) => el.click());
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.mapStackController.getActiveId() === 'osm' &&
|
||||||
|
!window.__godsEyeView.engine.host.container.hasAttribute(
|
||||||
|
'data-terrain-daylight',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS Cesium default, both switches, same document, warm engine identity, parked canvas, no startup splash, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -28,15 +28,15 @@
|
|||||||
* E3 requests : the sweep requested all 12 hexes, each exactly once
|
* E3 requests : the sweep requested all 12 hexes, each exactly once
|
||||||
* E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after
|
* E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after
|
||||||
* enrichment (expected: 8)
|
* enrichment (expected: 8)
|
||||||
* E5 glyphs : every billboard's image === aircraftIcon(classify(typeCode))
|
* E5 glyphs : exact classified silhouette at its registered 64/192 px raster
|
||||||
* E6 scales : every billboard's scale === CLASS_SCALE_2D[klass]
|
* E6 scales : every billboard's scale === CLASS_SCALE_2D[klass]
|
||||||
* E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s
|
* E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s
|
||||||
* drip), total ≤300 (budget ceiling)
|
* drip), total ≤300 (budget ceiling)
|
||||||
* E8 next poll : another poll neither reverts glyphs nor re-requests hexes
|
* E8 next poll : another poll neither reverts glyphs nor re-requests hexes
|
||||||
* E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes
|
* E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes
|
||||||
* stop being requested once the bucket is empty (shrunk via
|
* stop at the real 300-token initial ceiling; the QA seam
|
||||||
* the __GEV_ENRICH_AMBIENT_QA seam; a real 5-min window can't
|
* shortens only the refill window (a real 5-min window
|
||||||
* be waited out headlessly)
|
* cannot be waited out headlessly)
|
||||||
* E11 resume : after a refill window passes, ambient enrichment RESUMES
|
* E11 resume : after a refill window passes, ambient enrichment RESUMES
|
||||||
* for the still-unrequested planes (the 2026-07-03 field bug:
|
* for the still-unrequested planes (the 2026-07-03 field bug:
|
||||||
* the old one-shot session cap never refilled, so hours-long
|
* the old one-shot session cap never refilled, so hours-long
|
||||||
@@ -66,9 +66,14 @@ 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: Navara reads the layer through the QA
|
||||||
|
// probe (window.__godsEyeView.layers.drawn) and moves the engine camera.
|
||||||
|
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/enrich-ambient');
|
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/enrich-ambient' : `qa-shots/enrich-ambient-${ENGINE}`);
|
||||||
|
|
||||||
// Client-side bounds under test (flights.js constants):
|
// Client-side bounds under test (flights.js constants):
|
||||||
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
|
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
|
||||||
@@ -76,13 +81,13 @@ const DISPATCH_GAP_MS = 200; // ENRICH_DISPATCH_GAP_MS (≤5 req/s)
|
|||||||
const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check
|
const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check
|
||||||
const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling)
|
const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling)
|
||||||
|
|
||||||
// Rolling-budget QA seam (window.__GEV_ENRICH_AMBIENT_QA in flights.js):
|
// Rolling-budget QA seam: exercise the real initial ceiling and shorten
|
||||||
// shrunk so the exhaust→refill cycle is observable headlessly. windowMs starts
|
// only the refill window so the exhaust→refill cycle is observable. windowMs starts
|
||||||
// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can
|
// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can
|
||||||
// land mid-phase); E11 then shortens it in-page so refill windows have
|
// land mid-phase); E11 then shortens it in-page so refill windows have
|
||||||
// "passed" and the bucket refills (clamped at ceil). ceil=20 leaves 8 tokens
|
// "passed" and the bucket refills (clamped at ceil). The initial production
|
||||||
// after the 12 baseline planes — E10's batch exhausts those 8 and stalls.
|
// ceiling of 300 is exercised without assuming the override resets state.
|
||||||
const BUDGET_QA = { ceil: 20, refillTokens: 6, windowMs: 3600000 };
|
const BUDGET_QA = { ceil: SESSION_CAP, refillTokens: 6, windowMs: 3600000 };
|
||||||
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
|
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
|
||||||
|
|
||||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||||
@@ -160,6 +165,9 @@ const expected = new Map(Object.entries(TYPES).map(([hex, tc]) => [
|
|||||||
hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }),
|
hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }),
|
||||||
]));
|
]));
|
||||||
const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default
|
const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default
|
||||||
|
// Adaptive raster resolution preserves the classified silhouette. Compare
|
||||||
|
// exact registered assets at either supported resolution, never another class.
|
||||||
|
const matchesClassIcon = (image, klass) => image === aircraftIcon(klass) || image === aircraftIcon(klass, 192);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -185,7 +193,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',
|
||||||
@@ -208,6 +216,7 @@ async function main() {
|
|||||||
|
|
||||||
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
|
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
|
||||||
await page.evaluateOnNewDocument((spec) => {
|
await page.evaluateOnNewDocument((spec) => {
|
||||||
|
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||||
window.__ENR = spec;
|
window.__ENR = spec;
|
||||||
window.__ENR.epochMs = Date.now();
|
window.__ENR.epochMs = Date.now();
|
||||||
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
|
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
|
||||||
@@ -289,15 +298,24 @@ 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 }
|
{ timeout: 60000, 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 billboard probe (same walk as qa-sprites-b5.mjs) ----------
|
// ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ----------
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__collectBillboards = function () {
|
window.__collectBillboards = function () {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer;
|
||||||
|
// Without a viewer (Navara): what the flights layer draws, through
|
||||||
|
// the engine-neutral probe.
|
||||||
|
if (!v)
|
||||||
|
return (window.__godsEyeView.layers.drawn('flights') ?? [])
|
||||||
|
.filter((row) => !row.trackedModel)
|
||||||
|
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
|
||||||
const out = [];
|
const out = [];
|
||||||
const walk = (coll) => {
|
const walk = (coll) => {
|
||||||
const n = coll.length;
|
const n = coll.length;
|
||||||
@@ -321,12 +339,12 @@ async function main() {
|
|||||||
// framing the fleet when the layer polls — otherwise the frustum test
|
// framing the fleet when the layer polls — otherwise the frustum test
|
||||||
// (correctly) skips off-screen planes.
|
// (correctly) skips off-screen planes.
|
||||||
const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG;
|
const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG;
|
||||||
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: ROW_LAT, lon: centerLon, height: 20000 });
|
}, { lat: ROW_LAT, lon: centerLon, height: 20000 });
|
||||||
@@ -334,8 +352,9 @@ async function main() {
|
|||||||
console.log('Priming straight-flight history through the render delay (30 s)...');
|
console.log('Priming straight-flight history through the render delay (30 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;
|
||||||
window.__ENR.timeOffsetSec = -32;
|
window.__ENR.timeOffsetSec = -32;
|
||||||
|
dm.setLayerParams('flights', { models3d: false }); // ambient budget excludes model-priority enrichment
|
||||||
await dm.setEnabled('flights', true);
|
await dm.setEnabled('flights', true);
|
||||||
const fl = dm.layers.get('flights').module;
|
const fl = dm.layers.get('flights').module;
|
||||||
for (const off of [-24, -16, -8, 0]) {
|
for (const off of [-24, -16, -8, 0]) {
|
||||||
@@ -344,11 +363,11 @@ async function main() {
|
|||||||
}
|
}
|
||||||
window.__ENR.timeOffsetSec = 0;
|
window.__ENR.timeOffsetSec = 0;
|
||||||
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
|
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
|
||||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length };
|
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length, models3d: fl.getParams().models3d };
|
||||||
});
|
});
|
||||||
console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`);
|
console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`);
|
||||||
record('E1 ingest: all synthetic category-0 planes in the flights layer',
|
record('E1 ingest: all synthetic category-0 planes in the flights layer',
|
||||||
primed.count === SPEC.planes.length, `count=${primed.count}/${SPEC.planes.length}`);
|
primed.count === SPEC.planes.length && primed.models3d === false, `count=${primed.count}/${SPEC.planes.length}, ambient-only models3d=${primed.models3d}`);
|
||||||
if (primed.count === 0) { finish(); return; }
|
if (primed.count === 0) { finish(); return; }
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -414,12 +433,11 @@ async function main() {
|
|||||||
const scaleBad = [];
|
const scaleBad = [];
|
||||||
for (const [hex, klass] of expected) {
|
for (const [hex, klass] of expected) {
|
||||||
const bb = afterById.get(hex);
|
const bb = afterById.get(hex);
|
||||||
const wantImg = aircraftIcon(klass);
|
|
||||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
if (!bb || !matchesClassIcon(bb.image, klass)) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`);
|
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`);
|
||||||
}
|
}
|
||||||
record('E5 glyphs: every billboard matches aircraftIcon(classify(typeCode))',
|
record('E5 glyphs: every billboard matches its classified 64/192 px asset',
|
||||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`);
|
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`);
|
||||||
record('E6 scales: every billboard has its per-class CLASS_SCALE_2D',
|
record('E6 scales: every billboard has its per-class CLASS_SCALE_2D',
|
||||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)');
|
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)');
|
||||||
@@ -456,7 +474,7 @@ async function main() {
|
|||||||
console.log('\nE8 — next poll after enrichment');
|
console.log('\nE8 — next poll after enrichment');
|
||||||
await page.evaluate(async () => {
|
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;
|
||||||
await dm.layers.get('flights').module.update(v);
|
await dm.layers.get('flights').module.update(v);
|
||||||
});
|
});
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
@@ -468,7 +486,7 @@ async function main() {
|
|||||||
const pollBad = [];
|
const pollBad = [];
|
||||||
for (const [hex, klass] of expected) {
|
for (const [hex, klass] of expected) {
|
||||||
const bb = afterPollById.get(hex);
|
const bb = afterPollById.get(hex);
|
||||||
if (!bb || bb.image !== aircraftIcon(klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
|
if (!bb || !matchesClassIcon(bb.image, klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
|
||||||
else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`);
|
else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`);
|
||||||
}
|
}
|
||||||
const noReRequests = afterPoll.starts === Object.keys(TYPES).length;
|
const noReRequests = afterPoll.starts === Object.keys(TYPES).length;
|
||||||
@@ -480,20 +498,19 @@ async function main() {
|
|||||||
// E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the
|
// E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the
|
||||||
// old ONE-SHOT session cap burned out and never refilled, so every new
|
// old ONE-SHOT session cap burned out and never refilled, so every new
|
||||||
// region after the first polls showed airliner monoculture. With the seam
|
// region after the first polls showed airliner monoculture. With the seam
|
||||||
// ceil=20 and 12 tokens spent above, only 8 remain — a batch of 26 fresh
|
// initial ceiling=300 and 12 tokens spent above, 288 remain. More than
|
||||||
// on-screen planes must stall at exactly 8 new requests, and further polls
|
// 300 total candidates must stall at exactly 300 requests; further polls
|
||||||
// must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so
|
// must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so
|
||||||
// no refill can land mid-phase). Pre-fix code fails this phase: there is
|
// no refill can land mid-phase). E10 verifies the initial limit; E11
|
||||||
// no rolling bucket to exhaust (the seam is unread and the 300 cap simply
|
// distinguishes a rolling refill from the old one-shot session cap.
|
||||||
// admits the whole batch).
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
console.log('\nE10 — rolling budget exhausts (batch of 26 new planes, 8 tokens left)');
|
console.log('\nE10 — rolling budget exhausts (real 300-request initial bucket)');
|
||||||
const BATCH_COUNT = 26;
|
const BATCH_COUNT = BUDGET_QA.ceil + 26;
|
||||||
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => `ab00${(i + 1).toString(16).padStart(2, '0')}`);
|
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => (0xab0000 + i + 1).toString(16).padStart(6, '0'));
|
||||||
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
|
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
|
||||||
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
|
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
|
||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer ?? null;
|
||||||
// Two fresh rows just north of the original one — still inside the
|
// Two fresh rows just north of the original one — still inside the
|
||||||
// top-down 20 km frame, so the sweep's frustum test keeps them.
|
// top-down 20 km frame, so the sweep's frustum test keeps them.
|
||||||
// STATIONARY (speed 0): moving planes drift north out of the frustum at
|
// STATIONARY (speed 0): moving planes drift north out of the frustum at
|
||||||
@@ -506,33 +523,47 @@ async function main() {
|
|||||||
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
|
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
|
||||||
courseDeg: 0,
|
courseDeg: 0,
|
||||||
speedMps: 0,
|
speedMps: 0,
|
||||||
altM: 2600 + i * 15,
|
altM: 2600 + (i % 26) * 15,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
const engine = window.__godsEyeView.engine;
|
||||||
|
engine.camera.cancelFlight();
|
||||||
|
engine.camera.setView({
|
||||||
|
destination: geo.cartesian3.fromDegrees(rowLon0 + 5.5 * stepDeg, rowLat, 20000),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
for (let frame = 0; frame < 30; frame++) {
|
||||||
|
engine.render.request('qa-enrich-budget');
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
}
|
||||||
const fl = dm.layers.get('flights').module;
|
const fl = dm.layers.get('flights').module;
|
||||||
await fl.update(v); // sweep enqueues the last 8 tokens
|
await fl.update(v); // sweep enqueues up to 150 ambient jobs
|
||||||
await fl.update(v); // bucket empty — must enqueue nothing more
|
await fl.update(v); // spend the remaining initial tokens; later polls must stall
|
||||||
}, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG });
|
}, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG });
|
||||||
// Wait for the drip to drain whatever was enqueued, then settle.
|
// Wait for the drip to drain whatever was enqueued, then settle.
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0,
|
(min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0,
|
||||||
{ timeout: 20000, polling: 100 }, startsBeforeBatch + (BUDGET_QA.ceil - startsBeforeBatch)
|
{ timeout: 90000, polling: 100 }, BUDGET_QA.ceil
|
||||||
).catch(() => { /* asserted below */ });
|
).catch(() => { /* asserted below */ });
|
||||||
await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms
|
await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms
|
||||||
// One more poll while exhausted — still nothing new.
|
// One more poll while exhausted — still nothing new.
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
|
||||||
});
|
});
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
const exhausted = await page.evaluate(() => ({
|
const exhausted = await page.evaluate(() => ({
|
||||||
starts: window.__ENRICH_LOG.starts.length,
|
starts: window.__ENRICH_LOG.starts.length,
|
||||||
inflight: window.__ENRICH_LOG.inflight,
|
inflight: window.__ENRICH_LOG.inflight,
|
||||||
|
sourceCount: window.__godsEyeView.dataManager.layers.get('flights').module.getStats().count,
|
||||||
|
fixtureCount: window.__ENR.planes.length,
|
||||||
|
cameraHeightM: window.__godsEyeView.engine.camera.pose().cartographic.height,
|
||||||
}));
|
}));
|
||||||
record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`,
|
record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`,
|
||||||
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0,
|
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0 && exhausted.sourceCount === startsBeforeBatch + BATCH_COUNT,
|
||||||
`starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; `
|
`starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; `
|
||||||
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}`);
|
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}, source=${exhausted.sourceCount}/${exhausted.fixtureCount}, camera=${exhausted.cameraHeightM.toFixed(1)}m`);
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
|
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
|
||||||
@@ -544,7 +575,7 @@ async function main() {
|
|||||||
await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses
|
await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
|
||||||
});
|
});
|
||||||
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
|
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
|
||||||
const resumed = await page.waitForFunction(
|
const resumed = await page.waitForFunction(
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { installAlprFixture, cameras } from './fixtures/w2/alpr.mjs';
|
||||||
|
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/w2/firms.mjs';
|
||||||
|
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||||
|
const url = localPreview(process.argv[2]),
|
||||||
|
fire = process.argv.includes('--fires'),
|
||||||
|
id = fire ? 'local-firms' : 'alpr-cameras';
|
||||||
|
url.search = '?engine=navara&welcome=0';
|
||||||
|
url.hash =
|
||||||
|
'v=2&' +
|
||||||
|
(fire
|
||||||
|
? 'lat=37.77&lon=-122.42&alt=100000'
|
||||||
|
: 'lat=30.262&lon=-97.7431&alt=5500') +
|
||||||
|
'&pitch=-90&heading=0&map=sentinel2&l=';
|
||||||
|
// Records the fixtures produce: the valid FIRMS rows; the ALPR cameras the
|
||||||
|
// fixture tiles carry around Austin.
|
||||||
|
const COUNT = fire ? FIRMS_VALID_COUNT : cameras.length;
|
||||||
|
const strict = createStrict({
|
||||||
|
origin: url.origin,
|
||||||
|
layers: [id],
|
||||||
|
// installAlprFixture answers the camera tiles.
|
||||||
|
fixtures: ['https://tiles.dontgetflocked.com/*'],
|
||||||
|
});
|
||||||
|
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));
|
||||||
|
await installAlprFixture(p);
|
||||||
|
await p.evaluateOnNewDocument((data) => {
|
||||||
|
window.qaFirmsReads = 0;
|
||||||
|
const fetch = window.fetch.bind(window);
|
||||||
|
window.fetch = (input, opt) => {
|
||||||
|
if (
|
||||||
|
new URL(input.url ?? input, location.href).pathname === '/api/firms'
|
||||||
|
) {
|
||||||
|
window.qaFirmsReads++;
|
||||||
|
return Promise.resolve(Response.json(data));
|
||||||
|
}
|
||||||
|
return fetch(input, opt);
|
||||||
|
};
|
||||||
|
}, firmsFixture());
|
||||||
|
await p.goto(url.href);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||||
|
document.querySelector('#loading-screen.hidden'),
|
||||||
|
);
|
||||||
|
await p.evaluate(async (id) => {
|
||||||
|
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||||
|
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||||
|
throw Error('Keyless preview required');
|
||||||
|
await styleManager.initialRestorePromise;
|
||||||
|
window.qaGround = dataManager.layers.get(id).module;
|
||||||
|
await dataManager.setEnabled(id, true, { origin: 'user' });
|
||||||
|
}, id);
|
||||||
|
await p.waitForFunction(() => window.qaGround?.getStats().count > 0, {
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
// A pickable target in the middle of the view: the camera the engine's
|
||||||
|
// own picker returns, or the hottest fire.
|
||||||
|
const findTarget = () =>
|
||||||
|
p
|
||||||
|
.waitForFunction(
|
||||||
|
(fire) => {
|
||||||
|
const a = window.__godsEyeView,
|
||||||
|
m = window.qaGround;
|
||||||
|
const points = fire
|
||||||
|
? m
|
||||||
|
.getDetectableObjects({ maxCount: 1000 })
|
||||||
|
.filter((v) => v.id === m.getAnalystRecords(1)[0]?.id)
|
||||||
|
: [...window.__gevContextStore.entities.values()]
|
||||||
|
.filter((v) => v.layerId === 'alpr-cameras')
|
||||||
|
.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
position:
|
||||||
|
v.entity.gevAlprCanvasPosition ||
|
||||||
|
v.entity.gevDisplayPosition?.(),
|
||||||
|
}));
|
||||||
|
for (const row of points) {
|
||||||
|
if (!row.position) continue;
|
||||||
|
const s = a.engine.projector.worldToWindow(row.position);
|
||||||
|
if (!s || s.x < 430 || s.x > 850 || s.y < 220 || s.y > 600)
|
||||||
|
continue;
|
||||||
|
const canvas = a.engine.host.canvas;
|
||||||
|
const box = canvas.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
!canvas.contains(
|
||||||
|
document.elementFromPoint(box.left + s.x, box.top + s.y),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const picked = a.engine.picker.pick(s);
|
||||||
|
if (fire || picked?.id === row.id || picked?.id?.id === row.id)
|
||||||
|
return { x: box.left + s.x, y: box.top + s.y, id: row.id };
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
fire,
|
||||||
|
)
|
||||||
|
.then((handle) => handle.jsonValue());
|
||||||
|
const selected = (id) =>
|
||||||
|
p.waitForFunction(
|
||||||
|
(id, fire) =>
|
||||||
|
fire
|
||||||
|
? window.qaGround.getSelectedInfo()?.frp === 999
|
||||||
|
: window.__gevContextStore?.selectedEntityId === id,
|
||||||
|
{ timeout: 15000 },
|
||||||
|
id,
|
||||||
|
fire,
|
||||||
|
);
|
||||||
|
// A click on empty ground (nothing picked there, nothing covering it).
|
||||||
|
const clickEmpty = async () => {
|
||||||
|
const handle = await p.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const canvas = engine.host.canvas;
|
||||||
|
const box = canvas.getBoundingClientRect();
|
||||||
|
for (let y = 240; y < 600; y += 9)
|
||||||
|
for (let x = 440; x < 840; x += 9) {
|
||||||
|
if (
|
||||||
|
!canvas.contains(
|
||||||
|
document.elementFromPoint(box.left + x, box.top + y),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
let free = true;
|
||||||
|
for (const [dx, dy] of [
|
||||||
|
[0, 0],
|
||||||
|
[6, 0],
|
||||||
|
[-6, 0],
|
||||||
|
[0, 6],
|
||||||
|
[0, -6],
|
||||||
|
])
|
||||||
|
if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
|
||||||
|
if (free) return { x: box.left + x, y: box.top + y };
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
const at = await handle.jsonValue();
|
||||||
|
await p.mouse.click(at.x, at.y);
|
||||||
|
await p.waitForFunction(
|
||||||
|
(fire) =>
|
||||||
|
fire
|
||||||
|
? !window.qaGround.getSelectedInfo()
|
||||||
|
: !window.__gevContextStore?.selectedEntityId,
|
||||||
|
{ timeout: 15000 },
|
||||||
|
fire,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const xy = await findTarget();
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
await p.mouse.click(xy.x, xy.y);
|
||||||
|
await selected(xy.id);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
id,
|
||||||
|
fire
|
||||||
|
? 'a click on the hottest fire selects it'
|
||||||
|
: 'a click on a camera selects it',
|
||||||
|
xy,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 1600));
|
||||||
|
const expectedId = fire
|
||||||
|
? await p.evaluate(() => window.qaGround.getSelectedInfo().id)
|
||||||
|
: xy.id;
|
||||||
|
const before = await p.evaluate(() => ({
|
||||||
|
stamp: window.qaGround.getStats().lastUpdate,
|
||||||
|
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
|
||||||
|
reads: window.qaFirmsReads,
|
||||||
|
}));
|
||||||
|
for (const kind of ['cesium', 'navara']) {
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
const result = await p.evaluate(
|
||||||
|
(id) => ({
|
||||||
|
same:
|
||||||
|
window.qaGround ===
|
||||||
|
window.__godsEyeView.dataManager.layers.get(id).module,
|
||||||
|
selected: window.__gevContextStore.selectedEntityId,
|
||||||
|
record: window.qaGround.getSelectedInfo?.()?.id,
|
||||||
|
stamp: window.qaGround.getStats().lastUpdate,
|
||||||
|
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
|
||||||
|
reads: window.qaFirmsReads,
|
||||||
|
count: window.qaGround.getStats().count,
|
||||||
|
}),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
console.log(kind, id, result.selected, result.count, result.records.length);
|
||||||
|
assert.ok(result.same && result.count);
|
||||||
|
assert.equal(result.selected, expectedId);
|
||||||
|
if (fire) assert.equal(result.record, expectedId);
|
||||||
|
assert.equal(result.reads, before.reads);
|
||||||
|
assert.deepEqual(result.records, before.records);
|
||||||
|
assert.equal(result.stamp, before.stamp);
|
||||||
|
await strict.check(
|
||||||
|
p,
|
||||||
|
id,
|
||||||
|
`same model, ${COUNT} records, the clicked record still selected, no source read, same receipt time`,
|
||||||
|
result.count === COUNT,
|
||||||
|
{ count: result.count },
|
||||||
|
);
|
||||||
|
if (kind === 'cesium') {
|
||||||
|
// Click-picking on Cesium: clear the selection with a click on empty
|
||||||
|
// ground, then click the same record again.
|
||||||
|
await clickEmpty();
|
||||||
|
const again = await findTarget();
|
||||||
|
assert.equal(again.id, xy.id);
|
||||||
|
await p.mouse.click(again.x, again.y);
|
||||||
|
await selected(again.id);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
id,
|
||||||
|
fire
|
||||||
|
? 'a click on empty ground clears the fire; a click on it selects it again'
|
||||||
|
: 'a click on empty ground clears the camera; a click on it selects it again',
|
||||||
|
again,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log('PASS retained source, stable clicked record on each planet');
|
||||||
|
});
|
||||||
+73
-16
@@ -30,22 +30,36 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import {
|
||||||
|
isExpectedPreviewSetupError,
|
||||||
|
redact,
|
||||||
|
} from './qa-browserEvidence.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';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const getOpt = (name, dflt) => {
|
const getOpt = (name, dflt) => {
|
||||||
const i = argv.indexOf(name);
|
const i = argv.indexOf(name);
|
||||||
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:4420');
|
const appUrl = new URL(getOpt('--url', 'http://localhost:4420'));
|
||||||
|
const ENGINE = getOpt('--engine', appUrl.searchParams.get('engine') || 'cesium');
|
||||||
|
if (!['cesium', 'navara'].includes(ENGINE)) throw new Error('Unknown engine');
|
||||||
|
appUrl.searchParams.set('engine', ENGINE);
|
||||||
|
const APP_URL = appUrl.href;
|
||||||
const HEADFUL = argv.includes('--headful');
|
const HEADFUL = argv.includes('--headful');
|
||||||
const FIXTURES = argv.includes('--fixtures');
|
const FIXTURES = argv.includes('--fixtures');
|
||||||
|
const PREVIEW = argv.includes('--preview');
|
||||||
|
const ANGLE = getOpt('--angle', 'swiftshader');
|
||||||
|
const OUT = getOpt('--out', null);
|
||||||
|
const SHOTS_DIR = OUT ? path.resolve(OUT).replace(/\.json$/, '') + '-shots' : path.join(REPO_ROOT, 'qa-shots');
|
||||||
|
if (!['metal', 'swiftshader'].includes(ANGLE)) throw new Error('Unknown ANGLE backend');
|
||||||
|
const runtimeErrors = [];
|
||||||
|
const expectedSourceErrors = [];
|
||||||
|
|
||||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||||
@@ -157,9 +171,11 @@ async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {
|
|||||||
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
||||||
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?.viewer && window.__godsEyeView?.dataManager,
|
() => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
|
||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
|
const actualEngine = await page.evaluate(() => window.__godsEyeView.engine.kind);
|
||||||
|
if (actualEngine !== ENGINE) throw new Error('Requested FIRMS engine did not load');
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
return page.evaluate(async (tS) => {
|
return page.evaluate(async (tS) => {
|
||||||
@@ -178,8 +194,15 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
|||||||
|
|
||||||
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
|
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
|
||||||
async function setView(page, lon, lat, height) {
|
async function setView(page, lon, lat, height) {
|
||||||
await page.evaluate((lo, la, h) => {
|
await page.evaluate(async (lo, la, h) => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
|
if (!gev.viewer) {
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
gev.engine.camera.cancelFlight();
|
||||||
|
gev.engine.camera.setView({destination:geo.cartesian3.fromDegrees(lo,la,h),orientation:{heading:0,pitch:-Math.PI/2,roll:0}});
|
||||||
|
gev.engine.render.request('firms-legacy-view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||||
const d2r = Math.PI / 180;
|
const d2r = Math.PI / 180;
|
||||||
// The app's intro flyTo animation clobbers a setView issued mid-flight.
|
// The app's intro flyTo animation clobbers a setView issued mid-flight.
|
||||||
@@ -251,7 +274,7 @@ async function main() {
|
|||||||
headless: HEADFUL ? false : 'new',
|
headless: HEADFUL ? false : 'new',
|
||||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', `--use-angle=${ANGLE}`,
|
||||||
'--disable-dev-shm-usage', '--disable-web-security',
|
'--disable-dev-shm-usage', '--disable-web-security',
|
||||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||||
'--window-size=1440,900',
|
'--window-size=1440,900',
|
||||||
@@ -265,6 +288,23 @@ async function main() {
|
|||||||
|
|
||||||
await page.setRequestInterception(true);
|
await page.setRequestInterception(true);
|
||||||
let interceptMode = FIXTURES ? 'fixture' : 'live';
|
let interceptMode = FIXTURES ? 'fixture' : 'live';
|
||||||
|
page.on('pageerror', error => runtimeErrors.push(redact(error.message)));
|
||||||
|
page.on('error', error => runtimeErrors.push(redact(error.message)));
|
||||||
|
page.on('console', message => {
|
||||||
|
if (message.type() !== 'error') return;
|
||||||
|
if (isExpectedPreviewSetupError(message, { preview: PREVIEW, baseUrl: APP_URL })) {
|
||||||
|
expectedSourceErrors.push('/api/setup/status: 404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let expected = false;
|
||||||
|
try {
|
||||||
|
const url = new URL(message.location()?.url);
|
||||||
|
expected = interceptMode === 'keyless' && url.origin === new URL(APP_URL).origin
|
||||||
|
&& url.pathname === '/api/firms' && /^Failed to load resource:.*\b503\b/.test(message.text());
|
||||||
|
} catch { /* an unknown location is never waived */ }
|
||||||
|
if (expected) expectedSourceErrors.push('/api/firms: intentional keyless fixture503');
|
||||||
|
else runtimeErrors.push(redact(message.text()));
|
||||||
|
});
|
||||||
const stableStalePayload = stalePayload();
|
const stableStalePayload = stalePayload();
|
||||||
page.on('request', (req) => {
|
page.on('request', (req) => {
|
||||||
const url = req.url();
|
const url = req.url();
|
||||||
@@ -386,17 +426,22 @@ async function main() {
|
|||||||
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
|
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
|
||||||
if (!target?.position) return null;
|
if (!target?.position) return null;
|
||||||
|
|
||||||
const sentinel = gev.viewer.entities.add({
|
const native = Boolean(gev.viewer);
|
||||||
|
const sentinel = native ? gev.viewer.entities.add({
|
||||||
id: 'qa-firms-prior-camera-owner',
|
id: 'qa-firms-prior-camera-owner',
|
||||||
position: target.position,
|
position: target.position,
|
||||||
});
|
}) : gev.engine.camera.follow({position:()=>target.position,viewFrom:{x:0,y:-5000,z:8000},
|
||||||
gev.viewer.trackedEntity = sentinel;
|
label:{id:'qa-firms-prior-camera-owner',model:{title:'QA prior camera owner'},position:()=>target.position}});
|
||||||
|
if (native) gev.viewer.trackedEntity = sentinel;
|
||||||
|
|
||||||
const camera = gev.viewer.camera;
|
const camera = native ? gev.viewer.camera : gev.engine.camera;
|
||||||
const originalFly = camera.flyToBoundingSphere;
|
const flightMethod = native ? 'flyToBoundingSphere' : 'flyToSphere';
|
||||||
|
const originalFly = camera[flightMethod];
|
||||||
const proof = {
|
const proof = {
|
||||||
sentinel,
|
sentinel,
|
||||||
originalFly,
|
originalFly,
|
||||||
|
flightMethod,
|
||||||
|
native,
|
||||||
flightCount: 0,
|
flightCount: 0,
|
||||||
trackedAtFlight: 'not-called',
|
trackedAtFlight: 'not-called',
|
||||||
request: null,
|
request: null,
|
||||||
@@ -409,9 +454,9 @@ async function main() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
window.addEventListener('gev:world-request-focus', proof.onRequest);
|
window.addEventListener('gev:world-request-focus', proof.onRequest);
|
||||||
camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) {
|
camera[flightMethod] = function qaFirmsObservedFlight(...args) {
|
||||||
proof.flightCount += 1;
|
proof.flightCount += 1;
|
||||||
proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null;
|
proof.trackedAtFlight = native ? gev.viewer.trackedEntity?.id || null : gev.engine.camera.following()?.label?.id || null;
|
||||||
return originalFly.apply(this, args);
|
return originalFly.apply(this, args);
|
||||||
};
|
};
|
||||||
window.__qaFirmsActionProof = proof;
|
window.__qaFirmsActionProof = proof;
|
||||||
@@ -433,7 +478,7 @@ async function main() {
|
|||||||
|
|
||||||
for (let i = 0; i < 30; i += 1) {
|
for (let i = 0; i < 30; i += 1) {
|
||||||
const settled = await page.evaluate(() => {
|
const settled = await page.evaluate(() => {
|
||||||
window.__godsEyeView?.viewer?.scene?.requestRender?.();
|
window.__godsEyeView?.engine?.render?.request?.('firms-legacy');
|
||||||
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
|
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
|
||||||
});
|
});
|
||||||
if (settled) break;
|
if (settled) break;
|
||||||
@@ -451,7 +496,7 @@ async function main() {
|
|||||||
const result = {
|
const result = {
|
||||||
flightCount: state?.flightCount || 0,
|
flightCount: state?.flightCount || 0,
|
||||||
trackedAtFlight: state?.trackedAtFlight,
|
trackedAtFlight: state?.trackedAtFlight,
|
||||||
trackingReleased: gev.viewer.trackedEntity == null,
|
trackingReleased: gev.viewer ? gev.viewer.trackedEntity == null : gev.engine.camera.following() == null,
|
||||||
generationBefore: state?.generationBefore,
|
generationBefore: state?.generationBefore,
|
||||||
generationAfter: gev.styleManager._navigationGeneration,
|
generationAfter: gev.styleManager._navigationGeneration,
|
||||||
request: state?.request || null,
|
request: state?.request || null,
|
||||||
@@ -461,9 +506,12 @@ async function main() {
|
|||||||
).length,
|
).length,
|
||||||
};
|
};
|
||||||
if (state) {
|
if (state) {
|
||||||
gev.viewer.camera.flyToBoundingSphere = state.originalFly;
|
(state.native ? gev.viewer.camera : gev.engine.camera)[state.flightMethod] = state.originalFly;
|
||||||
window.removeEventListener('gev:world-request-focus', state.onRequest);
|
window.removeEventListener('gev:world-request-focus', state.onRequest);
|
||||||
if (state.sentinel) gev.viewer.entities.remove(state.sentinel);
|
if (state.sentinel) {
|
||||||
|
if (state.native) gev.viewer.entities.remove(state.sentinel);
|
||||||
|
else state.sentinel.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
delete window.__qaFirmsActionProof;
|
delete window.__qaFirmsActionProof;
|
||||||
return result;
|
return result;
|
||||||
@@ -513,12 +561,21 @@ async function main() {
|
|||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record('RUNTIME: no unexpected browser errors', runtimeErrors.length === 0,
|
||||||
|
runtimeErrors.length ? JSON.stringify(runtimeErrors) : `expected source errors=${expectedSourceErrors.length}`);
|
||||||
|
if (runtimeErrors.length) exitCode = 1;
|
||||||
const pass = results.filter((r) => r.ok === true).length;
|
const pass = results.filter((r) => r.ok === true).length;
|
||||||
const fail = results.filter((r) => r.ok === false).length;
|
const fail = results.filter((r) => r.ok === false).length;
|
||||||
console.log('\n' + '─'.repeat(60));
|
console.log('\n' + '─'.repeat(60));
|
||||||
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||||
console.log(` Shots : ${SHOTS_DIR}/firms-*.png`);
|
console.log(` Shots : ${SHOTS_DIR}/firms-*.png`);
|
||||||
console.log('─'.repeat(60) + '\n');
|
console.log('─'.repeat(60) + '\n');
|
||||||
|
if (OUT) {
|
||||||
|
const file = path.resolve(OUT);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, JSON.stringify({ pass:exitCode === 0 && fail === 0, fixture:FIXTURES, angle:ANGLE, engine:ENGINE,
|
||||||
|
shots:SHOTS_DIR, results, browserErrors:runtimeErrors, expectedSourceErrors }, null, 2) + '\n');
|
||||||
|
}
|
||||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const SITE = {
|
|||||||
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
|
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
|
||||||
};
|
};
|
||||||
const ICAO = 'aa0f01';
|
const ICAO = 'aa0f01';
|
||||||
const OUT_DIR = 'qa-shots/floorhold';
|
const OUT_DIR = new URL(APP_URL).searchParams.get('engine') === 'navara' ? 'qa-shots/floorhold/navara' : 'qa-shots/floorhold';
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -107,7 +107,7 @@ await page.evaluateOnNewDocument((site, icao) => {
|
|||||||
|
|
||||||
console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU : ANGLE/${ANGLE}\n Site : ${SITE.lat}, ${SITE.lon}\n`);
|
console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU : ANGLE/${ANGLE}\n Site : ${SITE.lat}, ${SITE.lon}\n`);
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||||
await page.waitForFunction(() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
await page.waitForFunction(() => window.__godsEyeView?.layers && window.__godsEyeView?.dataManager,
|
||||||
{ timeout: 150000 });
|
{ timeout: 150000 });
|
||||||
await sleep(12000); // let the boot fly-to settle before pinning
|
await sleep(12000); // let the boot fly-to settle before pinning
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -116,8 +116,19 @@ await page.evaluate(() => {
|
|||||||
await sleep(600); // let the launcher dismissal finish before taking evidence
|
await sleep(600); // let the launcher dismissal finish before taking evidence
|
||||||
|
|
||||||
await page.evaluate((site) => { window.__SITE = site; }, SITE);
|
await page.evaluate((site) => { window.__SITE = site; }, SITE);
|
||||||
const pin = () => page.evaluate(() => {
|
const pin = () => page.evaluate(async () => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const app = window.__godsEyeView;
|
||||||
|
if (!app.viewer) {
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
app.engine.camera.cancelFlight();
|
||||||
|
app.engine.camera.setView({
|
||||||
|
destination: geo.cartesian3.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900),
|
||||||
|
orientation: { heading: 0, pitch: -0.45, roll: 0 },
|
||||||
|
});
|
||||||
|
app.engine.camera.moveEnd.raiseEvent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const v = app.viewer;
|
||||||
const C = v.camera.positionCartographic.constructor;
|
const C = v.camera.positionCartographic.constructor;
|
||||||
v.camera.cancelFlight();
|
v.camera.cancelFlight();
|
||||||
v.camera.setView({
|
v.camera.setView({
|
||||||
@@ -142,7 +153,24 @@ record('the billboard floor test is explicitly in 2D aircraft mode', billboardMo
|
|||||||
|
|
||||||
/** Reads the contact's rendered height and the rendered mesh beneath it. */
|
/** Reads the contact's rendered height and the rendered mesh beneath it. */
|
||||||
const measure = () => page.evaluate(async (icao) => {
|
const measure = () => page.evaluate(async (icao) => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const app = window.__godsEyeView;
|
||||||
|
if (!app.viewer) {
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
const bb = app.layers.drawn('flights').find((row) => row.id === icao && !row.trackedModel);
|
||||||
|
if (!bb?.position) return { error: 'contact billboard not found' };
|
||||||
|
const carto = geo.cartographic.fromCartesian(bb.position);
|
||||||
|
let mesh = null;
|
||||||
|
for (let attempt = 0; attempt < 10 && mesh == null; attempt++) {
|
||||||
|
app.engine.render.request('floor-hold');
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
const height = app.engine.surface.sampleHeight(carto);
|
||||||
|
if (Number.isFinite(height)) mesh = height;
|
||||||
|
else await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
}
|
||||||
|
return { spriteH: carto.height, meshH: mesh, shown: bb.show === true,
|
||||||
|
lat: +(carto.latitude * 180 / Math.PI).toFixed(5), lon: +(carto.longitude * 180 / Math.PI).toFixed(5), terrain: { ...window.__TERRAIN } };
|
||||||
|
}
|
||||||
|
const v = app.viewer;
|
||||||
const C = v.camera.positionCartographic.constructor;
|
const C = v.camera.positionCartographic.constructor;
|
||||||
const sprites = []; const models = []; let bb = null;
|
const sprites = []; const models = []; let bb = null;
|
||||||
const walk = (coll) => {
|
const walk = (coll) => {
|
||||||
@@ -201,7 +229,9 @@ async function demTruth(lat, lon) {
|
|||||||
if (truth.has(key)) return truth.get(key);
|
if (truth.has(key)) return truth.get(key);
|
||||||
let h = null;
|
let h = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${APP_URL}/api/terrain/heights?points=${lon.toFixed(5)},${lat.toFixed(5)}`);
|
const url = new URL('/api/terrain/heights', APP_URL);
|
||||||
|
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||||
|
const res = await fetch(url);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
|
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
|
||||||
if (Number.isFinite(v)) h = v;
|
if (Number.isFinite(v)) h = v;
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import fs from 'node:fs';
|
|||||||
|
|
||||||
// QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so a
|
// QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so a
|
||||||
// secondary checkout can verify against its own dev server instead of the default :4173.
|
// secondary checkout can verify against its own dev server instead of the default :4173.
|
||||||
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||||
|
// --engine=cesium|navara: on Navara the camera, the mesh probes and
|
||||||
|
// the sprite census go through the engine and the QA probe.
|
||||||
|
const ENGINE = (process.argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
|
||||||
|
const APP_URL =
|
||||||
|
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
|
||||||
// CLI: --lat --lon --floor-min --floor-max (defaults: Austin airport)
|
// CLI: --lat --lon --floor-min --floor-max (defaults: Austin airport)
|
||||||
const argv = Object.fromEntries(process.argv.slice(2).map((a) => a.split('=')).filter((x) => x.length === 2).map(([k, v]) => [k.replace(/^--/, ''), Number(v)]));
|
const argv = Object.fromEntries(process.argv.slice(2).map((a) => a.split('=')).filter((x) => x.length === 2).map(([k, v]) => [k.replace(/^--/, ''), Number(v)]));
|
||||||
const SITE = {
|
const SITE = {
|
||||||
@@ -32,13 +37,13 @@ const chrome = [
|
|||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
headless: 'new', executablePath: chrome,
|
headless: 'new', executablePath: chrome,
|
||||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
args: ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`, '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setViewport({ width: 1400, height: 900 });
|
await page.setViewport({ width: 1400, height: 900 });
|
||||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.viewer, { timeout: 150000 });
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.engine, { timeout: 150000 });
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
// Let the app's boot fly-to fully settle FIRST (it was overriding an early
|
// Let the app's boot fly-to fully settle FIRST (it was overriding an early
|
||||||
@@ -46,8 +51,16 @@ await sleep(2000);
|
|||||||
// coarse global tiles at ~25 m and the oracle was garbage).
|
// coarse global tiles at ~25 m and the oracle was garbage).
|
||||||
await sleep(12000);
|
await sleep(12000);
|
||||||
await page.evaluate((site) => { window.__QA_SITE = site; }, SITE);
|
await page.evaluate((site) => { window.__QA_SITE = site; }, SITE);
|
||||||
const camPin = async () => page.evaluate(() => {
|
const camPin = async () => page.evaluate(async () => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer;
|
||||||
|
if (!v) {
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
const { camera } = window.__godsEyeView.engine;
|
||||||
|
camera.cancelFlight();
|
||||||
|
camera.setView({ destination: geo.cartesian3.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000) });
|
||||||
|
const c = camera.pose().cartographic;
|
||||||
|
return { lat: +(c.latitude * 180 / Math.PI).toFixed(3), lon: +(c.longitude * 180 / Math.PI).toFixed(3), h: Math.round(c.height) };
|
||||||
|
}
|
||||||
const C = v.camera.positionCartographic.constructor;
|
const C = v.camera.positionCartographic.constructor;
|
||||||
v.camera.cancelFlight();
|
v.camera.cancelFlight();
|
||||||
v.camera.setView({ destination: v.scene.globe.ellipsoid.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000)) });
|
v.camera.setView({ destination: v.scene.globe.ellipsoid.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000)) });
|
||||||
@@ -63,6 +76,7 @@ await sleep(100000);
|
|||||||
console.log('camera at probe time:', JSON.stringify(await camPin()));
|
console.log('camera at probe time:', JSON.stringify(await camPin()));
|
||||||
const ready = await page.evaluate(() => {
|
const ready = await page.evaluate(() => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer;
|
||||||
|
if (!v) return { tilesLoaded: window.__godsEyeView.engine.surface.tilesLoaded() };
|
||||||
const prims = v.scene.primitives;
|
const prims = v.scene.primitives;
|
||||||
for (let i = 0; i < prims.length; i++) {
|
for (let i = 0; i < prims.length; i++) {
|
||||||
const p = prims.get(i);
|
const p = prims.get(i);
|
||||||
@@ -73,11 +87,60 @@ const ready = await page.evaluate(() => {
|
|||||||
console.log('tileset ready:', JSON.stringify(ready));
|
console.log('tileset ready:', JSON.stringify(ready));
|
||||||
await sleep(8000);
|
await sleep(8000);
|
||||||
|
|
||||||
const report = await page.evaluate(() => {
|
const report = await page.evaluate(async () => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const v = gev.viewer;
|
const v = gev.viewer;
|
||||||
const layer = gev.dataManager.layers.get('flights')?.module;
|
const layer = gev.dataManager.layers.get('flights')?.module;
|
||||||
if (!layer) return { err: 'no flights module' };
|
if (!layer) return { err: 'no flights module' };
|
||||||
|
if (!v) {
|
||||||
|
// The engine path: mesh probes through engine.surface, excluding the
|
||||||
|
// layer's own mover sets; the census from the probe's drawn().
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
const { engine } = gev;
|
||||||
|
const center = geo.cartesian3.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200);
|
||||||
|
const nearby = layer.getNearby(center, 15000, 60);
|
||||||
|
const exclude = engine.movers.list().map(({ id }) => id).filter((id) => id.startsWith('flights'));
|
||||||
|
// The app's own ground floor (the scene's surface services, not a fresh
|
||||||
|
// import of the module).
|
||||||
|
const { cachedGroundFloor } = gev.surfaceServices.groundFloor;
|
||||||
|
const out = [];
|
||||||
|
// A contact a 3D model draws is measured where the model is (its sprite
|
||||||
|
// is hidden and keeps the pre-floor height by design, T7).
|
||||||
|
const drawnRows = new Map((gev.layers.drawn('flights') ?? []).map((row) => [row.id, row]));
|
||||||
|
const drawnAt = (p) => {
|
||||||
|
const model = drawnRows.get(p.icao24 ?? p.id)?.model;
|
||||||
|
return model?.show && model.ready && model.position ? model.position : null;
|
||||||
|
};
|
||||||
|
for (const p of nearby) {
|
||||||
|
const modelAt = drawnAt(p);
|
||||||
|
const c = geo.cartographic.fromCartesian(modelAt ?? p.position);
|
||||||
|
const answer = engine.surface.sampleHeightDetailed(
|
||||||
|
geo.cartographic.fromRadians(c.longitude, c.latitude, 0),
|
||||||
|
{ exclude },
|
||||||
|
);
|
||||||
|
const meshH = answer?.source === 'tiles' && Number.isFinite(answer.height) ? answer.height : null;
|
||||||
|
out.push({
|
||||||
|
id: p.id,
|
||||||
|
renderAltM: +c.height.toFixed(1),
|
||||||
|
drawnBy: modelAt ? 'model' : 'sprite',
|
||||||
|
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
||||||
|
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
|
||||||
|
// Why a contact sits where it does: the probe's own
|
||||||
|
// answer (source, tile size) and the coarse DEM floor (the prior).
|
||||||
|
probe: answer ? { source: answer.source, tileSizeM: answer.tileSizeM ?? null } : null,
|
||||||
|
demFloorM: cachedGroundFloor(c.latitude * 180 / Math.PI, c.longitude * 180 / Math.PI) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let shown = 0, hidden = 0;
|
||||||
|
for (const row of gev.layers.drawn('flights') ?? []) {
|
||||||
|
if (!row.position) continue;
|
||||||
|
const cc = geo.cartographic.fromCartesian(row.position);
|
||||||
|
const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI;
|
||||||
|
if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue;
|
||||||
|
if (row.show) shown++; else hidden++;
|
||||||
|
}
|
||||||
|
return { ausContacts: out.length, contacts: out.slice(0, 16), spritesShown: shown, spritesHidden: hidden };
|
||||||
|
}
|
||||||
const ell = v.scene.globe.ellipsoid;
|
const ell = v.scene.globe.ellipsoid;
|
||||||
const C = v.camera.positionCartographic.constructor;
|
const C = v.camera.positionCartographic.constructor;
|
||||||
const center = ell.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200));
|
const center = ell.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200));
|
||||||
@@ -101,9 +164,15 @@ const report = await page.evaluate(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
walk(v.scene.primitives);
|
walk(v.scene.primitives);
|
||||||
|
const drawnRows = new Map((gev.layers?.drawn('flights') ?? []).map((row) => [row.id, row]));
|
||||||
|
const drawnAt = (p) => {
|
||||||
|
const model = drawnRows.get(p.icao24 ?? p.id)?.model;
|
||||||
|
return model?.show && model.ready && model.position ? model.position : null;
|
||||||
|
};
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const p of nearby) {
|
for (const p of nearby) {
|
||||||
const c = ell.cartesianToCartographic(p.position);
|
const modelAt = drawnAt(p);
|
||||||
|
const c = ell.cartesianToCartographic(modelAt ?? p.position);
|
||||||
const latDeg = c.latitude * 180 / Math.PI, lonDeg = c.longitude * 180 / Math.PI;
|
const latDeg = c.latitude * 180 / Math.PI, lonDeg = c.longitude * 180 / Math.PI;
|
||||||
let meshH = null;
|
let meshH = null;
|
||||||
try {
|
try {
|
||||||
@@ -113,8 +182,16 @@ const report = await page.evaluate(() => {
|
|||||||
out.push({
|
out.push({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
renderAltM: +c.height.toFixed(1),
|
renderAltM: +c.height.toFixed(1),
|
||||||
|
drawnBy: modelAt ? 'model' : 'sprite',
|
||||||
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
||||||
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
|
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
|
||||||
|
// A contact whose sprite hides under a model that is not (yet) drawn.
|
||||||
|
model: drawnRows.get(p.icao24 ?? p.id)?.model
|
||||||
|
? { show: drawnRows.get(p.icao24 ?? p.id).model.show, ready: drawnRows.get(p.icao24 ?? p.id).model.ready }
|
||||||
|
: null,
|
||||||
|
spriteShown: drawnRows.get(p.icao24 ?? p.id)?.show ?? null,
|
||||||
|
// The coarse floor (mesh cell ?? DEM prior) the layer reads here.
|
||||||
|
demFloorM: gev.surfaceServices?.groundFloor.cachedGroundFloor(latDeg, lonDeg) ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Visibility census (round 6): getNearby only returns contacts a sprite or a
|
// Visibility census (round 6): getNearby only returns contacts a sprite or a
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
* --headful drops those flags and is the real-GPU visual sign-off path.
|
* --headful drops those flags and is the real-GPU visual sign-off path.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { redact } from './qa-browserEvidence.mjs';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -29,7 +30,13 @@ const getOpt = (name, fallback = null) => {
|
|||||||
};
|
};
|
||||||
const hasFlag = (name) => argv.includes(name);
|
const hasFlag = (name) => argv.includes(name);
|
||||||
|
|
||||||
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 JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
|
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
|
||||||
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
|
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
|
||||||
const HEADFUL = hasFlag('--headful');
|
const HEADFUL = hasFlag('--headful');
|
||||||
@@ -125,6 +132,10 @@ async function readTileReadiness(page) {
|
|||||||
const activeStack = controller?.getActiveId?.() || null;
|
const activeStack = controller?.getActiveId?.() || null;
|
||||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||||
|
if (!gev.viewer) return {
|
||||||
|
tilesSettled: null, tileSettleApplicable: false, activeStack,
|
||||||
|
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
tilesSettled: !applicable || tileset?.tilesLoaded === true,
|
tilesSettled: !applicable || tileset?.tilesLoaded === true,
|
||||||
tileSettleApplicable: applicable,
|
tileSettleApplicable: applicable,
|
||||||
@@ -142,6 +153,10 @@ async function awaitTilesSettled(page, scenario) {
|
|||||||
// the bootstrap field is retained only as a compatibility fallback.
|
// the bootstrap field is retained only as a compatibility fallback.
|
||||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||||
|
if (!gev.viewer) return {
|
||||||
|
tilesSettled: null, tileSettleApplicable: false, activeStack,
|
||||||
|
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
|
||||||
|
};
|
||||||
if (!applicable) {
|
if (!applicable) {
|
||||||
return {
|
return {
|
||||||
tilesSettled: true,
|
tilesSettled: true,
|
||||||
@@ -166,7 +181,8 @@ async function awaitTilesSettled(page, scenario) {
|
|||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
try {
|
try {
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
if (gev.viewer) gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||||
|
else gev.engine.render.request('focus-evidence');
|
||||||
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
|
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
|
||||||
return {
|
return {
|
||||||
tilesSettled: true,
|
tilesSettled: true,
|
||||||
@@ -203,7 +219,7 @@ async function capture(page, scenario, frame) {
|
|||||||
// WebGL surface is paused. Reading the explicitly rendered canvas captures
|
// WebGL surface is paused. Reading the explicitly rendered canvas captures
|
||||||
// that same frame without handing frame ownership back to the browser.
|
// that same frame without handing frame ownership back to the browser.
|
||||||
const dataUrl = await page.evaluate(() => (
|
const dataUrl = await page.evaluate(() => (
|
||||||
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
|
window.__godsEyeView.engine.host.canvas.toDataURL('image/png')
|
||||||
));
|
));
|
||||||
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
|
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
|
||||||
return { file: out, ...tileReadiness };
|
return { file: out, ...tileReadiness };
|
||||||
@@ -232,6 +248,14 @@ async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
|
|||||||
const viewer = gev.viewer;
|
const viewer = gev.viewer;
|
||||||
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
|
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
|
||||||
const nowMs = seam.advanceFrameClock(stepMs);
|
const nowMs = seam.advanceFrameClock(stepMs);
|
||||||
|
if (!viewer) return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => { remove(); reject(new Error('Evidence frame timeout')); }, 5000);
|
||||||
|
const remove = gev.engine.frames.postRender.addEventListener(() => {
|
||||||
|
remove(); clearTimeout(timer);
|
||||||
|
setTimeout(() => resolve(nowMs), 0);
|
||||||
|
});
|
||||||
|
gev.engine.render.request('focus-evidence');
|
||||||
|
});
|
||||||
const JulianDate = viewer.clock.currentTime.constructor;
|
const JulianDate = viewer.clock.currentTime.constructor;
|
||||||
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
|
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
|
||||||
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
|
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
|
||||||
@@ -266,6 +290,7 @@ async function installSyntheticFetches(page) {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}));
|
}));
|
||||||
|
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||||
window.fetch = (input, init) => {
|
window.fetch = (input, init) => {
|
||||||
const url = typeof input === 'string' ? input : input?.url || '';
|
const url = typeof input === 'string' ? input : input?.url || '';
|
||||||
if (url.includes('/api/flights/track')) return json({ path: [] });
|
if (url.includes('/api/flights/track')) return json({ path: [] });
|
||||||
@@ -281,9 +306,10 @@ async function installSyntheticFetches(page) {
|
|||||||
async function waitForApp(page) {
|
async function waitForApp(page) {
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
|
||||||
{ timeout: 60_000, polling: 200 },
|
{ timeout: 60_000, polling: 200 },
|
||||||
);
|
);
|
||||||
|
await page.evaluate(async () => { window.__focusGeo = await import('/src/geo/index.js'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setBasemap(page, stackId) {
|
async function setBasemap(page, stackId) {
|
||||||
@@ -317,7 +343,18 @@ async function requireEvidenceSeams(page) {
|
|||||||
|
|
||||||
async function setCamera(page, values) {
|
async function setCamera(page, values) {
|
||||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const app = window.__godsEyeView;
|
||||||
|
if (!app.viewer) {
|
||||||
|
const geo = window.__focusGeo;
|
||||||
|
app.engine.camera.following()?.stop();
|
||||||
|
app.engine.camera.cancelFlight();
|
||||||
|
app.engine.camera.setView({
|
||||||
|
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||||
|
orientation: { heading, pitch, roll: 0 },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewer = app.viewer;
|
||||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||||
viewer.trackedEntity = undefined;
|
viewer.trackedEntity = undefined;
|
||||||
viewer.camera.cancelFlight?.();
|
viewer.camera.cancelFlight?.();
|
||||||
@@ -345,7 +382,7 @@ async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
|
|||||||
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
|
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
|
||||||
}
|
}
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
|
() => Boolean(window.__godsEyeView.engine.camera.following()),
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
);
|
);
|
||||||
await advanceEvidenceDuration(page, 400);
|
await advanceEvidenceDuration(page, 400);
|
||||||
@@ -356,7 +393,8 @@ async function readTrackedBasis(page) {
|
|||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const layer = gev.dataManager.layers.get('flights').module;
|
const layer = gev.dataManager.layers.get('flights').module;
|
||||||
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
|
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
|
||||||
const camera = gev.viewer.camera;
|
const pose = gev.engine.camera.pose();
|
||||||
|
const camera = gev.viewer?.camera ?? { positionWC: pose.position, rightWC: pose.right, upWC: pose.up, frustum: { fovy: pose.fovy } };
|
||||||
if (!target) throw new Error('Tracked target position unavailable');
|
if (!target) throw new Error('Tracked target position unavailable');
|
||||||
const toTarget = {
|
const toTarget = {
|
||||||
x: target.x - camera.positionWC.x,
|
x: target.x - camera.positionWC.x,
|
||||||
@@ -364,7 +402,7 @@ async function readTrackedBasis(page) {
|
|||||||
z: target.z - camera.positionWC.z,
|
z: target.z - camera.positionWC.z,
|
||||||
};
|
};
|
||||||
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
|
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
|
||||||
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
|
const canvasHeight = gev.engine.host.size().height;
|
||||||
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
|
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
|
||||||
return {
|
return {
|
||||||
target: [target.x, target.y, target.z],
|
target: [target.x, target.y, target.z],
|
||||||
@@ -427,8 +465,8 @@ async function vesselRowsAroundTarget(page) {
|
|||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const layer = gev.dataManager.layers.get('flights').module;
|
const layer = gev.dataManager.layers.get('flights').module;
|
||||||
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
|
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
|
||||||
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
|
const Cartographic = { fromCartesian: window.__focusGeo.cartographic.fromCartesian };
|
||||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
const ellipsoid = window.__focusGeo.ellipsoid.WGS84;
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (let i = 0; i < 12; i += 1) {
|
for (let i = 0; i < 12; i += 1) {
|
||||||
const angle = (i / 12) * Math.PI * 2;
|
const angle = (i / 12) * Math.PI * 2;
|
||||||
@@ -624,7 +662,9 @@ async function browserContext(page, browser) {
|
|||||||
},
|
},
|
||||||
caveat: HEADFUL
|
caveat: HEADFUL
|
||||||
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
|
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
|
||||||
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
|
: process.env.QA_ANGLE === 'metal'
|
||||||
|
? 'Headless ANGLE/Metal capture; renderer and DPR recorded above.'
|
||||||
|
: 'SwiftShader/headless output is relative-only.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +681,7 @@ async function main() {
|
|||||||
headless: HEADFUL ? false : 'new',
|
headless: HEADFUL ? false : 'new',
|
||||||
protocolTimeout: 300000,
|
protocolTimeout: 300000,
|
||||||
args: [
|
args: [
|
||||||
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
...(HEADFUL ? [] : process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-setuid-sandbox',
|
'--disable-setuid-sandbox',
|
||||||
'--disable-dev-shm-usage',
|
'--disable-dev-shm-usage',
|
||||||
@@ -655,10 +695,10 @@ async function main() {
|
|||||||
const consoleMessages = [];
|
const consoleMessages = [];
|
||||||
page.on('console', (message) => {
|
page.on('console', (message) => {
|
||||||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
consoleMessages.push({ type: message.type(), text: redact(message.text()) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: redact(error.message) }));
|
||||||
|
|
||||||
const report = {
|
const report = {
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
|
|||||||
+291
-21
@@ -1,19 +1,29 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Keyless station-name regression against the real globe and bike-share tool.
|
* Keyless station-name regression against the real globe and bike-share tool.
|
||||||
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
|
* Run with a dev or QA preview server and an explicitly assigned --url.
|
||||||
* GBFS documents are synthetic; no operator feed or provider key is required.
|
* GBFS documents are synthetic; no operator feed or provider key is required.
|
||||||
*/
|
*/
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
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 puppeteer from 'puppeteer';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { watchW1Page, redact } from './qa-browserEvidence.mjs';
|
||||||
|
|
||||||
const urlIndex = process.argv.indexOf('--url');
|
const urlIndex = process.argv.indexOf('--url');
|
||||||
const appUrl =
|
if (urlIndex < 0) throw new Error('An explicit --url is required');
|
||||||
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
|
const appUrl = process.argv[urlIndex + 1];
|
||||||
const shots = path.resolve('qa-shots/gbfs-names');
|
const engineName = new URL(appUrl).searchParams.get('engine') || 'cesium';
|
||||||
|
const extended = process.argv.includes('--w2');
|
||||||
|
const reportIndex = process.argv.indexOf('--report');
|
||||||
|
const reportPath =
|
||||||
|
reportIndex < 0 ? null : path.resolve(process.argv[reportIndex + 1]);
|
||||||
|
const shots = reportPath
|
||||||
|
? reportPath.replace(/\.json$/, '-shots')
|
||||||
|
: path.resolve(`qa-shots/gbfs-names/${engineName}`);
|
||||||
fs.mkdirSync(shots, { recursive: true });
|
fs.mkdirSync(shots, { recursive: true });
|
||||||
|
const report = { engine: engineName, checks: [], poses: {}, browserErrors: [] };
|
||||||
const information = {
|
const information = {
|
||||||
version: '3.0',
|
version: '3.0',
|
||||||
data: {
|
data: {
|
||||||
@@ -67,6 +77,7 @@ const browser = await puppeteer.launch({
|
|||||||
});
|
});
|
||||||
let checks = 0;
|
let checks = 0;
|
||||||
const check = (name, actual, expected) => {
|
const check = (name, actual, expected) => {
|
||||||
|
report.checks.push({ name, actual, expected });
|
||||||
assert.deepEqual(actual, expected, name);
|
assert.deepEqual(actual, expected, name);
|
||||||
checks++;
|
checks++;
|
||||||
console.log(`[PASS] ${name}`);
|
console.log(`[PASS] ${name}`);
|
||||||
@@ -75,6 +86,12 @@ async function open(mode = 'normal') {
|
|||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.bringToFront();
|
await page.bringToFront();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
if (extended)
|
||||||
|
watchW1Page(page, report, {
|
||||||
|
name: mode,
|
||||||
|
baseUrl: appUrl,
|
||||||
|
preview: process.argv.includes('--preview'),
|
||||||
|
});
|
||||||
page.on('pageerror', (error) => errors.push(error.message));
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
await page.setViewport({ width: 1440, height: 900 });
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
await page.evaluateOnNewDocument(
|
await page.evaluateOnNewDocument(
|
||||||
@@ -134,15 +151,17 @@ async function open(mode = 'normal') {
|
|||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const { dataManager, viewer } = window.__godsEyeView;
|
const { dataManager, engine } = window.__godsEyeView;
|
||||||
|
await window.__godsEyeView.styleManager.initialRestorePromise;
|
||||||
|
window.__godsEyeView.styleManager.orbitController.stop();
|
||||||
for (const [id, layer] of dataManager.layers) {
|
for (const [id, layer] of dataManager.layers) {
|
||||||
if (layer.enabled)
|
if (layer.enabled)
|
||||||
await dataManager.setEnabled(id, false, { origin: 'user' });
|
await dataManager.setEnabled(id, false, { origin: 'user' });
|
||||||
}
|
}
|
||||||
const Cartesian3 = viewer.camera.position.constructor;
|
const geo = await import('/src/geo/index.js');
|
||||||
viewer.camera.cancelFlight();
|
engine.camera.cancelFlight();
|
||||||
viewer.camera.setView({
|
engine.camera.setView({
|
||||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
});
|
});
|
||||||
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
|
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
|
||||||
@@ -150,6 +169,211 @@ async function open(mode = 'normal') {
|
|||||||
});
|
});
|
||||||
return { page, errors };
|
return { page, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function nextFrame(page) {
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const engine = window.__godsEyeView.engine;
|
||||||
|
const remove = engine.frames.postRender.addEventListener(() => {
|
||||||
|
remove();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
engine.render.request('gbfs-raster-qa');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settledTiles(page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const engine = window.__godsEyeView.engine;
|
||||||
|
const start = performance.now();
|
||||||
|
let since = null;
|
||||||
|
while (performance.now() - start < 90000) {
|
||||||
|
if (engine.surface.tilesLoaded()) since ??= performance.now();
|
||||||
|
else since = null;
|
||||||
|
if (since !== null && performance.now() - since > 1500) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error('Map tiles did not settle for raster sampling');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function w2Checks(page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
const detection = await import('/src/data/detection.js');
|
||||||
|
detection.setMode('OFF');
|
||||||
|
const { appClock } = await import('/src/time/clock.js');
|
||||||
|
appClock.freeze(Date.parse('2026-10-28T19:00:00Z'));
|
||||||
|
const canvas = app.engine.host.canvas;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent =
|
||||||
|
'.gbfs-raster-hidden { visibility: hidden !important; }';
|
||||||
|
document.head.append(style);
|
||||||
|
for (const element of document.body.querySelectorAll('*'))
|
||||||
|
if (element !== canvas && !element.contains(canvas))
|
||||||
|
element.classList.add('gbfs-raster-hidden');
|
||||||
|
});
|
||||||
|
for (const [name, height] of [
|
||||||
|
['city', 10000],
|
||||||
|
['close', 3000],
|
||||||
|
['global', 100000],
|
||||||
|
]) {
|
||||||
|
await page.evaluate(async (height) => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
await app.dataManager.setEnabled('bikeshare', false);
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
app.engine.camera.setView({
|
||||||
|
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, height),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
}, height);
|
||||||
|
await settledTiles(page);
|
||||||
|
await nextFrame(page);
|
||||||
|
const off = await page.screenshot({
|
||||||
|
path: path.join(shots, `${name}-off.png`),
|
||||||
|
});
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(count) =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getStats().count === count,
|
||||||
|
{},
|
||||||
|
name === 'global' ? 0 : 5,
|
||||||
|
);
|
||||||
|
await nextFrame(page);
|
||||||
|
const controls = await page.evaluate(() => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
const rect = app.engine.host.canvas.getBoundingClientRect();
|
||||||
|
return app.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getDetectableObjects()
|
||||||
|
.map((record) => {
|
||||||
|
const p = app.engine.projector.worldToWindow(record.position);
|
||||||
|
return { id: record.sourceId, x: p.x + rect.left, y: p.y + rect.top };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const on = await page.screenshot({
|
||||||
|
path: path.join(shots, `${name}-on.png`),
|
||||||
|
});
|
||||||
|
const a = await sharp(off)
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
const b = await sharp(on).ensureAlpha().raw().toBuffer();
|
||||||
|
for (const control of controls) {
|
||||||
|
let pixels = 0;
|
||||||
|
for (
|
||||||
|
let y = Math.max(0, Math.floor(control.y) - 16);
|
||||||
|
y < Math.min(a.info.height, control.y + 16);
|
||||||
|
y++
|
||||||
|
)
|
||||||
|
for (
|
||||||
|
let x = Math.max(0, Math.floor(control.x) - 16);
|
||||||
|
x < Math.min(a.info.width, control.x + 16);
|
||||||
|
x++
|
||||||
|
) {
|
||||||
|
const i = (y * a.info.width + x) * 4;
|
||||||
|
if (
|
||||||
|
Math.max(
|
||||||
|
...[0, 1, 2].map((c) => Math.abs(a.data[i + c] - b[i + c])),
|
||||||
|
) > 3
|
||||||
|
)
|
||||||
|
pixels++;
|
||||||
|
}
|
||||||
|
control.changedPixels = pixels;
|
||||||
|
}
|
||||||
|
report.poses[name] = controls;
|
||||||
|
check(
|
||||||
|
`${name} expected station population`,
|
||||||
|
controls.length,
|
||||||
|
name === 'global' ? 0 : 5,
|
||||||
|
);
|
||||||
|
if (controls.length)
|
||||||
|
check(
|
||||||
|
`${name} five nonempty marker raster controls`,
|
||||||
|
controls.every((c) => c.changedPixels > 0),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.evaluate(() => {
|
||||||
|
for (const element of document.querySelectorAll('.gbfs-raster-hidden'))
|
||||||
|
element.classList.remove('gbfs-raster-hidden');
|
||||||
|
});
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
app.engine.camera.setView({
|
||||||
|
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
await app.dataManager.setEnabled('bikeshare', false);
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getStats().count === 5,
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('bikeshare', false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
'ten cycles clear all records',
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getStats().count,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const idle = await page.evaluate(async () => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
let frames = 0,
|
||||||
|
last = performance.now();
|
||||||
|
const remove = app.engine.frames.postRender.addEventListener(() => {
|
||||||
|
frames++;
|
||||||
|
last = performance.now();
|
||||||
|
});
|
||||||
|
const start = performance.now();
|
||||||
|
while (
|
||||||
|
performance.now() - start < 60000 &&
|
||||||
|
(performance.now() - last < 2000 || !app.engine.surface.tilesLoaded())
|
||||||
|
)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
const ready =
|
||||||
|
performance.now() - last >= 2000 && app.engine.surface.tilesLoaded();
|
||||||
|
frames = 0;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
remove();
|
||||||
|
return { ready, frames, governor: app.getRenderGovernorDiagnostics() };
|
||||||
|
});
|
||||||
|
report.idle = idle;
|
||||||
|
check('camera and tiles settle before idle sample', idle.ready, true);
|
||||||
|
check(
|
||||||
|
'disabled static scene renders zero frames over two seconds',
|
||||||
|
idle.frames,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
|
||||||
|
.count === 5,
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { page, errors } = await open();
|
const { page, errors } = await open();
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
@@ -158,6 +382,17 @@ try {
|
|||||||
.count === 5,
|
.count === 5,
|
||||||
{ timeout: 15000 },
|
{ timeout: 15000 },
|
||||||
);
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const rendered = new Promise((resolve) => {
|
||||||
|
const remove = engine.frames.postRender.addEventListener(() => {
|
||||||
|
remove();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.__godsEyeView.requestRender('gbfs-name-pick-qa');
|
||||||
|
await rendered;
|
||||||
|
});
|
||||||
const records = await page.evaluate(() =>
|
const records = await page.evaluate(() =>
|
||||||
window.__godsEyeView.dataManager.layers
|
window.__godsEyeView.dataManager.layers
|
||||||
.get('bikeshare')
|
.get('bikeshare')
|
||||||
@@ -198,17 +433,39 @@ try {
|
|||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
if (extended) await w2Checks(page);
|
||||||
const clickPoint = await page.evaluate(() => {
|
const clickPoint = await page.evaluate(() => {
|
||||||
window.__qaPainted = [];
|
window.__qaPainted = [];
|
||||||
const { viewer } = window.__godsEyeView;
|
const { engine } = window.__godsEyeView;
|
||||||
const Cartesian3 = viewer.camera.position.constructor;
|
const station = window.__godsEyeView.dataManager.layers
|
||||||
const point = viewer.scene.cartesianToCanvasCoordinates(
|
.get('bikeshare')
|
||||||
Cartesian3.fromDegrees(-97.7469, 30.2681),
|
.module.getDetectableObjects()
|
||||||
);
|
.find(({ sourceId }) => sourceId === 'austin-capmetro:english');
|
||||||
const rect = viewer.scene.canvas.getBoundingClientRect();
|
const point = engine.projector.worldToWindow(station.position);
|
||||||
return { x: rect.left + point.x, y: rect.top + point.y };
|
const rect = engine.host.canvas.getBoundingClientRect();
|
||||||
|
const picked = engine.picker.pick(point);
|
||||||
|
return {
|
||||||
|
x: rect.left + point.x,
|
||||||
|
y: rect.top + point.y,
|
||||||
|
pickedId: typeof picked?.id === 'string' ? picked.id : null,
|
||||||
|
primitiveId:
|
||||||
|
typeof picked?.primitive?.id === 'string' ? picked.primitive.id : null,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
check(
|
||||||
|
'the scene pick resolves the first stable station key',
|
||||||
|
clickPoint.pickedId || clickPoint.primitiveId,
|
||||||
|
'austin-capmetro:english',
|
||||||
|
);
|
||||||
await page.mouse.click(clickPoint.x, clickPoint.y);
|
await page.mouse.click(clickPoint.x, clickPoint.y);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getDetectableObjects()
|
||||||
|
.some((record) => record.skipLabel),
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => window.__qaPainted.includes('QA English Square'),
|
() => window.__qaPainted.includes('QA English Square'),
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
@@ -263,15 +520,15 @@ try {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await page.setViewport({ width: 390, height: 844 });
|
await page.setViewport({ width: 390, height: 844 });
|
||||||
await page.evaluate(() => {
|
await page.evaluate(async () => {
|
||||||
window.__qaPainted = [];
|
window.__qaPainted = [];
|
||||||
const app = window.__godsEyeView;
|
const app = window.__godsEyeView;
|
||||||
// Use the supported clean view to inspect the mobile card clear of panels.
|
// Use the supported clean view to inspect the mobile card clear of panels.
|
||||||
app.styleManager.toggleCleanView(true);
|
app.styleManager.toggleCleanView(true);
|
||||||
const Cartesian3 = app.viewer.camera.position.constructor;
|
const geo = await import('/src/geo/index.js');
|
||||||
app.viewer.camera.cancelFlight();
|
app.engine.camera.cancelFlight();
|
||||||
app.viewer.camera.setView({
|
app.engine.camera.setView({
|
||||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
});
|
});
|
||||||
app.dataManager.layers
|
app.dataManager.layers
|
||||||
@@ -330,7 +587,20 @@ try {
|
|||||||
check(`${mode} produces no page exceptions`, errors, []);
|
check(`${mode} produces no page exceptions`, errors, []);
|
||||||
await page.close();
|
await page.close();
|
||||||
}
|
}
|
||||||
|
if (extended)
|
||||||
|
check(
|
||||||
|
'all pages have no unexpected runtime errors',
|
||||||
|
report.browserErrors,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
report.pass = true;
|
||||||
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
|
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
|
||||||
|
} catch (error) {
|
||||||
|
report.pass = false;
|
||||||
|
report.failure = redact(error.message);
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (reportPath)
|
||||||
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
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=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||||
|
const strict = createStrict({ origin: base.origin, layers: ['s2-ghostmaps'] });
|
||||||
|
await strict.run({}, async (b) => {
|
||||||
|
const { page: p, errors } = await openPage(b);
|
||||||
|
const file = await readFile(
|
||||||
|
new URL('./fixtures/ghostmaps-scenes.kmz', import.meta.url),
|
||||||
|
);
|
||||||
|
await p.evaluateOnNewDocument(
|
||||||
|
(bytes) => {
|
||||||
|
const fetch = window.fetch.bind(window);
|
||||||
|
const events = window.EventSource;
|
||||||
|
window.ghostStats = { streams: 0, reads: 0, catalogs: 0, titles: [] };
|
||||||
|
window.EventSource = class extends EventTarget {
|
||||||
|
constructor(url, options) {
|
||||||
|
super();
|
||||||
|
if (!url.includes('/api/ghostmaps/events'))
|
||||||
|
return new events(url, options);
|
||||||
|
window.ghostStats.streams++;
|
||||||
|
this.readyState = 1;
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
};
|
||||||
|
window.fetch = (input, options) => {
|
||||||
|
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
||||||
|
if (pathname === '/api/ghostmaps/catalog') {
|
||||||
|
window.ghostStats.catalogs++;
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
commit: 'qa',
|
||||||
|
products: [
|
||||||
|
{ id: 'cip', version: 'qa', title: 'QA', defaultOn: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pathname.startsWith('/api/ghostmaps/file/')) {
|
||||||
|
window.ghostStats.reads++;
|
||||||
|
return Promise.resolve(new Response(new Uint8Array(bytes)));
|
||||||
|
}
|
||||||
|
return fetch(input, options);
|
||||||
|
};
|
||||||
|
const fill = CanvasRenderingContext2D.prototype.fillText;
|
||||||
|
CanvasRenderingContext2D.prototype.fillText = function (value, ...args) {
|
||||||
|
if (/QA (pin|route|zone)/i.test(value))
|
||||||
|
window.ghostStats.titles.push(value);
|
||||||
|
return fill.call(this, value, ...args);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[...file],
|
||||||
|
);
|
||||||
|
await p.goto(base.href);
|
||||||
|
await waitForEngine(p, 'navara');
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { dataManager } = window.__godsEyeView;
|
||||||
|
await dataManager.setEnabled('s2-ghostmaps', true, { origin: 'user' });
|
||||||
|
window.qaGhost = dataManager.layers.get('s2-ghostmaps').module;
|
||||||
|
});
|
||||||
|
const click = async (lon, lat) => {
|
||||||
|
const { x, y } = await p.evaluate(
|
||||||
|
(pos) => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const point = engine.projector.worldToWindow(pos, {});
|
||||||
|
const box = engine.host.canvas.getBoundingClientRect();
|
||||||
|
window.ghostStats.titles = [];
|
||||||
|
return { x: box.left + point.x, y: box.top + point.y };
|
||||||
|
},
|
||||||
|
fromDegrees(lon, lat),
|
||||||
|
);
|
||||||
|
await p.mouse.click(x, y);
|
||||||
|
};
|
||||||
|
const overlay = () =>
|
||||||
|
p.evaluate(() => {
|
||||||
|
const canvas = document.getElementById('world-overlay-canvas');
|
||||||
|
const data = canvas
|
||||||
|
.getContext('2d')
|
||||||
|
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
let pixels = 0;
|
||||||
|
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
|
||||||
|
return pixels;
|
||||||
|
});
|
||||||
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
|
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||||
|
await p.evaluate(() => {
|
||||||
|
window.ghostStats.titles = [];
|
||||||
|
});
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
await p.waitForFunction(() =>
|
||||||
|
window.ghostStats.titles.some((v) => /QA zone/i.test(v)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inventory = await p.evaluate(() => ({
|
||||||
|
same:
|
||||||
|
window.qaGhost ===
|
||||||
|
window.__godsEyeView.dataManager.layers.get('s2-ghostmaps').module,
|
||||||
|
names: window.qaGhost.getAnalystRecords().map((r) => r.name),
|
||||||
|
streams: window.ghostStats.streams,
|
||||||
|
reads: window.ghostStats.reads,
|
||||||
|
}));
|
||||||
|
assert.deepEqual(inventory, {
|
||||||
|
same: true,
|
||||||
|
names: ['QA pin', 'QA route', 'QA zone'],
|
||||||
|
streams: 1,
|
||||||
|
reads: 1,
|
||||||
|
});
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
's2-ghostmaps',
|
||||||
|
'same model, three features, one event stream, one KMZ read',
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2200));
|
||||||
|
for (const [name, lon, lat] of [
|
||||||
|
['QA pin', -79.38, 43.65],
|
||||||
|
['QA route', -79.375, 43.71],
|
||||||
|
['QA zone', -79.28, 43.61],
|
||||||
|
]) {
|
||||||
|
await click(lon, lat);
|
||||||
|
await p.waitForFunction(
|
||||||
|
(name) =>
|
||||||
|
window.ghostStats.titles.some((v) =>
|
||||||
|
v.toLowerCase().includes(name.toLowerCase()),
|
||||||
|
),
|
||||||
|
{ timeout: 12000 },
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
's2-ghostmaps',
|
||||||
|
`a click on ${name} draws its card text`,
|
||||||
|
);
|
||||||
|
console.log('PASS', kind, name, 'pointer hit');
|
||||||
|
}
|
||||||
|
// Not vacuous: the zone's card is up, so the overlay only clears when
|
||||||
|
// the click in the hole is handled as a click on empty ground. The
|
||||||
|
// overlay without any card is measured with a click far from every
|
||||||
|
// feature.
|
||||||
|
const shown = await overlay();
|
||||||
|
await click(-79.316, 43.61);
|
||||||
|
await p.waitForFunction(
|
||||||
|
(shown) => {
|
||||||
|
const canvas = document.getElementById('world-overlay-canvas');
|
||||||
|
const data = canvas
|
||||||
|
.getContext('2d')
|
||||||
|
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
let pixels = 0;
|
||||||
|
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
|
||||||
|
return pixels < shown - 100;
|
||||||
|
},
|
||||||
|
{ timeout: 10000 },
|
||||||
|
shown,
|
||||||
|
);
|
||||||
|
const hole = await overlay();
|
||||||
|
await click(-79.45, 43.55);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
const empty = await overlay();
|
||||||
|
console.log(
|
||||||
|
kind,
|
||||||
|
'overlay pixels: zone card',
|
||||||
|
shown,
|
||||||
|
'hole',
|
||||||
|
hole,
|
||||||
|
'empty ground',
|
||||||
|
empty,
|
||||||
|
);
|
||||||
|
await strict.check(
|
||||||
|
p,
|
||||||
|
's2-ghostmaps',
|
||||||
|
'a click in the zone hole clears the selected zone card like empty ground',
|
||||||
|
shown > empty + 100 && Math.abs(hole - empty) <= 0.02 * shown,
|
||||||
|
{ shown, hole, empty },
|
||||||
|
);
|
||||||
|
await click(-79.28, 43.61);
|
||||||
|
await p.waitForFunction(() =>
|
||||||
|
window.ghostStats.titles.some((v) => /QA zone/i.test(v)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
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=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
|
||||||
|
const strict = createStrict({
|
||||||
|
origin: base.origin,
|
||||||
|
layers: [
|
||||||
|
'ais-live-vessels',
|
||||||
|
'air-quality',
|
||||||
|
'gnss-integrity',
|
||||||
|
'fire-perimeters',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await strict.run({}, async (b) => {
|
||||||
|
const { page: p, errors } = await openPage(b);
|
||||||
|
await p.evaluateOnNewDocument(() => {
|
||||||
|
const fetch = window.fetch.bind(window);
|
||||||
|
window.fetch = (input, o) => {
|
||||||
|
const url = new URL(input.url ?? input, document.baseURI);
|
||||||
|
if (url.pathname === '/api/vessels/track') {
|
||||||
|
window.qaVesselHistories = (window.qaVesselHistories ?? 0) + 1;
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({ samples: [{ lat: 43.63, lon: -79.38 }] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/vessels') {
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
status: 'live',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
mmsi: '123456789',
|
||||||
|
name: 'Toronto QA',
|
||||||
|
lat: 43.65,
|
||||||
|
lon: -79.38,
|
||||||
|
last_position_epoch: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith('/api/fire-perimeters/inciweb')) {
|
||||||
|
return Promise.resolve(Response.json({ rows: [] }));
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/fire-perimeters') {
|
||||||
|
const square = (dx) => [
|
||||||
|
[-79.38 - dx, 43.65 - dx],
|
||||||
|
[-79.38 + dx, 43.65 - dx],
|
||||||
|
[-79.38 + dx, 43.65 + dx],
|
||||||
|
[-79.38 - dx, 43.65 + dx],
|
||||||
|
[-79.38 - dx, 43.65 - dx],
|
||||||
|
];
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
stableId: 'fire:fixture',
|
||||||
|
name: 'QA Fire',
|
||||||
|
acres: 500,
|
||||||
|
containedPct: 40,
|
||||||
|
polygons: [[square(0.09), square(0.018).reverse()]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/gnss-integrity') {
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
ageMs: 0,
|
||||||
|
rows: ['a1', 'a2', 'a3'].map((hex) => ({
|
||||||
|
hex,
|
||||||
|
lat: 43.65,
|
||||||
|
lon: -79.38,
|
||||||
|
nic: 0,
|
||||||
|
nacp: 0,
|
||||||
|
degraded: true,
|
||||||
|
gpsLost: false,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.hostname === 'api.weather.gc.ca') {
|
||||||
|
const records = url.pathname.includes('aqhi-stations')
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
|
||||||
|
properties: {
|
||||||
|
location_id: 'QA1',
|
||||||
|
location_name_en: 'QA Toronto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
properties: {
|
||||||
|
location_id: 'QA1',
|
||||||
|
aqhi: 9,
|
||||||
|
observation_datetime: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: records.map((f) => ({ type: 'Feature', ...f })),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fetch(input, o);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await p.goto(base.href);
|
||||||
|
const capture = async (visible, layerId = 'air-quality') => {
|
||||||
|
await p.evaluate(
|
||||||
|
async (show, id) => {
|
||||||
|
const d = window.__godsEyeView;
|
||||||
|
await d.dataManager.setEnabled(id, show, { origin: 'user' });
|
||||||
|
d.engine.render.request('qa-air-quality');
|
||||||
|
},
|
||||||
|
visible,
|
||||||
|
layerId,
|
||||||
|
);
|
||||||
|
// Ready to compare: the layer's data is in (when shown), the globe's
|
||||||
|
// tiles under the view have loaded and stayed loaded, and a frame has
|
||||||
|
// been drawn since.
|
||||||
|
await p
|
||||||
|
.waitForFunction(
|
||||||
|
(show, id) => {
|
||||||
|
const d = window.__godsEyeView;
|
||||||
|
if (!d?.engine) return false;
|
||||||
|
const module = d.dataManager.layers.get(id).module;
|
||||||
|
const data =
|
||||||
|
!show ||
|
||||||
|
(id === 'air-quality'
|
||||||
|
? module.getAnalystRecords().length === 1
|
||||||
|
: module.getStats().levels?.high === 1);
|
||||||
|
const now = performance.now();
|
||||||
|
// The engines draw on request: keep frames coming so tiles load.
|
||||||
|
d.engine.render.request('qa-ground-settle');
|
||||||
|
if (!data || !d.engine.surface.tilesLoaded()) {
|
||||||
|
window.__qaSettledSince = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
window.__qaSettledSince ??= now;
|
||||||
|
return now - window.__qaSettledSince > 1200;
|
||||||
|
},
|
||||||
|
{ polling: 100, timeout: 30000 },
|
||||||
|
visible,
|
||||||
|
layerId,
|
||||||
|
)
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.log(
|
||||||
|
'not settled',
|
||||||
|
JSON.stringify(
|
||||||
|
await p.evaluate((id) => {
|
||||||
|
const d = window.__godsEyeView;
|
||||||
|
const module = d?.dataManager.layers.get(id).module;
|
||||||
|
return {
|
||||||
|
engine: d?.engine?.kind,
|
||||||
|
tiles: d?.engine?.surface.tilesLoaded(),
|
||||||
|
records: module?.getAnalystRecords().length,
|
||||||
|
stats: module?.getStats(),
|
||||||
|
};
|
||||||
|
}, layerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
// A frame drawn after the settle (the engine draws on request), then
|
||||||
|
// the animation frame that presents it.
|
||||||
|
await p.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const remove = engine.frames.postRender.addEventListener(() => {
|
||||||
|
remove();
|
||||||
|
requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
engine.render.request('qa-ground');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await p.evaluate(() => (window.__qaSettledSince = null));
|
||||||
|
return sample();
|
||||||
|
};
|
||||||
|
const sample = async () => {
|
||||||
|
const shot = await p.screenshot();
|
||||||
|
return sharp(shot)
|
||||||
|
.extract({ left: 537, top: 403, width: 22, height: 18 })
|
||||||
|
.removeAlpha()
|
||||||
|
.png()
|
||||||
|
.toBuffer()
|
||||||
|
.then((buf) => sharp(buf).stats());
|
||||||
|
};
|
||||||
|
// The layer shown, sampled until its polygon appears in the patch (the
|
||||||
|
// engines draw a clamped polygon a few frames after its data, with no
|
||||||
|
// signal for it), for at most 20 s; the comparison stays the same.
|
||||||
|
const orangeOf = (v) => v.channels[0].mean - v.channels[2].mean;
|
||||||
|
const drawn = async (off, layerId) => {
|
||||||
|
const deadline = Date.now() + 20000;
|
||||||
|
let on = await capture(true, layerId);
|
||||||
|
while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
on = await sample();
|
||||||
|
}
|
||||||
|
return on;
|
||||||
|
};
|
||||||
|
await waitForEngine(p, 'navara');
|
||||||
|
await p.evaluate(() => {
|
||||||
|
const d = window.__godsEyeView;
|
||||||
|
if (d.tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||||
|
throw Error('Keyless required');
|
||||||
|
window.qaAir = d.dataManager.layers.get('air-quality').module;
|
||||||
|
window.qaGnss = d.dataManager.layers.get('gnss-integrity').module;
|
||||||
|
});
|
||||||
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
|
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
const same = await p.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers.get('air-quality').module ===
|
||||||
|
window.qaAir &&
|
||||||
|
window.__godsEyeView.dataManager.layers.get('gnss-integrity')
|
||||||
|
.module === window.qaGnss,
|
||||||
|
);
|
||||||
|
await strict.check(
|
||||||
|
p,
|
||||||
|
['air-quality', 'gnss-integrity'],
|
||||||
|
'same layer models after the switch',
|
||||||
|
same,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await p.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('gnss-integrity', false),
|
||||||
|
);
|
||||||
|
const off = await capture(false),
|
||||||
|
on = await drawn(off, 'air-quality');
|
||||||
|
const values = await p.evaluate(() => ({
|
||||||
|
records: window.qaAir.getAnalystRecords(),
|
||||||
|
card: window.qaAir.getRowControls().list.items,
|
||||||
|
stats: window.qaAir.getStats(),
|
||||||
|
})); // An interior patch away from the label must take on the high-risk orange.
|
||||||
|
const orange = (v) => v.channels[0].mean - v.channels[2].mean;
|
||||||
|
assert.ok(
|
||||||
|
orange(on) > orange(off) + 5,
|
||||||
|
kind + ' must draw the station footprint',
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'PASS',
|
||||||
|
kind,
|
||||||
|
values.records[0].id,
|
||||||
|
'AQHI',
|
||||||
|
values.records[0].value,
|
||||||
|
);
|
||||||
|
assert.equal(values.records[0].name, 'QA Toronto');
|
||||||
|
assert.equal(values.records[0].value, 9);
|
||||||
|
assert.equal(values.card.length, 1);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'air-quality',
|
||||||
|
'AQHI 9 station footprint drawn, one card row',
|
||||||
|
{
|
||||||
|
off: orange(off),
|
||||||
|
on: orange(on),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await capture(false);
|
||||||
|
const cellOff = await capture(false, 'gnss-integrity');
|
||||||
|
const cellOn = await drawn(cellOff, 'gnss-integrity');
|
||||||
|
assert.ok(
|
||||||
|
orange(cellOn) > orange(cellOff) + 5,
|
||||||
|
kind + ' must draw GNSS high cell',
|
||||||
|
);
|
||||||
|
const gnss = await p.evaluate(() => window.qaGnss.getStats());
|
||||||
|
assert.equal(gnss.levels.high, 1);
|
||||||
|
await strict.passed(p, 'gnss-integrity', 'one high GNSS cell drawn', {
|
||||||
|
off: orange(cellOff),
|
||||||
|
on: orange(cellOn),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
'PASS',
|
||||||
|
kind,
|
||||||
|
'GNSS high cell, view anchor',
|
||||||
|
gnss.coverage.latitude,
|
||||||
|
gnss.coverage.longitude,
|
||||||
|
);
|
||||||
|
await p.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('air-quality', true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { dataManager } = window.__godsEyeView;
|
||||||
|
await dataManager.setEnabled('air-quality', false);
|
||||||
|
await dataManager.setEnabled('fire-perimeters', true, { origin: 'user' });
|
||||||
|
window.qaFire = dataManager.layers.get('fire-perimeters').module;
|
||||||
|
if (
|
||||||
|
!dataManager.isEnabled('fire-perimeters') ||
|
||||||
|
window.qaFire.getStats().count !== 1
|
||||||
|
)
|
||||||
|
throw Error(
|
||||||
|
'Failed to enable fire perimeter fixture: ' +
|
||||||
|
JSON.stringify(window.qaFire.getStats()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const card = async (visible) =>
|
||||||
|
p.waitForFunction(
|
||||||
|
(want) => {
|
||||||
|
const canvas = document.getElementById('world-overlay-canvas');
|
||||||
|
const data = canvas
|
||||||
|
.getContext('2d')
|
||||||
|
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
let pixels = 0;
|
||||||
|
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
|
||||||
|
return want ? pixels > 100 : pixels === 0;
|
||||||
|
},
|
||||||
|
{ timeout: 15000 },
|
||||||
|
visible,
|
||||||
|
);
|
||||||
|
const click = async (lon, lat) => {
|
||||||
|
const { x, y } = await p.evaluate(
|
||||||
|
(world) => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const point = engine.projector.worldToWindow(world, {});
|
||||||
|
if (!point) throw Error('the point is not in view');
|
||||||
|
const box = engine.host.canvas.getBoundingClientRect();
|
||||||
|
return { x: box.left + point.x, y: box.top + point.y };
|
||||||
|
},
|
||||||
|
fromDegrees(lon, lat),
|
||||||
|
);
|
||||||
|
await p.mouse.click(x, y);
|
||||||
|
};
|
||||||
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
|
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
await card(true);
|
||||||
|
await strict.check(
|
||||||
|
p,
|
||||||
|
'fire-perimeters',
|
||||||
|
'same model; the incident card survives the switch',
|
||||||
|
await p.evaluate(
|
||||||
|
() =>
|
||||||
|
window.qaFire ===
|
||||||
|
window.__godsEyeView.dataManager.layers.get('fire-perimeters')
|
||||||
|
.module,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await click(-79.51, 43.65);
|
||||||
|
await card(false);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'fire-perimeters',
|
||||||
|
'a click outside the perimeter clears the card',
|
||||||
|
);
|
||||||
|
await click(-79.435, 43.65);
|
||||||
|
await card(true);
|
||||||
|
await strict.check(
|
||||||
|
p,
|
||||||
|
'fire-perimeters',
|
||||||
|
'a click on the ring selects the fire',
|
||||||
|
await p.evaluate(() => window.qaFire.getSelectedInfo?.()?.id ?? true),
|
||||||
|
);
|
||||||
|
// Not vacuous: the fire is selected, so the card only goes when the
|
||||||
|
// click in the hole is handled as a click outside the polygon.
|
||||||
|
await click(-79.38, 43.65);
|
||||||
|
await card(false);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'fire-perimeters',
|
||||||
|
'a click in the hole deselects the selected fire',
|
||||||
|
);
|
||||||
|
await click(-79.435, 43.65);
|
||||||
|
await card(true);
|
||||||
|
console.log(
|
||||||
|
'PASS',
|
||||||
|
kind,
|
||||||
|
'select fire under GNSS, exclude hole, retain incident card',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { dataManager } = window.__godsEyeView;
|
||||||
|
for (const id of ['gnss-integrity', 'fire-perimeters', 'air-quality'])
|
||||||
|
await dataManager.setEnabled(id, false);
|
||||||
|
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||||
|
window.qaVesselEntry = dataManager.layers.get('ais-live-vessels');
|
||||||
|
});
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
// Click-picking on the running engine: clear the selection with a click on
|
||||||
|
// empty water, then select the vessel with a click on its chevron.
|
||||||
|
const pickVessel = async () => {
|
||||||
|
// Empty ground: a point in the middle of the view where the engine
|
||||||
|
// picks nothing and nothing covers the canvas.
|
||||||
|
const empty = await p
|
||||||
|
.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const canvas = engine.host.canvas;
|
||||||
|
const box = canvas.getBoundingClientRect();
|
||||||
|
for (let y = 220; y < 560; y += 11)
|
||||||
|
for (let x = 300; x < 720; x += 11) {
|
||||||
|
if (
|
||||||
|
!canvas.contains(
|
||||||
|
document.elementFromPoint(box.left + x, box.top + y),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
let free = true;
|
||||||
|
for (const [dx, dy] of [
|
||||||
|
[0, 0],
|
||||||
|
[10, 0],
|
||||||
|
[-10, 0],
|
||||||
|
[0, 10],
|
||||||
|
[0, -10],
|
||||||
|
])
|
||||||
|
if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
|
||||||
|
if (free) return { x: box.left + x, y: box.top + y };
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ timeout: 15000 },
|
||||||
|
)
|
||||||
|
.then((handle) => handle.jsonValue());
|
||||||
|
await p.mouse.click(empty.x, empty.y);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => !window.qaVesselEntry.module.getSelectedInfo(),
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
|
await click(-79.38, 43.65);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'ais-live-vessels',
|
||||||
|
'a click on the chevron selects the vessel',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await click(-79.38, 43.65);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'ais-live-vessels',
|
||||||
|
'a click on the chevron selects the vessel',
|
||||||
|
);
|
||||||
|
let histories = await p.evaluate(() => window.qaVesselHistories);
|
||||||
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
|
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
}
|
||||||
|
const sample = await p.evaluate(() => {
|
||||||
|
const entry =
|
||||||
|
window.__godsEyeView.dataManager.layers.get('ais-live-vessels');
|
||||||
|
const drawn = entry.module.qaDrawn();
|
||||||
|
return {
|
||||||
|
retained: entry === window.qaVesselEntry,
|
||||||
|
selected: entry.module.getSelectedInfo()?.mmsi,
|
||||||
|
chevron: drawn.chevrons.find((c) => c.selected && c.show)?.id,
|
||||||
|
card: drawn.cards.find((c) => c.selected)?.id,
|
||||||
|
histories: window.qaVesselHistories,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
sample,
|
||||||
|
{
|
||||||
|
retained: true,
|
||||||
|
selected: '123456789',
|
||||||
|
chevron: '123456789',
|
||||||
|
card: 'vessel:123456789',
|
||||||
|
histories,
|
||||||
|
},
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'ais-live-vessels',
|
||||||
|
'same entry; selection, chevron and card kept; no new track request',
|
||||||
|
);
|
||||||
|
if (kind === 'cesium' || histories === 1) {
|
||||||
|
await pickVessel();
|
||||||
|
histories = await p.evaluate(() => window.qaVesselHistories);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'PASS',
|
||||||
|
kind,
|
||||||
|
'retained selected AIS vessel, chevron, card and single track request',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
});
|
||||||
+57
-24
@@ -99,6 +99,7 @@
|
|||||||
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 puppeteer from 'puppeteer';
|
||||||
|
import { installNavaraHeadingProbe } from './qa-heading-navara.mjs';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Args (same shape as track-regression.mjs)
|
// Args (same shape as track-regression.mjs)
|
||||||
@@ -110,7 +111,13 @@ 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/b3');
|
||||||
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2');
|
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2');
|
||||||
@@ -148,7 +155,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?.viewer);
|
||||||
const scene = viewer?.scene;
|
const scene = viewer?.scene;
|
||||||
const history = [];
|
const history = [];
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
@@ -453,7 +460,7 @@ async function main() {
|
|||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-setuid-sandbox',
|
'--disable-setuid-sandbox',
|
||||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
...(HEADFUL ? [] : ['--use-gl=angle', `--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',
|
||||||
@@ -480,6 +487,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 };
|
||||||
@@ -574,9 +582,12 @@ 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 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.');
|
||||||
|
|
||||||
// ---- Model finder + course derivation, installed once ------------------
|
// ---- Model finder + course derivation, installed once ------------------
|
||||||
@@ -589,7 +600,7 @@ async function main() {
|
|||||||
// ~90° course spike into the sampled series — a measurement flake, not
|
// ~90° course spike into the sampled series — a measurement flake, not
|
||||||
// a product regression.)
|
// a product regression.)
|
||||||
window.__findTrackedModel = function (icao) {
|
window.__findTrackedModel = function (icao) {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||||
const out = [];
|
const out = [];
|
||||||
const walk = (coll) => {
|
const walk = (coll) => {
|
||||||
const n = coll.length;
|
const n = coll.length;
|
||||||
@@ -625,7 +636,7 @@ async function main() {
|
|||||||
// 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]));
|
||||||
@@ -646,7 +657,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();
|
||||||
@@ -685,13 +696,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);
|
||||||
@@ -726,7 +739,10 @@ 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; }
|
||||||
@@ -783,14 +799,14 @@ async function main() {
|
|||||||
await sleep(800);
|
await sleep(800);
|
||||||
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})`); }
|
||||||
@@ -812,7 +828,10 @@ 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) {
|
||||||
@@ -841,14 +860,14 @@ async function main() {
|
|||||||
await sleep(800);
|
await sleep(800);
|
||||||
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})`); }
|
||||||
@@ -877,7 +896,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;
|
||||||
@@ -914,7 +936,7 @@ 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 sleep(1200);
|
||||||
@@ -930,8 +952,9 @@ 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(() => {
|
||||||
|
if (window.__headingView) { window.__findModelByIcao = window.__findTrackedModel; return; }
|
||||||
window.__findModelByIcao = function (icao) {
|
window.__findModelByIcao = function (icao) {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||||
const out = [];
|
const out = [];
|
||||||
const walk = (coll) => {
|
const walk = (coll) => {
|
||||||
const n = coll.length;
|
const n = coll.length;
|
||||||
@@ -957,7 +980,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) => {
|
||||||
@@ -966,7 +989,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');
|
||||||
@@ -983,9 +1006,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;
|
||||||
@@ -1037,7 +1061,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;
|
||||||
@@ -1061,7 +1094,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();
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Navara ports for the existing heading scenarios. No app viewer is installed.
|
||||||
|
// Samples come from the renderer handles through the read-only layer probe.
|
||||||
|
export async function installNavaraHeadingProbe(page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
if (app.engine.kind !== 'navara') return;
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
const engine = app.engine;
|
||||||
|
const drawn = (id) =>
|
||||||
|
['flights', 'military']
|
||||||
|
.flatMap((layer) => app.layers.drawn(layer) ?? [])
|
||||||
|
.filter((row) => row.id === id);
|
||||||
|
window.__findTrackedModel = (id) => {
|
||||||
|
const rows = drawn(id);
|
||||||
|
const row =
|
||||||
|
rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ??
|
||||||
|
rows.find((r) => r.model?.ready && r.model.show);
|
||||||
|
return row
|
||||||
|
? {
|
||||||
|
id,
|
||||||
|
ready: row.model.ready,
|
||||||
|
show: row.model.show,
|
||||||
|
modelMatrix: row.model.matrix,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
window.__courseFromModelMatrix = (mm, offset) => {
|
||||||
|
if (!mm) return null;
|
||||||
|
const c = geo.cartographic.fromCartesian({
|
||||||
|
x: mm[12],
|
||||||
|
y: mm[13],
|
||||||
|
z: mm[14],
|
||||||
|
});
|
||||||
|
const ex = -Math.sin(c.longitude),
|
||||||
|
ey = Math.cos(c.longitude);
|
||||||
|
const nx = -Math.sin(c.latitude) * Math.cos(c.longitude);
|
||||||
|
const ny = -Math.sin(c.latitude) * Math.sin(c.longitude),
|
||||||
|
nz = Math.cos(c.latitude);
|
||||||
|
const h =
|
||||||
|
(Math.atan2(
|
||||||
|
-(mm[0] * nx + mm[1] * ny + mm[2] * nz),
|
||||||
|
mm[0] * ex + mm[1] * ey,
|
||||||
|
) *
|
||||||
|
180) /
|
||||||
|
Math.PI;
|
||||||
|
return (((h - offset) % 360) + 360) % 360;
|
||||||
|
};
|
||||||
|
const turn = (angle = 0, nearer = 0) => {
|
||||||
|
const handle = engine.camera.following();
|
||||||
|
const target = handle?.target();
|
||||||
|
if (!target)
|
||||||
|
throw new Error('Heading screenshot needs a followed target');
|
||||||
|
const frame = geo.transforms.eastNorthUpToFixedFrame(target);
|
||||||
|
const inverse = geo.matrix4.inverseTransformation(
|
||||||
|
frame,
|
||||||
|
geo.matrix4.create(),
|
||||||
|
);
|
||||||
|
const p = geo.matrix4.multiplyByPoint(
|
||||||
|
inverse,
|
||||||
|
engine.camera.pose().position,
|
||||||
|
geo.cartesian3.create(),
|
||||||
|
);
|
||||||
|
const x = p.x * Math.cos(angle) - p.y * Math.sin(angle);
|
||||||
|
const y = p.x * Math.sin(angle) + p.y * Math.cos(angle);
|
||||||
|
const range = Math.hypot(x, y, p.z);
|
||||||
|
const scale = Math.max(150, range - nearer) / range;
|
||||||
|
handle.retarget({
|
||||||
|
viewFrom: { x: x * scale, y: y * scale, z: p.z * scale },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.__headingView = {
|
||||||
|
clock: { currentTime: undefined },
|
||||||
|
get trackedEntity() {
|
||||||
|
const handle = engine.camera.following();
|
||||||
|
if (!handle) return null;
|
||||||
|
const label = handle.label;
|
||||||
|
const id = label?.id?.split(':').at(-1);
|
||||||
|
return {
|
||||||
|
gevTrackedId: label?.id,
|
||||||
|
gevLabelModel:
|
||||||
|
typeof label?.model === 'function' ? label.model() : label?.model,
|
||||||
|
gevDisplayPosition: handle.target,
|
||||||
|
billboard: {
|
||||||
|
rotation: {
|
||||||
|
getValue: () => drawn(id).find((r) => r.tracked)?.trackedRotation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
rotateRight: (a) => turn(a),
|
||||||
|
rotateLeft: (a) => turn(-a),
|
||||||
|
zoomIn: (m) => turn(0, m),
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
preRender: engine.frames.preRender,
|
||||||
|
postRender: engine.frames.postRender,
|
||||||
|
requestRender: () => engine.render.request('heading-probe'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Observe real Navara frames; unlike Cesium this port does not own a
|
||||||
|
// manual viewer loop. The 600 ms case samples at that cadence while
|
||||||
|
// Navara continues rendering; the dt-clamp branch is covered by unit QA.
|
||||||
|
window.__sampleModelCourse = (id, offset, windowMs, stepMs = 100) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const samples = [];
|
||||||
|
let last = -Infinity;
|
||||||
|
const remove = engine.frames.preRender.addEventListener(() => {
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - last < stepMs) return;
|
||||||
|
last = now;
|
||||||
|
const model = window.__findTrackedModel(id);
|
||||||
|
samples.push({
|
||||||
|
tMs: now,
|
||||||
|
epochMs: Date.now(),
|
||||||
|
course: model
|
||||||
|
? window.__courseFromModelMatrix(model.modelMatrix, offset)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
remove();
|
||||||
|
resolve(samples);
|
||||||
|
}, windowMs);
|
||||||
|
engine.render.request('heading-probe');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -213,7 +213,8 @@ async function readCameraGround(page, camId) {
|
|||||||
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
|
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
|
||||||
*/
|
*/
|
||||||
async function fetchReearthEllipsoid(lat, lon) {
|
async function fetchReearthEllipsoid(lat, lon) {
|
||||||
const url = `${APP_URL}/api/terrain/heights?points=${encodeURIComponent(`${lon.toFixed(5)},${lat.toFixed(5)}`)}`;
|
const url = new URL('/api/terrain/heights', APP_URL);
|
||||||
|
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
@@ -253,7 +254,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',
|
||||||
@@ -279,7 +280,7 @@ 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 }
|
{ timeout: 60000 }
|
||||||
);
|
);
|
||||||
await sleep(4000);
|
await sleep(4000);
|
||||||
@@ -287,6 +288,10 @@ async function main() {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Group 1: CCTV ground correctness
|
// Group 1: CCTV ground correctness
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
const hasCctv = await page.evaluate(() => window.__godsEyeView.layers.available('cctv'));
|
||||||
|
let allCameras = [], londonCams = [], austinCams = [];
|
||||||
|
if (!hasCctv) console.log(' [SKIP] CCTV ground/regime groups are outside the Navara W1 catalog');
|
||||||
|
if (hasCctv) {
|
||||||
console.log('Enabling CCTV layer (full city-packs catalog)...');
|
console.log('Enabling CCTV layer (full city-packs catalog)...');
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const dm = window.__godsEyeView.dataManager;
|
const dm = window.__godsEyeView.dataManager;
|
||||||
@@ -337,7 +342,7 @@ async function main() {
|
|||||||
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
|
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
|
||||||
// Caltrans cameras are identified by proximity to the SF anchor instead
|
// Caltrans cameras are identified by proximity to the SF anchor instead
|
||||||
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
|
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
|
||||||
const allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
|
allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
|
||||||
console.log(`Camera catalog: ${allCameras.length} total.`);
|
console.log(`Camera catalog: ${allCameras.length} total.`);
|
||||||
|
|
||||||
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
|
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
|
||||||
@@ -349,8 +354,8 @@ async function main() {
|
|||||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
};
|
};
|
||||||
|
|
||||||
const londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
||||||
const austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
|
austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
|
||||||
const sfCams = allCameras
|
const sfCams = allCameras
|
||||||
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
|
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
@@ -456,6 +461,8 @@ async function main() {
|
|||||||
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
|
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Group 2: Aircraft render altitude
|
// Group 2: Aircraft render altitude
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -511,7 +518,7 @@ async function main() {
|
|||||||
// returns everything currently shown regardless of viewer position.
|
// returns everything currently shown regardless of viewer position.
|
||||||
const nearbyList = await page.evaluate(() => {
|
const nearbyList = await page.evaluate(() => {
|
||||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||||
const center = window.__godsEyeView.viewer.camera.position;
|
const center = window.__godsEyeView.engine.camera.livePosition;
|
||||||
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
|
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
|
||||||
});
|
});
|
||||||
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
|
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
|
||||||
@@ -626,6 +633,7 @@ async function main() {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Group 3: Regime C re-resolve
|
// Group 3: Regime C re-resolve
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
if (hasCctv) {
|
||||||
console.log('Switching map stack to keyless OSM (regime C)...');
|
console.log('Switching map stack to keyless OSM (regime C)...');
|
||||||
// Grab a London camera's ground BEFORE the switch (still whatever regime
|
// Grab a London camera's ground BEFORE the switch (still whatever regime
|
||||||
// we were in — Google-3D, given the app's default).
|
// we were in — Google-3D, given the app's default).
|
||||||
@@ -694,6 +702,8 @@ async function main() {
|
|||||||
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
|
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Screenshot for human visual review (not a pass/fail gate).
|
// Screenshot for human visual review (not a pass/fail gate).
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
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=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||||
|
// Every geometry of the bundled files (a Multi* feature counts each part),
|
||||||
|
// read from the files this checkout builds.
|
||||||
|
const geometries = (name) =>
|
||||||
|
readFileSync(
|
||||||
|
new URL(`../src/data/local_data/${name}/${name}.geojsonl`, import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.reduce((sum, line) => {
|
||||||
|
const { type, coordinates } = JSON.parse(line).geometry;
|
||||||
|
return sum + (type.startsWith('Multi') ? coordinates.length : 1);
|
||||||
|
}, 0);
|
||||||
|
const DATACENTERS = geometries('datacenters');
|
||||||
|
const DAMS = geometries('dams');
|
||||||
|
const strict = createStrict({
|
||||||
|
origin: base.origin,
|
||||||
|
layers: ['local-dams', 'local-datacenters'],
|
||||||
|
});
|
||||||
|
await strict.run({}, async (b) => {
|
||||||
|
const { page: p, errors } = await openPage(b);
|
||||||
|
const reads = { datacenters: 0, dams: 0 };
|
||||||
|
p.on('request', (req) => {
|
||||||
|
const path = new URL(req.url()).pathname;
|
||||||
|
for (const name of Object.keys(reads))
|
||||||
|
if (path.includes(name) && path.endsWith('.geojsonl')) reads[name]++;
|
||||||
|
});
|
||||||
|
await p.evaluateOnNewDocument(() => {
|
||||||
|
window.qaSelections = [];
|
||||||
|
window.addEventListener('gev:entity-selected', ({ detail }) => {
|
||||||
|
if (detail?.layerId?.startsWith('local-'))
|
||||||
|
window.qaSelections.push(detail.id);
|
||||||
|
});
|
||||||
|
window.qaCleared = 0;
|
||||||
|
window.addEventListener('gev:entity-selection-cleared', () => {
|
||||||
|
window.qaCleared++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await p.goto(base.href);
|
||||||
|
await waitForEngine(p, 'navara');
|
||||||
|
const ids = ['local-datacenters', 'local-dams'];
|
||||||
|
const dataset = await p.evaluate(async (ids) => {
|
||||||
|
const { dataManager, tileset } = window.__godsEyeView;
|
||||||
|
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||||
|
throw Error('Keyless preview required');
|
||||||
|
window.qaSites = [];
|
||||||
|
const records = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!(await dataManager.setEnabled(id, true, { origin: 'user' })))
|
||||||
|
throw Error(id + ' did not enable');
|
||||||
|
const mod = dataManager.layers.get(id).module;
|
||||||
|
window.qaSites.push(mod);
|
||||||
|
const all = mod.getAnalystRecords(Infinity);
|
||||||
|
records.push({
|
||||||
|
count: mod.getStats().count,
|
||||||
|
total: all.length,
|
||||||
|
name: all[0]?.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}, ids);
|
||||||
|
// The bundled files are fixed: every record is loaded.
|
||||||
|
assert.deepEqual(
|
||||||
|
dataset.map((d) => d.count),
|
||||||
|
[DATACENTERS, DAMS],
|
||||||
|
JSON.stringify(dataset),
|
||||||
|
);
|
||||||
|
const record = await p.evaluate(() =>
|
||||||
|
window.qaSites[0]
|
||||||
|
.getAnalystRecords(5000)
|
||||||
|
.find((r) => r.name === 'Geneseo Communications Inc'),
|
||||||
|
);
|
||||||
|
assert.ok(record);
|
||||||
|
const clickSite = async () => {
|
||||||
|
const screen = await p.evaluate(
|
||||||
|
(world) => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const at = engine.projector.worldToWindow(world, {});
|
||||||
|
const box = engine.host.canvas.getBoundingClientRect();
|
||||||
|
return { x: box.left + at.x, y: box.top + at.y };
|
||||||
|
},
|
||||||
|
fromDegrees(record.lon, record.lat),
|
||||||
|
);
|
||||||
|
await p.mouse.click(screen.x, screen.y);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.qaSelections.at(-1) === 'local-datacenters:342664739',
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// A dam in view, clicked where its record is.
|
||||||
|
const dam = await p.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('local-dams')
|
||||||
|
.module.getAnalystRecords(5000)
|
||||||
|
.find((r) => r.name === 'Arsenal Power Dam'),
|
||||||
|
);
|
||||||
|
assert.ok(dam);
|
||||||
|
const clickDam = async () => {
|
||||||
|
const screen = await p.evaluate(
|
||||||
|
(world) => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const at = engine.projector.worldToWindow(world, {});
|
||||||
|
const box = engine.host.canvas.getBoundingClientRect();
|
||||||
|
window.qaSelections = [];
|
||||||
|
return { x: box.left + at.x, y: box.top + at.y };
|
||||||
|
},
|
||||||
|
fromDegrees(dam.lon, dam.lat),
|
||||||
|
);
|
||||||
|
await p.mouse.click(screen.x, screen.y);
|
||||||
|
await p
|
||||||
|
.waitForFunction(
|
||||||
|
() => window.qaSelections.at(-1) === 'local-dams:-12841971',
|
||||||
|
{ timeout: 15000 },
|
||||||
|
)
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.log(
|
||||||
|
'dam click',
|
||||||
|
JSON.stringify(screen),
|
||||||
|
JSON.stringify(
|
||||||
|
await p.evaluate(({ x, y }) => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const box = engine.host.canvas.getBoundingClientRect();
|
||||||
|
const id = engine.picker.pick({
|
||||||
|
x: x - box.left,
|
||||||
|
y: y - box.top,
|
||||||
|
})?.id;
|
||||||
|
return {
|
||||||
|
selections: window.qaSelections,
|
||||||
|
cleared: window.qaCleared,
|
||||||
|
pick: String(id?.id ?? id),
|
||||||
|
hit: document.elementFromPoint(x, y)?.tagName,
|
||||||
|
};
|
||||||
|
}, screen),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const start = fromDegrees(-90.507, 41.534, 20000);
|
||||||
|
const home = async () => {
|
||||||
|
await p.evaluate((destination) => {
|
||||||
|
const { camera, render } = window.__godsEyeView.engine;
|
||||||
|
camera.cancelFlight();
|
||||||
|
camera.setView({
|
||||||
|
destination,
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
render.request('qa-home');
|
||||||
|
}, start);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
};
|
||||||
|
const counts = { ...reads };
|
||||||
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
|
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||||
|
await p.evaluate(() => {
|
||||||
|
window.qaSelections = [];
|
||||||
|
});
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.qaSelections.includes('local-datacenters:342664739'),
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
const result = await p.evaluate(
|
||||||
|
(ids) =>
|
||||||
|
ids.map((id, i) => {
|
||||||
|
const mod = window.__godsEyeView.dataManager.layers.get(id).module;
|
||||||
|
return {
|
||||||
|
same: mod === window.qaSites[i],
|
||||||
|
lod: mod.getLodDiagnostics(),
|
||||||
|
count: mod.getStats().count,
|
||||||
|
error: mod.getStats().error,
|
||||||
|
name: mod.getAnalystRecords(1)[0].name,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
console.log(kind, JSON.stringify(result));
|
||||||
|
for (const [i, layer] of result.entries()) {
|
||||||
|
assert.ok(
|
||||||
|
layer.same &&
|
||||||
|
!layer.error &&
|
||||||
|
layer.lod.active > 0 &&
|
||||||
|
layer.lod.active <= layer.lod.budgetLimit,
|
||||||
|
);
|
||||||
|
assert.equal(layer.count, dataset[i].count);
|
||||||
|
assert.equal(layer.name, dataset[i].name);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
reads,
|
||||||
|
counts,
|
||||||
|
'Do not reload bundled data on scene handoff',
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
ids,
|
||||||
|
'same model, every bundled record, bounded LOD stems, no file read again',
|
||||||
|
result.map((r) => r.count),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
kind !== 'navara' ||
|
||||||
|
(await p.evaluate(() => window.qaSelections.length))
|
||||||
|
)
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'local-datacenters',
|
||||||
|
'the clicked datacenter stays selected across the switch',
|
||||||
|
);
|
||||||
|
// Click-picking on this engine: a dam, then the datacenter again, each
|
||||||
|
// from the starting view (a selection flies the camera to it).
|
||||||
|
await home();
|
||||||
|
await clickDam();
|
||||||
|
await strict.passed(p, 'local-dams', 'a click on a dam selects it');
|
||||||
|
await home();
|
||||||
|
await clickSite();
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'local-datacenters',
|
||||||
|
'a click on the datacenter selects it',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(counts.datacenters, 1);
|
||||||
|
assert.equal(counts.dams, 1);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS complete bundled datasets, bounded stems, retained selection and one read per file',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -6,15 +6,18 @@
|
|||||||
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
|
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
|
||||||
* durations, so held frames last as long as they did on screen.
|
* durations, so held frames last as long as they did on screen.
|
||||||
* - `installFrameProbe(page)` measures every animation frame in the page:
|
* - `installFrameProbe(page)` measures every animation frame in the page:
|
||||||
* the requestAnimationFrame interval and, for frames Cesium rendered, the
|
* the requestAnimationFrame interval and, for rendered frames, the
|
||||||
* CPU time between `scene.preUpdate` and `scene.postRender`.
|
* CPU time between engine preUpdate and postRender events.
|
||||||
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
|
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
|
||||||
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
|
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
|
||||||
* zoom rather than teleporting it.
|
* zoom rather than teleporting it.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
/** Percentile of a numeric list (nearest rank); NaN for an empty list. */
|
/** Percentile of a numeric list (nearest rank); NaN for an empty list. */
|
||||||
export function percentile(values, p) {
|
export function percentile(values, p) {
|
||||||
@@ -25,7 +28,7 @@ export function percentile(values, p) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Summarize probe frames: rAF interval and Cesium render CPU time (ms). */
|
/** Summarize probe frames: rAF interval and engine render CPU time (ms). */
|
||||||
export function frameStats(frames) {
|
export function frameStats(frames) {
|
||||||
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
|
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
|
||||||
const renders = frames.map((f) => f.renderMs).filter(Number.isFinite);
|
const renders = frames.map((f) => f.renderMs).filter(Number.isFinite);
|
||||||
@@ -49,16 +52,17 @@ export function frameStats(frames) {
|
|||||||
export async function installFrameProbe(page) {
|
export async function installFrameProbe(page) {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
if (window.__gevFrameProbe) return;
|
if (window.__gevFrameProbe) return;
|
||||||
const { viewer } = window.__godsEyeView;
|
const { viewer, engine } = window.__godsEyeView;
|
||||||
|
const events = viewer?.scene ?? engine.frames;
|
||||||
const probe = { frames: [], started: 0 };
|
const probe = { frames: [], started: 0 };
|
||||||
window.__gevFrameProbe = probe;
|
window.__gevFrameProbe = probe;
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
let renderStart = null;
|
let renderStart = null;
|
||||||
let pendingRender = null;
|
let pendingRender = null;
|
||||||
viewer.scene.preUpdate.addEventListener(() => {
|
events.preUpdate.addEventListener(() => {
|
||||||
renderStart = performance.now();
|
renderStart = performance.now();
|
||||||
});
|
});
|
||||||
viewer.scene.postRender.addEventListener(() => {
|
events.postRender.addEventListener(() => {
|
||||||
if (renderStart !== null) pendingRender = performance.now() - renderStart;
|
if (renderStart !== null) pendingRender = performance.now() - renderStart;
|
||||||
renderStart = null;
|
renderStart = null;
|
||||||
});
|
});
|
||||||
@@ -98,6 +102,34 @@ export function pageNow(page) {
|
|||||||
* @param {number} seconds Flight duration; 0 jumps.
|
* @param {number} seconds Flight duration; 0 jumps.
|
||||||
*/
|
*/
|
||||||
export async function moveCamera(page, view, seconds) {
|
export async function moveCamera(page, view, seconds) {
|
||||||
|
if (
|
||||||
|
await page.evaluate(() => window.__godsEyeView.engine?.kind === 'navara')
|
||||||
|
) {
|
||||||
|
await page.evaluate(
|
||||||
|
async (v, duration) => {
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
const camera = window.__godsEyeView.engine.camera;
|
||||||
|
camera.cancelFlight();
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
camera.flyTo({
|
||||||
|
destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height),
|
||||||
|
orientation: {
|
||||||
|
heading: ((v.heading ?? 0) * Math.PI) / 180,
|
||||||
|
pitch: ((v.pitch ?? -45) * Math.PI) / 180,
|
||||||
|
roll: 0,
|
||||||
|
},
|
||||||
|
duration,
|
||||||
|
maximumHeight: Math.max(v.height, camera.height()),
|
||||||
|
complete: resolve,
|
||||||
|
cancel: resolve,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
view,
|
||||||
|
seconds,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(v, duration) =>
|
(v, duration) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
@@ -191,7 +223,8 @@ export async function createScreencast(page, dir, options = {}) {
|
|||||||
const list = path.join(dir, 'frames.txt');
|
const list = path.join(dir, 'frames.txt');
|
||||||
fs.writeFileSync(list, `${lines.join('\n')}\n`);
|
fs.writeFileSync(list, `${lines.join('\n')}\n`);
|
||||||
const clip = path.join(dir, 'clip.mp4');
|
const clip = path.join(dir, 'clip.mp4');
|
||||||
execFileSync('ffmpeg', [
|
// Keep servicing Puppeteer's request interception while encoding.
|
||||||
|
await execFileAsync('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
'-loglevel',
|
'-loglevel',
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
* legibility over bright ground.
|
* legibility over bright ground.
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after
|
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after [--engine=cesium|navara]
|
||||||
|
*
|
||||||
|
* Engine-neutral: camera, tiles and layers go through the globe engine and the
|
||||||
|
* QA probe; an engine without the flights/satellites layers (Navara today)
|
||||||
|
* shoots the scenes without a contact field and says so.
|
||||||
*
|
*
|
||||||
* Every scene pins its own camera pose AND substitutes a deterministic
|
* Every scene pins its own camera pose AND substitutes a deterministic
|
||||||
* synthetic contact field for the live pollers, so a `before` run and an
|
* synthetic contact field for the live pollers, so a `before` run and an
|
||||||
@@ -36,7 +40,12 @@ const getOpt = (name, fallback) => {
|
|||||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const APP_URL = getOpt('--url', 'http://localhost:4244');
|
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
|
||||||
|
const APP_URL = (() => {
|
||||||
|
const url = new URL(getOpt('--url', 'http://localhost:4244'));
|
||||||
|
url.searchParams.set('engine', ENGINE);
|
||||||
|
return url.href;
|
||||||
|
})();
|
||||||
const TAG = getOpt('--tag', 'after');
|
const TAG = getOpt('--tag', 'after');
|
||||||
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
|
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
|
||||||
const SCENE_FILTER = getOpt('--scenes', '')
|
const SCENE_FILTER = getOpt('--scenes', '')
|
||||||
@@ -205,11 +214,8 @@ async function settle(page) {
|
|||||||
while (Date.now() - started < SETTLE_MAX_MS) {
|
while (Date.now() - started < SETTLE_MAX_MS) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
const state = await page.evaluate(() => {
|
const state = await page.evaluate(() => {
|
||||||
const { viewer, tileset } = window.__godsEyeView;
|
const { engine } = window.__godsEyeView;
|
||||||
return {
|
return { tilesLoaded: engine.surface.tilesLoaded(), pending: 0 };
|
||||||
tilesLoaded: tileset?.tilesLoaded !== false,
|
|
||||||
pending: viewer.scene.globe?._surface?._tileLoadQueueHigh?.length ?? 0,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
const settled = state.tilesLoaded && state.pending === 0;
|
const settled = state.tilesLoaded && state.pending === 0;
|
||||||
if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true;
|
if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true;
|
||||||
@@ -239,7 +245,7 @@ async function main() {
|
|||||||
...(executablePath ? { executablePath } : {}),
|
...(executablePath ? { executablePath } : {}),
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox', '--disable-setuid-sandbox',
|
'--no-sandbox', '--disable-setuid-sandbox',
|
||||||
'--use-gl=angle', '--use-angle=swiftshader',
|
'--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||||
'--disable-dev-shm-usage', '--disable-web-security',
|
'--disable-dev-shm-usage', '--disable-web-security',
|
||||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||||
'--window-size=1280,800',
|
'--window-size=1280,800',
|
||||||
@@ -254,7 +260,7 @@ async function main() {
|
|||||||
|
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
|
||||||
{ timeout: 60_000, polling: 100 },
|
{ timeout: 60_000, polling: 100 },
|
||||||
);
|
);
|
||||||
// flyToAustin arrives ~500 ms after init; let it start and land before any
|
// flyToAustin arrives ~500 ms after init; let it start and land before any
|
||||||
@@ -268,12 +274,16 @@ async function main() {
|
|||||||
|
|
||||||
for (const scene of selected) {
|
for (const scene of selected) {
|
||||||
process.stdout.write(` ${scene.id} … `);
|
process.stdout.write(` ${scene.id} … `);
|
||||||
await page.evaluate((spec) => {
|
const fieldShown = await page.evaluate(async (spec) => {
|
||||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
const { engine, dataManager, styleManager, layers } = window.__godsEyeView;
|
||||||
viewer.camera.cancelFlight();
|
const geo = await import('/src/geo/index.js');
|
||||||
const Cartesian3 = viewer.camera.position.constructor;
|
engine.camera.cancelFlight();
|
||||||
|
const Cartesian3 = Object.assign(
|
||||||
|
(x, y, z) => geo.cartesian3.create(x, y, z),
|
||||||
|
{ fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) },
|
||||||
|
);
|
||||||
|
|
||||||
viewer.camera.setView({
|
engine.camera.setView({
|
||||||
destination: Cartesian3.fromDegrees(
|
destination: Cartesian3.fromDegrees(
|
||||||
spec.camera.lon, spec.camera.lat, spec.camera.height,
|
spec.camera.lon, spec.camera.lat, spec.camera.height,
|
||||||
),
|
),
|
||||||
@@ -290,10 +300,9 @@ async function main() {
|
|||||||
// the frame comes back with two labels instead of a field. Building
|
// the frame comes back with two labels instead of a field. Building
|
||||||
// straight off the camera basis guarantees every contact lands inside
|
// straight off the camera basis guarantees every contact lands inside
|
||||||
// the keyhole, at a known depth, in both the before and after runs.
|
// the keyhole, at a known depth, in both the before and after runs.
|
||||||
const camera = viewer.camera;
|
const camera = engine.camera.pose();
|
||||||
const frustum = camera.frustum;
|
const halfHeight = Math.tan((camera.fovy ?? 1.0) / 2);
|
||||||
const halfHeight = Math.tan((frustum.fovy ?? frustum.fov ?? 1.0) / 2);
|
const halfWidth = halfHeight * (camera.aspectRatio || 1.6);
|
||||||
const halfWidth = halfHeight * (frustum.aspectRatio || 1.6);
|
|
||||||
const basis = {
|
const basis = {
|
||||||
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
|
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
|
||||||
dx: camera.direction.x, dy: camera.direction.y, dz: camera.direction.z,
|
dx: camera.direction.x, dy: camera.direction.y, dz: camera.direction.z,
|
||||||
@@ -305,7 +314,7 @@ async function main() {
|
|||||||
const pointAt = (ndcX, ndcY, distance) => {
|
const pointAt = (ndcX, ndcY, distance) => {
|
||||||
const sx = ndcX * halfWidth * distance;
|
const sx = ndcX * halfWidth * distance;
|
||||||
const sy = ndcY * halfHeight * distance;
|
const sy = ndcY * halfHeight * distance;
|
||||||
return new Cartesian3(
|
return Cartesian3(
|
||||||
basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy,
|
basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy,
|
||||||
basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy,
|
basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy,
|
||||||
basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy,
|
basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy,
|
||||||
@@ -347,6 +356,9 @@ async function main() {
|
|||||||
? { satellites: build('s'), flights: [], military: [] }
|
? { satellites: build('s'), flights: [], military: [] }
|
||||||
: { flights: build('f'), military: [], satellites: [] };
|
: { flights: build('f'), military: [], satellites: [] };
|
||||||
window.__LABEL_EVIDENCE = assignment;
|
window.__LABEL_EVIDENCE = assignment;
|
||||||
|
const fieldLayers = Object.keys(assignment).filter(
|
||||||
|
(layerId) => assignment[layerId].length,
|
||||||
|
);
|
||||||
for (const layerId of Object.keys(assignment)) {
|
for (const layerId of Object.keys(assignment)) {
|
||||||
const entry = dataManager.layers.get(layerId);
|
const entry = dataManager.layers.get(layerId);
|
||||||
if (entry?.module) {
|
if (entry?.module) {
|
||||||
@@ -362,6 +374,7 @@ async function main() {
|
|||||||
densityPct: 100,
|
densityPct: 100,
|
||||||
allocationStrategy: 'elastic',
|
allocationStrategy: 'elastic',
|
||||||
});
|
});
|
||||||
|
return fieldLayers.every((layerId) => layers.available(layerId));
|
||||||
}, scene);
|
}, scene);
|
||||||
|
|
||||||
const converged = await settle(page);
|
const converged = await settle(page);
|
||||||
@@ -399,10 +412,11 @@ async function main() {
|
|||||||
const diagnostics = await page.evaluate(
|
const diagnostics = await page.evaluate(
|
||||||
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
|
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
|
||||||
);
|
);
|
||||||
results.push({ scene: scene.id, converged, ...stats });
|
results.push({ scene: scene.id, engine: ENGINE, converged, fieldShown, ...stats });
|
||||||
console.log(
|
console.log(
|
||||||
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
|
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
|
||||||
+ `${converged ? '' : ' (tiles did not fully settle)'}`
|
+ `${converged ? '' : ' (tiles did not fully settle)'}`
|
||||||
|
+ `${fieldShown ? '' : ` (no contact field: its layers are not on ${ENGINE})`}`
|
||||||
+ ` [${diagnostics.detectionMode || '?'}]`,
|
+ ` [${diagnostics.detectionMode || '?'}]`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-24
@@ -3,7 +3,12 @@
|
|||||||
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
|
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
* node scripts/qa-labels.mjs --url http://localhost:4173
|
* node scripts/qa-labels.mjs --url http://localhost:4173 [--engine=cesium|navara]
|
||||||
|
*
|
||||||
|
* Engine-neutral: the camera and frames go through the globe engine and the
|
||||||
|
* layers through the QA probe (window.__godsEyeView.layers). On an engine
|
||||||
|
* without the flights, military and satellites layers (Navara today) the
|
||||||
|
* field checks are SKIPPED and the settings checks still run.
|
||||||
*
|
*
|
||||||
* The harness replaces only the three detection read APIs in the loaded page.
|
* The harness replaces only the three detection read APIs in the loaded page.
|
||||||
* It does not enable their network pollers or mutate repository data. The field
|
* It does not enable their network pollers or mutate repository data. The field
|
||||||
@@ -22,7 +27,12 @@ const getOpt = (name, fallback) => {
|
|||||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
|
||||||
|
const APP_URL = (() => {
|
||||||
|
const url = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||||
|
url.searchParams.set('engine', ENGINE);
|
||||||
|
return url.href;
|
||||||
|
})();
|
||||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||||
const HEADFUL = getFlag('--headful');
|
const HEADFUL = getFlag('--headful');
|
||||||
const SHOT_DIR = path.resolve('qa-shots/labels');
|
const SHOT_DIR = path.resolve('qa-shots/labels');
|
||||||
@@ -74,6 +84,8 @@ function findChromeExecutable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
let skippedChecks = 0;
|
||||||
|
let skippedField = false;
|
||||||
function record(name, ok, detail) {
|
function record(name, ok, detail) {
|
||||||
results.push({ name, ok, detail });
|
results.push({ 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';
|
||||||
@@ -214,7 +226,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',
|
||||||
@@ -263,7 +275,7 @@ async function main() {
|
|||||||
|
|
||||||
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?.viewer && window.__godsEyeView?.styleManager,
|
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
|
||||||
{ timeout: 60000, polling: 100 },
|
{ timeout: 60000, polling: 100 },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -271,7 +283,7 @@ async function main() {
|
|||||||
// Let that callback start, then cancel it before establishing the global
|
// Let that callback start, then cancel it before establishing the global
|
||||||
// measurement camera so startup motion cannot invalidate the sample.
|
// measurement camera so startup motion cannot invalidate the sample.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
await page.evaluate(() => window.__godsEyeView.viewer.camera.cancelFlight());
|
await page.evaluate(() => window.__godsEyeView.engine.camera.cancelFlight());
|
||||||
|
|
||||||
const stateChecks = await page.evaluate((storageKey) => {
|
const stateChecks = await page.evaluate((storageKey) => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
@@ -297,10 +309,62 @@ async function main() {
|
|||||||
`OFF=${stateChecks.off.densityPct}%, restore=${stateChecks.restoredFromOff.detectionMode}/${stateChecks.restoredFromOff.densityPct}%`,
|
`OFF=${stateChecks.off.densityPct}%, restore=${stateChecks.restoredFromOff.detectionMode}/${stateChecks.restoredFromOff.densityPct}%`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const injected = await page.evaluate(({ fieldCounts, normalCounts }) => {
|
const fieldLayers = ['flights', 'military', 'satellites'];
|
||||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
const missingLayers = await page.evaluate(
|
||||||
viewer.camera.cancelFlight();
|
(ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
|
||||||
const Cartesian3 = viewer.camera.position.constructor;
|
fieldLayers,
|
||||||
|
);
|
||||||
|
// An engine with flights but not the other field layers: the same 12,000 / 5,200 observations, all as aircraft, so the
|
||||||
|
// detection lane is measured on the layers the engine has.
|
||||||
|
let fieldCounts = FIELD_COUNTS;
|
||||||
|
let normalCounts = NORMAL_COUNTS;
|
||||||
|
if (missingLayers.length && !missingLayers.includes('flights')) {
|
||||||
|
const sum = (counts) =>
|
||||||
|
Object.values(counts).reduce((total, count) => total + count, 0);
|
||||||
|
fieldCounts = Object.fromEntries(
|
||||||
|
fieldLayers.map((id) => [
|
||||||
|
id,
|
||||||
|
missingLayers.includes(id)
|
||||||
|
? 0
|
||||||
|
: id === 'flights'
|
||||||
|
? sum(FIELD_COUNTS) - fieldLayers.filter((other) => other !== 'flights' && !missingLayers.includes(other)).reduce((total, other) => total + FIELD_COUNTS[other], 0)
|
||||||
|
: FIELD_COUNTS[id],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
normalCounts = Object.fromEntries(
|
||||||
|
fieldLayers.map((id) => [
|
||||||
|
id,
|
||||||
|
missingLayers.includes(id)
|
||||||
|
? 0
|
||||||
|
: id === 'flights'
|
||||||
|
? sum(NORMAL_COUNTS) - fieldLayers.filter((other) => other !== 'flights' && !missingLayers.includes(other)).reduce((total, other) => total + NORMAL_COUNTS[other], 0)
|
||||||
|
: NORMAL_COUNTS[id],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` [INFO] the field as ${JSON.stringify(fieldCounts)} (no ${missingLayers.join(', ')} on ${ENGINE})`,
|
||||||
|
);
|
||||||
|
} else if (missingLayers.length) {
|
||||||
|
console.log(` [SKIP] the deterministic field checks — no ${missingLayers.join(', ')} on ${ENGINE}`);
|
||||||
|
skippedChecks += 1;
|
||||||
|
record(
|
||||||
|
'console remains clean',
|
||||||
|
consoleErrors.length === 0,
|
||||||
|
consoleErrors.slice(0, 3).join(' | ') || '0 errors',
|
||||||
|
);
|
||||||
|
skippedField = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const injected = await page.evaluate(async ({ fieldCounts, normalCounts }) => {
|
||||||
|
const { engine, dataManager, styleManager } = window.__godsEyeView;
|
||||||
|
const geo = await import('/src/geo/index.js');
|
||||||
|
engine.camera.cancelFlight();
|
||||||
|
// A frame counter on the engine (the samples are one per drawn frame).
|
||||||
|
window.__LABEL_QA_FRAME = 0;
|
||||||
|
engine.frames.postRender.addEventListener(() => {
|
||||||
|
window.__LABEL_QA_FRAME += 1;
|
||||||
|
});
|
||||||
|
const Cartesian3 = { fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) };
|
||||||
const field = { flights: [], military: [], satellites: [] };
|
const field = { flights: [], military: [], satellites: [] };
|
||||||
const layerIds = Object.keys(field);
|
const layerIds = Object.keys(field);
|
||||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||||
@@ -328,13 +392,19 @@ async function main() {
|
|||||||
window.__LABEL_QA_NORMAL_COUNTS = normalCounts;
|
window.__LABEL_QA_NORMAL_COUNTS = normalCounts;
|
||||||
for (const layerId of layerIds) {
|
for (const layerId of layerIds) {
|
||||||
const entry = dataManager.layers.get(layerId);
|
const entry = dataManager.layers.get(layerId);
|
||||||
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
|
if (entry)
|
||||||
|
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer.camera.setView({
|
// The global camera, orbiting west to east (the old rotateRight about
|
||||||
destination: Cartesian3.fromDegrees(-97, 30, 25000000),
|
// the globe centre at 25 000 km).
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
let orbitLon = -97;
|
||||||
});
|
const placeCamera = () =>
|
||||||
|
engine.camera.setView({
|
||||||
|
destination: Cartesian3.fromDegrees(orbitLon, 30, 25000000),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
placeCamera();
|
||||||
styleManager.setDetection({
|
styleManager.setDetection({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
densityPct: 100,
|
densityPct: 100,
|
||||||
@@ -351,13 +421,14 @@ async function main() {
|
|||||||
let lastOrbitAt = performance.now();
|
let lastOrbitAt = performance.now();
|
||||||
window.__LABEL_QA_ORBIT = setInterval(() => {
|
window.__LABEL_QA_ORBIT = setInterval(() => {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
viewer.camera.rotateRight((now - lastOrbitAt) * 0.0000012);
|
orbitLon -= ((now - lastOrbitAt) * 0.0000012 * 180) / Math.PI;
|
||||||
lastOrbitAt = now;
|
lastOrbitAt = now;
|
||||||
viewer.scene.requestRender();
|
placeCamera();
|
||||||
|
engine.render.request('qa-labels');
|
||||||
}, 100);
|
}, 100);
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
const diagnostics = styleManager.getDetectionDiagnostics();
|
const diagnostics = styleManager.getDetectionDiagnostics();
|
||||||
const frameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
const frameNumber = window.__LABEL_QA_FRAME;
|
||||||
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
|
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
|
||||||
window.__LABEL_QA.lastFrameNumber = frameNumber;
|
window.__LABEL_QA.lastFrameNumber = frameNumber;
|
||||||
window.__LABEL_QA.frames.push({
|
window.__LABEL_QA.frames.push({
|
||||||
@@ -387,7 +458,7 @@ async function main() {
|
|||||||
observationCount: Number(dataset.observationCount),
|
observationCount: Number(dataset.observationCount),
|
||||||
selectedCount: Number(dataset.selectedCount),
|
selectedCount: Number(dataset.selectedCount),
|
||||||
fadingCount: Number(dataset.fadingCount),
|
fadingCount: Number(dataset.fadingCount),
|
||||||
cameraHeight: viewer.camera.positionCartographic.height,
|
cameraHeight: engine.camera.height(),
|
||||||
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
|
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
|
||||||
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
|
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
|
||||||
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
|
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
|
||||||
@@ -398,7 +469,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
requestAnimationFrame(sample);
|
requestAnimationFrame(sample);
|
||||||
return Object.fromEntries(layerIds.map((layerId) => [layerId, field[layerId].length]));
|
return Object.fromEntries(layerIds.map((layerId) => [layerId, field[layerId].length]));
|
||||||
}, { fieldCounts: FIELD_COUNTS, normalCounts: NORMAL_COUNTS });
|
}, { fieldCounts, normalCounts });
|
||||||
|
|
||||||
record(
|
record(
|
||||||
'deterministic field contains exactly 12,000 observations',
|
'deterministic field contains exactly 12,000 observations',
|
||||||
@@ -412,12 +483,11 @@ async function main() {
|
|||||||
// -samples gates supersede it — same fix, deterministic instead of timed.)
|
// -samples gates supersede it — same fix, deterministic instead of timed.)
|
||||||
await waitForSettledSyntheticField(page);
|
await waitForSettledSyntheticField(page);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
|
||||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||||
window.__LABEL_QA.solve = [];
|
window.__LABEL_QA.solve = [];
|
||||||
window.__LABEL_QA.frames = [];
|
window.__LABEL_QA.frames = [];
|
||||||
window.__LABEL_QA.lastRevision = revision;
|
window.__LABEL_QA.lastRevision = revision;
|
||||||
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
window.__LABEL_QA.lastFrameNumber = window.__LABEL_QA_FRAME;
|
||||||
window.__LABEL_QA.startedAt = performance.now();
|
window.__LABEL_QA.startedAt = performance.now();
|
||||||
});
|
});
|
||||||
const syntheticSampling = await waitForConclusiveSamples(page, {
|
const syntheticSampling = await waitForConclusiveSamples(page, {
|
||||||
@@ -452,12 +522,11 @@ async function main() {
|
|||||||
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
|
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
|
||||||
);
|
);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
|
||||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||||
window.__LABEL_QA.solve = [];
|
window.__LABEL_QA.solve = [];
|
||||||
window.__LABEL_QA.frames = [];
|
window.__LABEL_QA.frames = [];
|
||||||
window.__LABEL_QA.lastRevision = revision;
|
window.__LABEL_QA.lastRevision = revision;
|
||||||
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
window.__LABEL_QA.lastFrameNumber = window.__LABEL_QA_FRAME;
|
||||||
window.__LABEL_QA.startedAt = performance.now();
|
window.__LABEL_QA.startedAt = performance.now();
|
||||||
});
|
});
|
||||||
const normalSampling = await waitForConclusiveSamples(page, {
|
const normalSampling = await waitForConclusiveSamples(page, {
|
||||||
@@ -579,10 +648,15 @@ async function main() {
|
|||||||
console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`);
|
console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
if (skippedField) summarize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summarize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize() {
|
||||||
const failures = results.filter((result) => !result.ok);
|
const failures = results.filter((result) => !result.ok);
|
||||||
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
|
console.log(`\n${results.length - failures.length}/${results.length} checks passed${skippedChecks ? `, ${skippedChecks} skipped` : ''} (engine ${ENGINE}).`);
|
||||||
if (failures.length) process.exit(1);
|
if (failures.length) process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||||
|
|
||||||
|
const url = localPreview(process.argv[2]);
|
||||||
|
const military = process.argv.includes('--military');
|
||||||
|
const layerId = military ? 'military' : 'flights';
|
||||||
|
const aircraftId = military ? 'aaa052' : '71be26';
|
||||||
|
url.search = '?engine=navara&welcome=0';
|
||||||
|
url.hash =
|
||||||
|
'v=2&lat=40.2539&lon=-125.5779&alt=21130&heading=360&pitch=-35&map=sentinel2' +
|
||||||
|
(military ? '&l=m&lo=f.e.1_f.u.aaa052' : '&l=f&lo=f.e.1_f.t.71be26');
|
||||||
|
// The Command Dock, and the Globe Engine picker in it, is hidden in cockpit
|
||||||
|
// mode: no person can pick an engine there, so the cockpit switches below go
|
||||||
|
// through the select element; the real picker is used once the cockpit is
|
||||||
|
// left.
|
||||||
|
const HIDDEN_PICKER = 'the Globe Engine picker is hidden in cockpit mode';
|
||||||
|
const strict = createStrict({ origin: url.origin, layers: [layerId] });
|
||||||
|
await strict.run({}, async (browser) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
await page.setViewport({ width: 1280, height: 720 });
|
||||||
|
await page.evaluateOnNewDocument(
|
||||||
|
(layerId, aircraftId) => {
|
||||||
|
window.__qaAircraft = { layerId, aircraftId };
|
||||||
|
const fetch = window.fetch.bind(window);
|
||||||
|
window.__flightPolls = 0;
|
||||||
|
const interval = window.setInterval.bind(window);
|
||||||
|
window.setInterval = function (callback, delay, ...args) {
|
||||||
|
if (typeof callback !== 'function')
|
||||||
|
return interval(callback, delay, ...args);
|
||||||
|
const id = interval(
|
||||||
|
function (...values) {
|
||||||
|
if (window.__handoff?.timer === id) window.__handoff.ticks++;
|
||||||
|
return callback.apply(this, values);
|
||||||
|
},
|
||||||
|
delay,
|
||||||
|
...args,
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
window.fetch = (input, options) => {
|
||||||
|
const path = new URL(input.url ?? input, document.baseURI).pathname;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (path === '/api/flights') {
|
||||||
|
if (layerId === 'flights') window.__flightPolls++;
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
time: now,
|
||||||
|
states: [
|
||||||
|
[
|
||||||
|
'71be26',
|
||||||
|
'GEV123',
|
||||||
|
'QA',
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
-125.5779,
|
||||||
|
40.2539,
|
||||||
|
9700,
|
||||||
|
false,
|
||||||
|
200,
|
||||||
|
45,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
9700,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (path === '/api/military') {
|
||||||
|
if (layerId === 'military') window.__flightPolls++;
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
now: Date.now(),
|
||||||
|
ac: [
|
||||||
|
{
|
||||||
|
hex: 'aaa052',
|
||||||
|
flight: 'GEVMIL',
|
||||||
|
lon: -125.5779,
|
||||||
|
lat: 40.2539,
|
||||||
|
alt_geom: 31825,
|
||||||
|
alt_baro: 31825,
|
||||||
|
gs: 389,
|
||||||
|
track: 45,
|
||||||
|
seen: 0,
|
||||||
|
seen_pos: 0,
|
||||||
|
dbFlags: 1,
|
||||||
|
type: 'adsb_icao',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (path === '/api/flights/track')
|
||||||
|
return Promise.resolve(Response.json({ path: [] }));
|
||||||
|
if (path.startsWith('/api/flights/'))
|
||||||
|
return Promise.resolve(Response.json({}));
|
||||||
|
return fetch(input, options);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
layerId,
|
||||||
|
aircraftId,
|
||||||
|
);
|
||||||
|
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||||
|
const ready = (kind) =>
|
||||||
|
page.waitForFunction(
|
||||||
|
(engine) =>
|
||||||
|
window.__godsEyeView?.engine?.kind === engine &&
|
||||||
|
!document.querySelector('#globe-engine')?.disabled &&
|
||||||
|
document.querySelector('#loading-screen.hidden') &&
|
||||||
|
!document.querySelector('.engine-switch-frame'),
|
||||||
|
{ timeout: 90000 },
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
await ready('navara');
|
||||||
|
await page.evaluate(
|
||||||
|
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||||
|
);
|
||||||
|
await page.waitForSelector('#cockpit-entry', { visible: true });
|
||||||
|
await page.click('#cockpit-entry');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.styleManager.getCockpitState().active,
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||||
|
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||||
|
throw Error('Keyless preview required');
|
||||||
|
styleManager.cockpitView.setVisionMode('thermal');
|
||||||
|
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||||
|
window.__handoff = {
|
||||||
|
dataManager,
|
||||||
|
entry,
|
||||||
|
row: document.querySelector(
|
||||||
|
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
|
||||||
|
),
|
||||||
|
timer: entry.intervalId,
|
||||||
|
ticks: 0,
|
||||||
|
model: entry.module,
|
||||||
|
pollCount: window.__flightPolls,
|
||||||
|
timestamp: entry.module.getStats().lastUpdate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Refuse the first Cesium canvas. The application must roll back with the
|
||||||
|
// original aircraft still in the cockpit, then allow an ordinary retry.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const original = HTMLCanvasElement.prototype.getContext;
|
||||||
|
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
|
||||||
|
if (String(type).includes('webgl')) {
|
||||||
|
window.__refusedContext = true;
|
||||||
|
throw new Error('QA renderer startup failure');
|
||||||
|
}
|
||||||
|
return original.call(this, type, ...args);
|
||||||
|
};
|
||||||
|
window.__restoreContext = () => {
|
||||||
|
HTMLCanvasElement.prototype.getContext = original;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const historyLength = await page.evaluate(() => history.length);
|
||||||
|
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
|
||||||
|
await page.waitForFunction(() => window.__refusedContext === true);
|
||||||
|
await ready('navara');
|
||||||
|
// A failed switch leaves the session usable on the engine that worked and
|
||||||
|
// adds no history entry.
|
||||||
|
const survived = (kind) =>
|
||||||
|
page.evaluate(
|
||||||
|
(kind, length) => {
|
||||||
|
const { dataManager, engine, styleManager } = window.__godsEyeView;
|
||||||
|
return (
|
||||||
|
engine.kind === kind &&
|
||||||
|
new URL(location.href).searchParams.get('engine') === kind &&
|
||||||
|
history.length === length &&
|
||||||
|
dataManager === window.__handoff.dataManager &&
|
||||||
|
dataManager.layers.get(window.__qaAircraft.layerId) ===
|
||||||
|
window.__handoff.entry &&
|
||||||
|
styleManager.getCockpitState().active &&
|
||||||
|
styleManager.getCockpitState().visionMode === 'thermal' &&
|
||||||
|
styleManager.getAircraftTrackingTarget()?.id ===
|
||||||
|
window.__qaAircraft.aircraftId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
kind,
|
||||||
|
historyLength,
|
||||||
|
);
|
||||||
|
await page.evaluate(() => window.__restoreContext());
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
layerId,
|
||||||
|
'cold Cesium failure rolls back to Navara: same session, cockpit, aircraft, no history entry',
|
||||||
|
await survived('navara'),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'PASS cold Cesium failure rolled back to Navara with the same flight, cockpit and history',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const kind of ['cesium', 'navara']) {
|
||||||
|
await strict.selectEngine(page, kind, HIDDEN_PICKER);
|
||||||
|
await ready(kind);
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const { dataManager, engine, styleManager } = window.__godsEyeView;
|
||||||
|
const before = window.__handoff;
|
||||||
|
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||||
|
const cockpit = styleManager.getCockpitState();
|
||||||
|
return {
|
||||||
|
sameOwner:
|
||||||
|
before.dataManager === dataManager &&
|
||||||
|
before.entry === entry &&
|
||||||
|
before.model === entry.module,
|
||||||
|
sameRow:
|
||||||
|
Boolean(before.row) &&
|
||||||
|
before.row ===
|
||||||
|
document.querySelector(
|
||||||
|
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
|
||||||
|
),
|
||||||
|
sameTimer:
|
||||||
|
before.timer === entry.intervalId && entry.intervalId != null,
|
||||||
|
sourcePolls: window.__flightPolls - before.pollCount,
|
||||||
|
timerTicks: before.ticks,
|
||||||
|
stamp: entry.module.getStats().lastUpdate,
|
||||||
|
initialStamp: before.timestamp,
|
||||||
|
id: cockpit.subject?.id,
|
||||||
|
active: cockpit.active,
|
||||||
|
mode: cockpit.visionMode,
|
||||||
|
hud: !document.querySelector('#cockpit-hud').hidden,
|
||||||
|
drawn: entry.module
|
||||||
|
.qaDrawn()
|
||||||
|
.some((row) => row.id === window.__qaAircraft.aircraftId),
|
||||||
|
height: engine.camera.height(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
result.sameOwner &&
|
||||||
|
result.sameTimer &&
|
||||||
|
result.sameRow &&
|
||||||
|
result.active &&
|
||||||
|
result.hud &&
|
||||||
|
result.drawn,
|
||||||
|
JSON.stringify(result),
|
||||||
|
);
|
||||||
|
assert.equal(result.id, aircraftId);
|
||||||
|
assert.equal(result.mode, 'thermal');
|
||||||
|
assert.ok(
|
||||||
|
result.sourcePolls <= result.timerTicks,
|
||||||
|
'switch must not add a source request',
|
||||||
|
);
|
||||||
|
if (result.timerTicks === 0) {
|
||||||
|
assert.equal(result.stamp, result.initialStamp);
|
||||||
|
}
|
||||||
|
await strict.passed(
|
||||||
|
page,
|
||||||
|
layerId,
|
||||||
|
'same manager entry, model, row, poll timer, cockpit, thermal mode, HUD and drawn aircraft',
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
// Polling goes on after the switch: the same timer ticks and each tick
|
||||||
|
// reads the source once (no tick may be skipped, none added).
|
||||||
|
const polls = await page.evaluate(async (layerId) => {
|
||||||
|
const entry = window.__godsEyeView.dataManager.layers.get(layerId);
|
||||||
|
const every =
|
||||||
|
Number(entry.module.refreshInterval) ||
|
||||||
|
Number(entry.module.updateInterval);
|
||||||
|
const start = {
|
||||||
|
polls: window.__flightPolls,
|
||||||
|
ticks: window.__handoff.ticks,
|
||||||
|
stamp: entry.module.getStats().lastUpdate,
|
||||||
|
};
|
||||||
|
const deadline = Date.now() + 2.5 * every + 5000;
|
||||||
|
while (window.__handoff.ticks - start.ticks < 2 && Date.now() < deadline)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
// Let the last tick's request land.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
return {
|
||||||
|
every,
|
||||||
|
ticks: window.__handoff.ticks - start.ticks,
|
||||||
|
polls: window.__flightPolls - start.polls,
|
||||||
|
updated: entry.module.getStats().lastUpdate !== start.stamp,
|
||||||
|
sameTimer: entry.intervalId === window.__handoff.timer,
|
||||||
|
};
|
||||||
|
}, layerId);
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
layerId,
|
||||||
|
'polling continues after the switch: two timer ticks, one source read each, fresh data',
|
||||||
|
polls.every > 0 &&
|
||||||
|
polls.ticks >= 2 &&
|
||||||
|
polls.polls === polls.ticks &&
|
||||||
|
polls.updated &&
|
||||||
|
polls.sameTimer,
|
||||||
|
polls,
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => Math.abs(window.__godsEyeView.engine.camera.height() - 9700) < 1000,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The other direction, warm: the parked Navara renderer fails to resume on
|
||||||
|
// the way back from Cesium. The session rolls back to Cesium, then an
|
||||||
|
// ordinary retry starts Navara cold.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__parkedNavara = window.__godsEyeView.engine;
|
||||||
|
});
|
||||||
|
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
|
||||||
|
await ready('cesium');
|
||||||
|
const lengthOnCesium = await page.evaluate(() => {
|
||||||
|
const request = window.__parkedNavara.render.request;
|
||||||
|
window.__parkedNavara.render.request = function (...args) {
|
||||||
|
window.__parkedNavara.render.request = request;
|
||||||
|
window.__refusedResume = true;
|
||||||
|
throw new Error('QA parked renderer failure');
|
||||||
|
};
|
||||||
|
return history.length;
|
||||||
|
});
|
||||||
|
await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
|
||||||
|
await page.waitForFunction(() => window.__refusedResume === true);
|
||||||
|
await ready('cesium');
|
||||||
|
assert.ok(
|
||||||
|
(await page.evaluate(
|
||||||
|
(length) => history.length === length,
|
||||||
|
lengthOnCesium,
|
||||||
|
)) &&
|
||||||
|
(await page.evaluate(() => {
|
||||||
|
const { styleManager } = window.__godsEyeView;
|
||||||
|
return (
|
||||||
|
styleManager.getCockpitState().active &&
|
||||||
|
styleManager.getAircraftTrackingTarget()?.id ===
|
||||||
|
window.__qaAircraft.aircraftId &&
|
||||||
|
new URL(location.href).searchParams.get('engine') === 'cesium'
|
||||||
|
);
|
||||||
|
})),
|
||||||
|
'a failed warm Navara resume must leave Cesium usable without a history entry',
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
page,
|
||||||
|
layerId,
|
||||||
|
'warm Navara failure rolls back to Cesium: same aircraft and cockpit, no history entry',
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit',
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__parkedCesium = window.__godsEyeView.engine;
|
||||||
|
});
|
||||||
|
await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
|
||||||
|
await ready('navara');
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { engine, styleManager } = window.__godsEyeView;
|
||||||
|
return (
|
||||||
|
engine !== window.__parkedNavara &&
|
||||||
|
styleManager.getCockpitState().active &&
|
||||||
|
styleManager.getAircraftTrackingTarget()?.id ===
|
||||||
|
window.__qaAircraft.aircraftId
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
'the retry starts a new Navara renderer on the same session',
|
||||||
|
);
|
||||||
|
console.log('PASS retry after the warm failure started Navara cold');
|
||||||
|
|
||||||
|
// Both engines fail: the warm Cesium resume and the warm Navara recovery.
|
||||||
|
// No engine runs, but the error screen offers the engine picker, and a
|
||||||
|
// retry starts Cesium cold on the same session without a reload.
|
||||||
|
const lengthBeforeDoubleFailure = await page.evaluate(() => {
|
||||||
|
window.__failResume = true;
|
||||||
|
for (const engine of [window.__godsEyeView.engine, window.__parkedCesium]) {
|
||||||
|
const request = engine.render.request;
|
||||||
|
engine.render.request = function (reason, ...args) {
|
||||||
|
if (reason === 'engine-resume' && window.__failResume) {
|
||||||
|
window.__refusedResumes = (window.__refusedResumes ?? 0) + 1;
|
||||||
|
throw new Error('QA parked renderer failure');
|
||||||
|
}
|
||||||
|
return request.call(this, reason, ...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.__startDocument = document;
|
||||||
|
return history.length;
|
||||||
|
});
|
||||||
|
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
|
||||||
|
await page
|
||||||
|
.waitForSelector('#loading-screen .engine-switch-retry button', {
|
||||||
|
visible: true,
|
||||||
|
timeout: 90000,
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.log(
|
||||||
|
'no retry picker',
|
||||||
|
JSON.stringify(
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
refused: window.__refusedResumes,
|
||||||
|
engine: window.__godsEyeView?.engine?.kind ?? null,
|
||||||
|
retry: !!document.querySelector('.engine-switch-retry'),
|
||||||
|
loading: document.querySelector('#loading-screen')?.className,
|
||||||
|
status: document.querySelector('#loading-screen .loader-status')
|
||||||
|
?.textContent,
|
||||||
|
address: location.search,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(
|
||||||
|
(length) =>
|
||||||
|
window.__refusedResumes === 2 &&
|
||||||
|
// No globe runs (the page's debug object may stay, without one).
|
||||||
|
!window.__godsEyeView?.engine &&
|
||||||
|
history.length === length &&
|
||||||
|
!document
|
||||||
|
.querySelector('#loading-screen')
|
||||||
|
.classList.contains('hidden') &&
|
||||||
|
/QA parked renderer failure/.test(
|
||||||
|
document.querySelector('#loading-screen .loader-status').textContent,
|
||||||
|
),
|
||||||
|
lengthBeforeDoubleFailure,
|
||||||
|
),
|
||||||
|
'a failed recovery shows its error and the engine picker ' +
|
||||||
|
JSON.stringify(
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
refused: window.__refusedResumes,
|
||||||
|
app: !!window.__godsEyeView,
|
||||||
|
engine: window.__godsEyeView?.engine?.kind ?? null,
|
||||||
|
history: history.length,
|
||||||
|
loading: document.querySelector('#loading-screen')?.className,
|
||||||
|
status: document.querySelector('#loading-screen .loader-status')
|
||||||
|
?.textContent,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__failResume = false;
|
||||||
|
});
|
||||||
|
await page.click(
|
||||||
|
'#loading-screen .engine-switch-retry [data-engine="cesium"]',
|
||||||
|
);
|
||||||
|
await ready('cesium');
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { dataManager, styleManager } = window.__godsEyeView;
|
||||||
|
return (
|
||||||
|
window.__startDocument === document &&
|
||||||
|
!document.querySelector('.engine-switch-retry') &&
|
||||||
|
dataManager === window.__handoff.dataManager &&
|
||||||
|
dataManager.layers.get(window.__qaAircraft.layerId) ===
|
||||||
|
window.__handoff.entry &&
|
||||||
|
styleManager.getCockpitState().active &&
|
||||||
|
styleManager.getAircraftTrackingTarget()?.id ===
|
||||||
|
window.__qaAircraft.aircraftId &&
|
||||||
|
new URL(location.href).searchParams.get('engine') === 'cesium'
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
'the retry restores the same session on Cesium',
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
page,
|
||||||
|
layerId,
|
||||||
|
'after both engines fail, the retry restores the same session',
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'PASS failed recovery offered the picker; the retry restored the session on Cesium',
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.controlCockpit('exit'),
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !window.__godsEyeView.styleManager.getCockpitState().active,
|
||||||
|
);
|
||||||
|
// Out of the cockpit the dock shows the picker again: a switch with
|
||||||
|
// real input keeps the same entry and timer, the aircraft drawn, and the
|
||||||
|
// source polled on its timer.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__handoff.ticks = 0;
|
||||||
|
window.__handoff.pollCount = window.__flightPolls;
|
||||||
|
window.__handoff.timer = window.__godsEyeView.dataManager.layers.get(
|
||||||
|
window.__qaAircraft.layerId,
|
||||||
|
).intervalId;
|
||||||
|
});
|
||||||
|
await strict.pick(page, 'navara');
|
||||||
|
await ready('navara');
|
||||||
|
const after = await page.evaluate(async () => {
|
||||||
|
const { dataManager } = window.__godsEyeView;
|
||||||
|
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||||
|
const every =
|
||||||
|
Number(entry.module.refreshInterval) ||
|
||||||
|
Number(entry.module.updateInterval);
|
||||||
|
const deadline = Date.now() + 2.5 * every + 5000;
|
||||||
|
while (window.__handoff.ticks < 1 && Date.now() < deadline)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
return {
|
||||||
|
same:
|
||||||
|
dataManager === window.__handoff.dataManager &&
|
||||||
|
entry === window.__handoff.entry,
|
||||||
|
timer: entry.intervalId === window.__handoff.timer,
|
||||||
|
drawn: entry.module
|
||||||
|
.qaDrawn()
|
||||||
|
.some((row) => row.id === window.__qaAircraft.aircraftId),
|
||||||
|
ticks: window.__handoff.ticks,
|
||||||
|
polls: window.__flightPolls - window.__handoff.pollCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
layerId,
|
||||||
|
'picker switch keeps the entry, timer and drawn aircraft; polling continues',
|
||||||
|
after.same &&
|
||||||
|
after.timer &&
|
||||||
|
after.drawn &&
|
||||||
|
after.ticks >= 1 &&
|
||||||
|
after.polls === after.ticks,
|
||||||
|
after,
|
||||||
|
);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||||
|
const strict = createStrict({ origin: base.origin, layers: ['local-adsb'] });
|
||||||
|
await strict.run({}, async (b) => {
|
||||||
|
const { page: p, errors } = await openPage(b);
|
||||||
|
await p.evaluateOnNewDocument(() => {
|
||||||
|
const raw = window.fetch;
|
||||||
|
window.fetch = function (input, ...args) {
|
||||||
|
const url = new URL(input?.url ?? input, location.href);
|
||||||
|
if (url.pathname === '/api/local-receivers/aircraft') {
|
||||||
|
const at = Date.now();
|
||||||
|
// Each poll reports the aircraft a little further north.
|
||||||
|
const n = (window.qaAdsbPolls = (window.qaAdsbPolls ?? 0) + 1);
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
configured: true,
|
||||||
|
generatedAt: at,
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
icao: 'abc123',
|
||||||
|
callsign: 'LOCAL1',
|
||||||
|
lat: 41.534 + 0.0002 * n,
|
||||||
|
lon: -90.507,
|
||||||
|
altitudeFt: 11000,
|
||||||
|
groundSpeedKt: 180,
|
||||||
|
trackDeg: 5,
|
||||||
|
lastPositionAt: at,
|
||||||
|
lastMessageAt: at,
|
||||||
|
messageCount: 12 + n,
|
||||||
|
band: '1090',
|
||||||
|
source: 'decoder',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return raw.call(this, input, ...args);
|
||||||
|
};
|
||||||
|
window.qaSelections = [];
|
||||||
|
window.addEventListener('gev:entity-selected', ({ detail }) =>
|
||||||
|
window.qaSelections.push(detail.id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await p.goto(base.href);
|
||||||
|
await waitForEngine(p, 'navara');
|
||||||
|
await p.evaluate(async () => {
|
||||||
|
const { dataManager, tileset } = window.__godsEyeView;
|
||||||
|
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||||
|
throw Error('Keyless preview required');
|
||||||
|
const mod = dataManager.layers.get('local-adsb').module;
|
||||||
|
window.qaAdsb = {
|
||||||
|
mod,
|
||||||
|
receiver: mod.receiver,
|
||||||
|
feeds: mod.feeds,
|
||||||
|
starts: 0,
|
||||||
|
stops: 0,
|
||||||
|
closes: 0,
|
||||||
|
};
|
||||||
|
for (const [obj, key, counter] of [
|
||||||
|
[mod.receiver, 'destroy', 'closes'],
|
||||||
|
[mod.feeds, 'start', 'starts'],
|
||||||
|
[mod.feeds, 'stop', 'stops'],
|
||||||
|
]) {
|
||||||
|
const original = obj[key].bind(obj);
|
||||||
|
obj[key] = (...args) => {
|
||||||
|
window.qaAdsb[counter]++;
|
||||||
|
return original(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await dataManager.setEnabled('local-adsb', true, { origin: 'user' });
|
||||||
|
});
|
||||||
|
await p.waitForFunction(
|
||||||
|
() => window.qaAdsb.mod.getAllPositions().length === 1,
|
||||||
|
);
|
||||||
|
// The selection hook the selectable layers share, by the row's own id.
|
||||||
|
assert.ok(
|
||||||
|
await p.evaluate(() =>
|
||||||
|
window.qaAdsb.mod.selectById(window.qaAdsb.mod.getAllPositions()[0].id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const kind of ['cesium', 'navara']) {
|
||||||
|
await p.evaluate(() => {
|
||||||
|
window.qaSelections = [];
|
||||||
|
});
|
||||||
|
await strict.switchTo(p, kind);
|
||||||
|
await p.waitForFunction(() =>
|
||||||
|
window.qaSelections.includes('local-adsb:abc123'),
|
||||||
|
);
|
||||||
|
const result = await p.evaluate(() => {
|
||||||
|
const q = window.qaAdsb,
|
||||||
|
manager = window.__godsEyeView.dataManager;
|
||||||
|
const mod = manager.layers.get('local-adsb').module;
|
||||||
|
return [
|
||||||
|
manager.isEnabled(mod.id),
|
||||||
|
q.mod === mod,
|
||||||
|
q.receiver === mod.receiver,
|
||||||
|
q.feeds === mod.feeds,
|
||||||
|
mod.getAllPositions()[0]?.id,
|
||||||
|
q.starts,
|
||||||
|
q.stops,
|
||||||
|
q.closes,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
assert.deepEqual(result, [true, true, true, true, 'abc123', 1, 0, 0]);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'local-adsb',
|
||||||
|
'same decoder, receiver and feeds, one feed start, selection republished',
|
||||||
|
);
|
||||||
|
// Updates continue on the new engine: further polls move the aircraft.
|
||||||
|
const before = await p.evaluate(() => ({
|
||||||
|
polls: window.qaAdsbPolls,
|
||||||
|
lat: window.qaAdsb.mod.getAnalystRecords()[0]?.lat,
|
||||||
|
stamp: window.qaAdsb.mod.getStats().lastUpdate,
|
||||||
|
}));
|
||||||
|
await p.waitForFunction(
|
||||||
|
(before) =>
|
||||||
|
window.qaAdsbPolls >= before.polls + 2 &&
|
||||||
|
window.qaAdsb.mod.getAnalystRecords()[0]?.lat > before.lat &&
|
||||||
|
window.qaAdsb.mod.getStats().lastUpdate > before.stamp,
|
||||||
|
{ timeout: 15000 },
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
await strict.passed(
|
||||||
|
p,
|
||||||
|
'local-adsb',
|
||||||
|
'receiver polls continue and move the selected aircraft',
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS retained local decoder, receiver, marker and selection in both scenes',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/** Install before navigation, without intercepting workers or ordinary tiles. */
|
||||||
|
export async function blockPaidQaMaps(page, { allowPaidMaps = false } = {}) {
|
||||||
|
if (allowPaidMaps) return;
|
||||||
|
const session = await page.createCDPSession();
|
||||||
|
await session.send('Network.enable');
|
||||||
|
await session.send('Network.setBlockedURLs', {
|
||||||
|
urls: [
|
||||||
|
'*://tile.googleapis.com/*',
|
||||||
|
'*://*.googleapis.com/*3dtiles*',
|
||||||
|
'*://api.cesium.com/v1/assets/2275207/*',
|
||||||
|
'*/api/google/tiles*',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free public tile hosts the strict acceptance lets a page ask for. The page
|
||||||
|
* never reaches them: the strict harness answers them from a disk cache
|
||||||
|
* (scripts/qa/tileCache.mjs), and Node fetches a missing tile once, politely.
|
||||||
|
* OpenStreetMap raster tiles, the keyless re:earth terrain, the EOX
|
||||||
|
* Sentinel-2 cloudless mosaic and Esri World Imagery (the keyless Cesium
|
||||||
|
* base imagery and the Navara startup imagery).
|
||||||
|
*/
|
||||||
|
export const STRICT_TILE_HOSTS = Object.freeze([
|
||||||
|
'tile.openstreetmap.org',
|
||||||
|
'terrain.reearth.land',
|
||||||
|
'tiles.maps.eox.at',
|
||||||
|
'services.arcgisonline.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests every keyless page makes on its own, answered by the strict
|
||||||
|
* harness exactly as the keyless preview would (or with an empty stylesheet
|
||||||
|
* for Google Fonts), so they reach neither the preview's paid routes nor
|
||||||
|
* Google. Any other request under a paid route or host stays blocked.
|
||||||
|
* Keys are URLPattern strings relative to the preview origin or absolute.
|
||||||
|
*/
|
||||||
|
export const KEYLESS_PROBES = Object.freeze([
|
||||||
|
{
|
||||||
|
pattern: '/api/openai/hud-summary/status',
|
||||||
|
method: 'GET',
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: '{"configured":false}',
|
||||||
|
},
|
||||||
|
{ pattern: '/api/google/tiles-token', method: 'GET', status: 204 },
|
||||||
|
{ pattern: '/api/realtime/debug-log', method: 'POST', status: 204 },
|
||||||
|
{
|
||||||
|
pattern: 'https://fonts.googleapis.com/css2*',
|
||||||
|
method: 'GET',
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/css',
|
||||||
|
body: '/* Google Fonts withheld by the strict QA policy */',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** A probe's absolute URLPattern string. */
|
||||||
|
export const probePattern = (origin, probe) =>
|
||||||
|
probe.pattern.startsWith('/')
|
||||||
|
? new URL(origin).origin + probe.pattern
|
||||||
|
: probe.pattern;
|
||||||
|
|
||||||
|
/** Paid or keyed services: blocked first, whatever else a scenario allows. */
|
||||||
|
export const PAID_HOST_PATTERNS = Object.freeze([
|
||||||
|
'*://*.googleapis.com/*',
|
||||||
|
'*://googleapis.com/*',
|
||||||
|
'*://*.google.com/*',
|
||||||
|
'*://google.com/*',
|
||||||
|
'*://maps.google.*/*',
|
||||||
|
'*://*.gstatic.com/*',
|
||||||
|
'*://*.cesium.com/*',
|
||||||
|
'*://cesium.com/*',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Preview routes that spend a key (OpenAI, Realtime, Google proxies). */
|
||||||
|
export const PAID_PREVIEW_PATHS = Object.freeze([
|
||||||
|
'/api/openai/*',
|
||||||
|
'/api/realtime/*',
|
||||||
|
'/api/google/*',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ordered Network.setBlockedURLs patterns of the strict policy (first
|
||||||
|
* match wins): the keyless probes the harness answers are let through to
|
||||||
|
* it, paid hosts and paid preview routes are blocked, then the
|
||||||
|
* preview origin, the scenario's fixture URLs and the free tile hosts are
|
||||||
|
* allowed, then everything else is blocked.
|
||||||
|
*
|
||||||
|
* @param {string} origin The preview origin, e.g. http://127.0.0.1:4208
|
||||||
|
* @param {{fixtures?: string[], tileHosts?: readonly string[]}} [options]
|
||||||
|
* `fixtures` are URLPattern strings for requests the scenario answers.
|
||||||
|
*/
|
||||||
|
export function strictBlockPatterns(
|
||||||
|
origin,
|
||||||
|
{ fixtures = [], tileHosts = STRICT_TILE_HOSTS } = {},
|
||||||
|
) {
|
||||||
|
const preview = new URL(origin).origin;
|
||||||
|
return [
|
||||||
|
...KEYLESS_PROBES.map((probe) => ({
|
||||||
|
urlPattern: probePattern(origin, probe),
|
||||||
|
block: false,
|
||||||
|
})),
|
||||||
|
...PAID_HOST_PATTERNS.map((urlPattern) => ({ urlPattern, block: true })),
|
||||||
|
...PAID_PREVIEW_PATHS.map((path) => ({
|
||||||
|
urlPattern: preview + path,
|
||||||
|
block: true,
|
||||||
|
})),
|
||||||
|
{ urlPattern: preview + '/*', block: false },
|
||||||
|
...fixtures.map((urlPattern) => ({ urlPattern, block: false })),
|
||||||
|
...tileHosts.map((host) => ({
|
||||||
|
urlPattern: `https://${host}/*`,
|
||||||
|
block: false,
|
||||||
|
})),
|
||||||
|
{ urlPattern: '*://*:*/*', block: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
blockPaidQaMaps,
|
||||||
|
KEYLESS_PROBES,
|
||||||
|
probePattern,
|
||||||
|
strictBlockPatterns,
|
||||||
|
} from './qa-map-policy.mjs';
|
||||||
|
|
||||||
|
test('routine QA blocks direct Google tiles, hosted Google assets and token fallback', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const page = {
|
||||||
|
createCDPSession: async () => ({
|
||||||
|
send: async (...args) => calls.push(args),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
await blockPaidQaMaps(page);
|
||||||
|
assert.equal(calls[0][0], 'Network.enable');
|
||||||
|
assert.equal(calls[1][0], 'Network.setBlockedURLs');
|
||||||
|
assert.ok(calls[1][1].urls.includes('*://tile.googleapis.com/*'));
|
||||||
|
assert.ok(
|
||||||
|
calls[1][1].urls.includes('*://api.cesium.com/v1/assets/2275207/*'),
|
||||||
|
);
|
||||||
|
assert.ok(calls[1][1].urls.includes('*/api/google/tiles*'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a necessary 3D-specific check must explicitly opt out of the request block', async () => {
|
||||||
|
await blockPaidQaMaps(
|
||||||
|
{ createCDPSession: () => assert.fail('unexpected block') },
|
||||||
|
{ allowPaidMaps: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the strict policy blocks paid hosts and routes first and everything unlisted last', () => {
|
||||||
|
const patterns = strictBlockPatterns('http://127.0.0.1:4208', {
|
||||||
|
fixtures: ['https://tiles.dontgetflocked.com/*'],
|
||||||
|
});
|
||||||
|
const decide = (url) =>
|
||||||
|
patterns.find(({ urlPattern }) => new URLPattern(urlPattern).test(url))
|
||||||
|
?.block;
|
||||||
|
for (const url of [
|
||||||
|
'https://tile.googleapis.com/v1/3dtiles/root.json',
|
||||||
|
'https://maps.googleapis.com/maps/api/js',
|
||||||
|
'https://fonts.gstatic.com/s/inter.woff2',
|
||||||
|
'https://api.cesium.com/v1/assets/1/endpoint',
|
||||||
|
'https://assets.ion.cesium.com/google-credit.png',
|
||||||
|
'http://127.0.0.1:4208/api/openai/hud-summary',
|
||||||
|
'http://127.0.0.1:4208/api/realtime/token',
|
||||||
|
'http://127.0.0.1:4208/api/google/nearby-places?lat=1',
|
||||||
|
'https://example.com/anything',
|
||||||
|
'wss://example.com/socket',
|
||||||
|
])
|
||||||
|
assert.equal(decide(url), true, url);
|
||||||
|
for (const url of [
|
||||||
|
'http://127.0.0.1:4208/assets/index.js',
|
||||||
|
'http://127.0.0.1:4208/api/flights',
|
||||||
|
'https://tiles.dontgetflocked.com/cameras-us-hourly/1/2/3.mvt',
|
||||||
|
'https://tile.openstreetmap.org/1/2/3.png',
|
||||||
|
'https://terrain.reearth.land/cesium-mesh/layer.json',
|
||||||
|
'https://tiles.maps.eox.at/wmts/1.0.0/s2/1/2/3.jpg',
|
||||||
|
])
|
||||||
|
assert.equal(decide(url), false, url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the keyless probes are let through only to their exact routes', () => {
|
||||||
|
const patterns = strictBlockPatterns('http://127.0.0.1:4208');
|
||||||
|
const decide = (url) =>
|
||||||
|
patterns.find(({ urlPattern }) => new URLPattern(urlPattern).test(url))
|
||||||
|
?.block;
|
||||||
|
for (const probe of KEYLESS_PROBES) {
|
||||||
|
const url = probePattern('http://127.0.0.1:4208', probe).replace(
|
||||||
|
/\*$/,
|
||||||
|
'?family=Inter',
|
||||||
|
);
|
||||||
|
assert.equal(decide(url), false, url);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
decide('http://127.0.0.1:4208/api/openai/hud-summary'),
|
||||||
|
true,
|
||||||
|
'the paid summary itself stays blocked',
|
||||||
|
);
|
||||||
|
assert.equal(decide('https://fonts.googleapis.com/icon?family=x'), true);
|
||||||
|
});
|
||||||
@@ -316,9 +316,9 @@ try {
|
|||||||
controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'),
|
controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'),
|
||||||
}));
|
}));
|
||||||
check(
|
check(
|
||||||
'exact five-source presentation; the retired left Map Stack panel is gone',
|
'exact six-source presentation; the retired left Map Stack panel is gone',
|
||||||
JSON.stringify(presentation.ids) === JSON.stringify([
|
JSON.stringify(presentation.ids) === JSON.stringify([
|
||||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
|
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'sentinel2', 'osm',
|
||||||
]) && !presentation.retiredPanel,
|
]) && !presentation.retiredPanel,
|
||||||
JSON.stringify(presentation),
|
JSON.stringify(presentation),
|
||||||
);
|
);
|
||||||
@@ -338,12 +338,16 @@ try {
|
|||||||
while (!document.body.innerText.includes('Powered by Esri') && performance.now() < creditDeadline) {
|
while (!document.body.innerText.includes('Powered by Esri') && performance.now() < creditDeadline) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
}
|
}
|
||||||
const provider = controller._activeImageryProvider;
|
// The base imagery the controller draws now (it is handed to the globe
|
||||||
|
// engine, src/maps/controller.js).
|
||||||
|
const liveProvider = () =>
|
||||||
|
controller._liveBaseProvider?.() ?? controller._activeImageryProvider;
|
||||||
|
const provider = liveProvider();
|
||||||
const before = {
|
const before = {
|
||||||
activeId: controller.getActiveId(),
|
activeId: controller.getActiveId(),
|
||||||
creditVisible: document.body.innerText.includes('Powered by Esri'),
|
creditVisible: document.body.innerText.includes('Powered by Esri'),
|
||||||
globeShown: styleManager.viewer.scene.globe.show,
|
globeShown: styleManager.viewer.scene.globe.show,
|
||||||
hasLayer: Boolean(controller._imageryLayer),
|
hasLayer: Boolean(liveProvider() ?? controller._imageryLayer),
|
||||||
};
|
};
|
||||||
provider?.errorEvent?.raiseEvent?.({ timesRetried: 0 });
|
provider?.errorEvent?.raiseEvent?.({ timesRetried: 0 });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
@@ -370,7 +374,7 @@ try {
|
|||||||
lastError: controller.getState().lastError,
|
lastError: controller.getState().lastError,
|
||||||
creditVisible: document.body.innerText.includes('Powered by Esri'),
|
creditVisible: document.body.innerText.includes('Powered by Esri'),
|
||||||
globeShown: styleManager.viewer.scene.globe.show,
|
globeShown: styleManager.viewer.scene.globe.show,
|
||||||
hasLayer: Boolean(controller._imageryLayer),
|
hasLayer: Boolean(liveProvider() ?? controller._imageryLayer),
|
||||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||||
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
||||||
.map((chip) => chip.dataset.stackId),
|
.map((chip) => chip.dataset.stackId),
|
||||||
@@ -564,9 +568,16 @@ try {
|
|||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await waitTray('true', keyboardSource);
|
await waitTray('true', keyboardSource);
|
||||||
|
|
||||||
// Visual Styles precede the five Map Source tiles. Keep the existing opening
|
// Visual Styles precede the Map Source section: its globe-engine picker
|
||||||
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
|
// and tiles (OSM is the last). Keep the existing opening handoff to
|
||||||
await pressTabs(5, true);
|
// selected OSM, then use the user's real Shift+Tab path into styles: one
|
||||||
|
// press per control of that section.
|
||||||
|
const mapSourceControls = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.querySelectorAll('.map-stack-chip').length +
|
||||||
|
(document.getElementById('globe-engine') ? 1 : 0),
|
||||||
|
);
|
||||||
|
await pressTabs(mapSourceControls, true);
|
||||||
const noirFocus = await styleFocusMetrics();
|
const noirFocus = await styleFocusMetrics();
|
||||||
check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
|
check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
|
||||||
hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
|
hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Annotations on both engines, under the strict network policy: the same
|
||||||
|
// board (a hand-drawn area, a pin in view and a pin on the far side of the
|
||||||
|
// globe) is drawn on Navara, survives a switch to Cesium and back, and on
|
||||||
|
// each engine the draped area paints amber pixels on the canvas, the near
|
||||||
|
// pin's callout shows and the far pin is culled at the horizon.
|
||||||
|
//
|
||||||
|
// node scripts/qa-navara-annotations.mjs http://127.0.0.1:<PORT>/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import {
|
||||||
|
createStrict,
|
||||||
|
localPreview,
|
||||||
|
waitForEngine,
|
||||||
|
} from './qa-strict-harness.mjs';
|
||||||
|
|
||||||
|
const LAT = 37.7749;
|
||||||
|
const LON = -122.4194;
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
base.search = '?engine=navara&welcome=0';
|
||||||
|
base.hash = `v=2&lat=${LAT}&lon=${LON}&alt=7000&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||||
|
const strict = createStrict({ origin: base.origin, layers: ['annotations'] });
|
||||||
|
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-annotations';
|
||||||
|
fs.mkdirSync(SHOTS, { recursive: true });
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
// The area: a box around the view centre, clear of the panels.
|
||||||
|
const RING = [
|
||||||
|
[LON - 0.012, LAT - 0.009],
|
||||||
|
[LON + 0.012, LAT - 0.009],
|
||||||
|
[LON + 0.012, LAT + 0.009],
|
||||||
|
[LON - 0.012, LAT + 0.009],
|
||||||
|
];
|
||||||
|
|
||||||
|
await strict.run(
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async (browser) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 860 });
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
await page.goto(base.href);
|
||||||
|
await waitForEngine(page, 'navara');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
'keyless preview required',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Amber pixels of the globe canvas alone (the SVG board hidden). */
|
||||||
|
const canvasAmber = async () => {
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: '.gev-screen-whiteboard{visibility:hidden!important}',
|
||||||
|
});
|
||||||
|
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
|
||||||
|
await wait(700);
|
||||||
|
const { data, info } = await sharp(
|
||||||
|
await page.screenshot({
|
||||||
|
clip: { x: 340, y: 180, width: 600, height: 500 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.removeAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document
|
||||||
|
.querySelectorAll('style')
|
||||||
|
.forEach(
|
||||||
|
(style) =>
|
||||||
|
style.textContent.includes('gev-screen-whiteboard{visibility') &&
|
||||||
|
style.remove(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let amber = 0;
|
||||||
|
for (let i = 0; i < data.length; i += info.channels) {
|
||||||
|
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||||
|
if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15)
|
||||||
|
amber++;
|
||||||
|
}
|
||||||
|
return amber;
|
||||||
|
};
|
||||||
|
/** The callout texts the page board shows, and those it hides. */
|
||||||
|
const board = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const out = { shown: [], hidden: [] };
|
||||||
|
for (const group of document.querySelectorAll(
|
||||||
|
'.gev-screen-whiteboard-svg g.gev-anno',
|
||||||
|
)) {
|
||||||
|
const text = group.querySelector('.gev-anno-text')?.textContent;
|
||||||
|
if (!text) continue;
|
||||||
|
const hidden =
|
||||||
|
group.style.display === 'none' ||
|
||||||
|
getComputedStyle(group).display === 'none';
|
||||||
|
(hidden ? out.hidden : out.shown).push(text);
|
||||||
|
}
|
||||||
|
out.shown.sort();
|
||||||
|
out.hidden.sort();
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let round = 0;
|
||||||
|
const drawn = await page.evaluate(
|
||||||
|
async (ring, lat, lon) => {
|
||||||
|
const annotations = window.__gevAnnotations;
|
||||||
|
window.qaBoard = annotations;
|
||||||
|
const result = await annotations.annotate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'area',
|
||||||
|
manual: true,
|
||||||
|
ring,
|
||||||
|
label: 'QA Area',
|
||||||
|
color: 'amber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'pin',
|
||||||
|
manual: true,
|
||||||
|
latitude: lat + 0.004,
|
||||||
|
longitude: lon + 0.006,
|
||||||
|
label: 'QA Near',
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'pin',
|
||||||
|
manual: true,
|
||||||
|
latitude: -lat,
|
||||||
|
longitude: lon + 180,
|
||||||
|
label: 'QA Far',
|
||||||
|
color: 'cyan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ persist: true, flyTo: false },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
drawn: result.drawn,
|
||||||
|
attached: annotations.attached,
|
||||||
|
handle: window.__godsEyeView.annotations === annotations,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
RING,
|
||||||
|
LAT,
|
||||||
|
LON,
|
||||||
|
);
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'annotations',
|
||||||
|
'the board draws three marks on the attached engine and is the debug handle',
|
||||||
|
drawn.drawn === 3 && drawn.attached && drawn.handle,
|
||||||
|
drawn,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
|
if (
|
||||||
|
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
|
||||||
|
) {
|
||||||
|
await strict.switchTo(page, kind);
|
||||||
|
}
|
||||||
|
await wait(2500);
|
||||||
|
const state = await page.evaluate(() => ({
|
||||||
|
same: window.__gevAnnotations === window.qaBoard,
|
||||||
|
count: window.qaBoard.count(),
|
||||||
|
attached: window.qaBoard.attached,
|
||||||
|
handle: window.__godsEyeView.annotations === window.qaBoard,
|
||||||
|
}));
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'annotations',
|
||||||
|
'the same session board keeps its three marks and draws on this engine',
|
||||||
|
state.same && state.count === 3 && state.attached && state.handle,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
const seen = await board();
|
||||||
|
console.log(kind, 'board', JSON.stringify(seen));
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'annotations',
|
||||||
|
'the near callouts show and the far pin is culled at the horizon',
|
||||||
|
seen.shown.includes('QA Near') &&
|
||||||
|
seen.shown.includes('QA Area') &&
|
||||||
|
!seen.shown.includes('QA Far'),
|
||||||
|
seen,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${SHOTS}/annotations-${kind}-${round}.png`,
|
||||||
|
});
|
||||||
|
const amber = await canvasAmber();
|
||||||
|
// The same view without the marks (hidden from the list).
|
||||||
|
const setHidden = (hidden) =>
|
||||||
|
page.evaluate((h) => {
|
||||||
|
for (const mark of window.qaBoard.list())
|
||||||
|
window.qaBoard.setHidden(mark.id, h);
|
||||||
|
}, hidden);
|
||||||
|
await setHidden(true);
|
||||||
|
const bare = await canvasAmber();
|
||||||
|
await setHidden(false);
|
||||||
|
console.log(kind, 'amber pixels', { bare, amber });
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'annotations',
|
||||||
|
'the draped area paints amber on the globe canvas',
|
||||||
|
amber - bare > 400,
|
||||||
|
{ bare, amber },
|
||||||
|
);
|
||||||
|
round++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fly-to a mark goes through the engine's camera on Navara.
|
||||||
|
const flight = await page.evaluate(async () => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const before = engine.camera.height();
|
||||||
|
const area = window.qaBoard.list().find((m) => m.label === 'QA Area');
|
||||||
|
const ok = window.qaBoard.frame(area.id);
|
||||||
|
await new Promise((r) => setTimeout(r, 2600));
|
||||||
|
return { ok, before, after: engine.camera.height() };
|
||||||
|
});
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'annotations',
|
||||||
|
'framing a mark flies the Navara camera to it',
|
||||||
|
flight.ok && Math.abs(flight.after - flight.before) > 100,
|
||||||
|
flight,
|
||||||
|
);
|
||||||
|
// Hidden from the list: the board and the canvas let it go.
|
||||||
|
const hidden = await page.evaluate(async () => {
|
||||||
|
for (const mark of window.qaBoard.list())
|
||||||
|
window.qaBoard.setHidden(mark.id, true);
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
return window.qaBoard.list().every((m) => m.hidden);
|
||||||
|
});
|
||||||
|
const afterHide = await board();
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'annotations',
|
||||||
|
'hidden marks leave the board',
|
||||||
|
hidden && afterHide.shown.length === 0,
|
||||||
|
afterHide,
|
||||||
|
);
|
||||||
|
await page.evaluate(() => window.qaBoard.clear());
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'PASS annotations drawn, culled and kept across engine switches',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// The app's Bloom on Navara: the Display bloom glows the
|
||||||
|
// Navara frame (brighter, more at a higher slider), it sits after the
|
||||||
|
// overlays and before the visual styles, Cesium's dead zone draws nothing,
|
||||||
|
// turning it off restores the frame exactly, and on Cesium the scene's own
|
||||||
|
// bloom stage still runs with the engine left alone. Keyless preview under
|
||||||
|
// the strict default-deny network policy.
|
||||||
|
//
|
||||||
|
// node scripts/qa-navara-bloom.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
createStrict,
|
||||||
|
localPreview,
|
||||||
|
waitForEngine,
|
||||||
|
} from './qa-strict-harness.mjs';
|
||||||
|
import {
|
||||||
|
capture,
|
||||||
|
checker,
|
||||||
|
diff,
|
||||||
|
freeze,
|
||||||
|
pickStyle,
|
||||||
|
pinDaytime,
|
||||||
|
saveCapture,
|
||||||
|
stats,
|
||||||
|
visualPageUrl,
|
||||||
|
} from './qa/navaraVisual.mjs';
|
||||||
|
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
const outDir = process.argv
|
||||||
|
.find((arg) => arg.startsWith('--out='))
|
||||||
|
?.slice('--out='.length);
|
||||||
|
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||||
|
const report = { checks: [] };
|
||||||
|
const check = checker(report, assert);
|
||||||
|
const save = (name, pixels) => saveCapture(outDir, name, pixels);
|
||||||
|
const luma = (s) => 0.299 * s.r + 0.587 * s.g + 0.114 * s.b;
|
||||||
|
|
||||||
|
// The Display bloom, as its toggle and slider set it.
|
||||||
|
const setBloom = (page, enabled, intensityPct) =>
|
||||||
|
page.evaluate(
|
||||||
|
(enabled, intensityPct) => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
a.styleManager.setBloom({ enabled, intensityPct });
|
||||||
|
return {
|
||||||
|
control: a.styleManager.getControlState().bloom,
|
||||||
|
engine: a.engine.render.bloom?.() ?? null,
|
||||||
|
pipeline: a.engine.pipeline?.() ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
intensityPct,
|
||||||
|
);
|
||||||
|
|
||||||
|
await strict.run({}, async (browser) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
// The same sun on every run: the frame's brightness decides what blooms
|
||||||
|
// and what each style's signature reads.
|
||||||
|
await pinDaytime(page);
|
||||||
|
await page.evaluateOnNewDocument(() => {
|
||||||
|
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||||
|
});
|
||||||
|
await page.setViewport({ width: 1280, height: 800 });
|
||||||
|
await page.goto(visualPageUrl(base.origin, 'navara'), {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
await waitForEngine(page, 'navara');
|
||||||
|
await freeze(page);
|
||||||
|
|
||||||
|
const initial = await page.evaluate(() => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
return { bloom: engine.render.bloom(), pipeline: engine.pipeline() };
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'Navara blooms itself and starts with bloom off',
|
||||||
|
initial.bloom.enabled === false && !initial.pipeline?.bloomShown,
|
||||||
|
initial,
|
||||||
|
);
|
||||||
|
const normal = await capture(page);
|
||||||
|
save('normal', normal);
|
||||||
|
const normalStats = stats(normal);
|
||||||
|
|
||||||
|
const full = await setBloom(page, true, 200);
|
||||||
|
check(
|
||||||
|
'the slider at 200 reaches the engine at full strength',
|
||||||
|
full.engine.enabled === true &&
|
||||||
|
full.engine.strength === 1 &&
|
||||||
|
full.pipeline.bloomShown === true,
|
||||||
|
full,
|
||||||
|
);
|
||||||
|
const order = full.pipeline.passes ?? [];
|
||||||
|
const at = order.indexOf('gevBloom');
|
||||||
|
check(
|
||||||
|
'bloom runs after the scene and overlays, just before the final copy',
|
||||||
|
at >= 0 &&
|
||||||
|
order[at + 1] === 'final' &&
|
||||||
|
['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every(
|
||||||
|
(key) => !order.includes(key) || order.indexOf(key) < at,
|
||||||
|
),
|
||||||
|
order,
|
||||||
|
);
|
||||||
|
const glowFull = await capture(page);
|
||||||
|
save('bloom-200', glowFull);
|
||||||
|
const fullChange = diff(normal, glowFull);
|
||||||
|
const fullStats = stats(glowFull);
|
||||||
|
check(
|
||||||
|
'bloom at 200 glows the frame brighter',
|
||||||
|
fullChange.mean > 3 && luma(fullStats) > luma(normalStats) + 2,
|
||||||
|
{
|
||||||
|
...fullChange,
|
||||||
|
normalLuma: luma(normalStats),
|
||||||
|
bloomLuma: luma(fullStats),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await setBloom(page, true, 100);
|
||||||
|
const glowHalf = await capture(page);
|
||||||
|
save('bloom-100', glowHalf);
|
||||||
|
const halfChange = diff(normal, glowHalf);
|
||||||
|
check(
|
||||||
|
'a lower slider glows less',
|
||||||
|
halfChange.mean > 0.5 && halfChange.mean < fullChange.mean,
|
||||||
|
{ half: halfChange.mean, full: fullChange.mean },
|
||||||
|
);
|
||||||
|
|
||||||
|
const dead = await setBloom(page, true, 10);
|
||||||
|
const deadFrame = await capture(page);
|
||||||
|
check(
|
||||||
|
"Cesium's dead zone: the toggle on, nothing glows",
|
||||||
|
dead.engine.enabled === false &&
|
||||||
|
dead.pipeline.bloomShown === false &&
|
||||||
|
diff(normal, deadFrame).mean === 0,
|
||||||
|
{ engine: dead.engine, diff: diff(normal, deadFrame) },
|
||||||
|
);
|
||||||
|
|
||||||
|
// With a style: bloom first, then the style, then the final copy.
|
||||||
|
await setBloom(page, true, 200);
|
||||||
|
const styled = await pickStyle(page, 'noir', 'normal');
|
||||||
|
const styledOrder = styled.pipeline?.passes ?? [];
|
||||||
|
check(
|
||||||
|
'bloom runs before the visual styles',
|
||||||
|
styledOrder.indexOf('gevBloom') >= 0 &&
|
||||||
|
styledOrder.indexOf('gevBloom') + 1 ===
|
||||||
|
styledOrder.indexOf('gevStyles') &&
|
||||||
|
styledOrder.indexOf('gevStyles') + 1 === styledOrder.indexOf('final'),
|
||||||
|
styledOrder,
|
||||||
|
);
|
||||||
|
await pickStyle(page, 'normal', 'noir');
|
||||||
|
|
||||||
|
const off = await setBloom(page, false, 200);
|
||||||
|
const offFrame = await capture(page);
|
||||||
|
check(
|
||||||
|
'turning bloom off restores the frame',
|
||||||
|
off.engine.enabled === false &&
|
||||||
|
off.pipeline.bloomShown === false &&
|
||||||
|
diff(normal, offFrame).mean === 0,
|
||||||
|
{ engine: off.engine, diff: diff(normal, offFrame) },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cesium keeps its scene bloom stage; its engine has no bloom.
|
||||||
|
await strict.switchTo(page, 'cesium');
|
||||||
|
await freeze(page);
|
||||||
|
await setBloom(page, false, 200);
|
||||||
|
const cesiumNormal = await capture(page);
|
||||||
|
const cesium = await setBloom(page, true, 200);
|
||||||
|
const cesiumStage = await page.evaluate(() => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
const stage = a.viewer?.scene?.postProcessStages?.bloom;
|
||||||
|
return {
|
||||||
|
setBloom: typeof a.engine.render.setBloom,
|
||||||
|
enabled: stage?.enabled ?? null,
|
||||||
|
contrast: stage?.uniforms?.contrast ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const cesiumGlow = await capture(page);
|
||||||
|
save('cesium-bloom-200', cesiumGlow);
|
||||||
|
check(
|
||||||
|
"Cesium: its scene's bloom stage glows, the engine draws none",
|
||||||
|
cesium.engine === null &&
|
||||||
|
cesiumStage.setBloom === 'undefined' &&
|
||||||
|
cesiumStage.enabled !== false &&
|
||||||
|
diff(cesiumNormal, cesiumGlow).mean > 1,
|
||||||
|
{ stage: cesiumStage, diff: diff(cesiumNormal, cesiumGlow) },
|
||||||
|
);
|
||||||
|
await setBloom(page, false, 200);
|
||||||
|
const cesiumBack = diff(cesiumNormal, await capture(page));
|
||||||
|
check(
|
||||||
|
'Cesium: bloom off restores its frame',
|
||||||
|
cesiumBack.mean < 0.5,
|
||||||
|
cesiumBack,
|
||||||
|
);
|
||||||
|
|
||||||
|
check('no page errors', errors.length === 0, errors);
|
||||||
|
if (outDir)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(outDir, 'bloom.json'),
|
||||||
|
JSON.stringify(report, null, 2),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// The cockpit weather clouds on Navara: in cockpit mode with
|
||||||
|
// the cockpit weather on, Navara's own volumetric clouds take their cover
|
||||||
|
// from the weather under the camera (clear: none; overcast: a heavy deck),
|
||||||
|
// with no second canvas or WebGL context; leaving the cockpit gives the look
|
||||||
|
// its own clouds back. On Cesium the cockpit keeps its own cloud canvas and
|
||||||
|
// the engine draws none. The weather route is a fixture, the page clock is
|
||||||
|
// shifted to an Austin midday (the look shows clouds only in daylight), and
|
||||||
|
// cockpit mode is entered through its event (the cloud controllers listen
|
||||||
|
// to it; a real cockpit needs a tracked aircraft). Keyless preview under the
|
||||||
|
// strict default-deny network policy.
|
||||||
|
//
|
||||||
|
// node scripts/qa-navara-cockpit-clouds.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
createStrict,
|
||||||
|
localPreview,
|
||||||
|
waitForEngine,
|
||||||
|
} from './qa-strict-harness.mjs';
|
||||||
|
import {
|
||||||
|
capture,
|
||||||
|
checker,
|
||||||
|
diff,
|
||||||
|
freeze,
|
||||||
|
saveCapture,
|
||||||
|
stats,
|
||||||
|
visualPageUrl,
|
||||||
|
} from './qa/navaraVisual.mjs';
|
||||||
|
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
const outDir = process.argv
|
||||||
|
.find((arg) => arg.startsWith('--out='))
|
||||||
|
?.slice('--out='.length);
|
||||||
|
const LOW_VIEW =
|
||||||
|
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=90&pitch=-8&map=osm&l=';
|
||||||
|
const MIDDAY_UTC = '2026-07-15T18:00:00Z';
|
||||||
|
const WEATHER = {
|
||||||
|
clear: { weatherCode: 0, cloudCoverPct: 0, windKph: 6, visibilityM: 20000 },
|
||||||
|
overcast: {
|
||||||
|
weatherCode: 3,
|
||||||
|
cloudCoverPct: 100,
|
||||||
|
windKph: 25,
|
||||||
|
visibilityM: 15000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let weather = WEATHER.clear;
|
||||||
|
const asked = [];
|
||||||
|
const strict = createStrict({
|
||||||
|
origin: base.origin,
|
||||||
|
layers: [],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
pattern: `${base.origin}/api/weather-effects*`,
|
||||||
|
respond: (request) => {
|
||||||
|
asked.push(request.url);
|
||||||
|
return {
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ status: 'live', weather }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const report = { checks: [] };
|
||||||
|
const check = checker(report, assert);
|
||||||
|
const save = (name, pixels) => saveCapture(outDir, name, pixels);
|
||||||
|
|
||||||
|
// Clouds grey the frame: the blue sky and the map's coloured lines go.
|
||||||
|
const saturation = (pixels) => stats(pixels).saturation;
|
||||||
|
|
||||||
|
// A few seconds of continuous frames: the clouds converge over frames.
|
||||||
|
async function converged(page, name) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
engine.render.setContinuous(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
engine.render.setContinuous(false);
|
||||||
|
});
|
||||||
|
const pixels = await capture(page);
|
||||||
|
save(name, pixels);
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cockpit = (page, active) =>
|
||||||
|
page.evaluate((active) => {
|
||||||
|
document.body.classList.toggle('cockpit-mode', active);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('gev:cockpit-mode-changed', { detail: { active } }),
|
||||||
|
);
|
||||||
|
}, active);
|
||||||
|
const weatherToggle = (page, enabled) =>
|
||||||
|
page.evaluate((enabled) => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('gev:cockpit-weather-toggle', { detail: { enabled } }),
|
||||||
|
);
|
||||||
|
}, enabled);
|
||||||
|
const coverage = (page) =>
|
||||||
|
page.evaluate(
|
||||||
|
() => window.__godsEyeView.engine.render.cloudCover?.().coverage,
|
||||||
|
);
|
||||||
|
|
||||||
|
await strict.run({}, async (browser) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
await page.evaluateOnNewDocument((utc) => {
|
||||||
|
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||||
|
const offset = Date.parse(utc) - Date.now();
|
||||||
|
const RealDate = Date;
|
||||||
|
class ShiftedDate extends RealDate {
|
||||||
|
constructor(...args) {
|
||||||
|
if (args.length === 0) super(RealDate.now() + offset);
|
||||||
|
else super(...args);
|
||||||
|
}
|
||||||
|
static now() {
|
||||||
|
return RealDate.now() + offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.Date = ShiftedDate;
|
||||||
|
}, MIDDAY_UTC);
|
||||||
|
await page.setViewport({ width: 1280, height: 800 });
|
||||||
|
await page.goto(visualPageUrl(base.origin, 'navara', '', LOW_VIEW), {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
await waitForEngine(page, 'navara');
|
||||||
|
await freeze(page);
|
||||||
|
|
||||||
|
const setup = await page.evaluate(() => {
|
||||||
|
const a = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
cover: a.engine.render.cloudCover?.() ?? null,
|
||||||
|
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
|
||||||
|
canvases: document.querySelectorAll('canvas').length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'Navara takes the cockpit cloud cover itself, no cloud canvas',
|
||||||
|
setup.cover?.coverage === null && !setup.canvas,
|
||||||
|
setup,
|
||||||
|
);
|
||||||
|
const lookOwn = await converged(page, 'look-own');
|
||||||
|
|
||||||
|
await weatherToggle(page, true);
|
||||||
|
await cockpit(page, true);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.engine.render.cloudCover().coverage === 0,
|
||||||
|
{ timeout: 10000, polling: 100 },
|
||||||
|
);
|
||||||
|
check('clear weather in the cockpit: no cloud cover', asked.length >= 1, {
|
||||||
|
requests: asked.length,
|
||||||
|
});
|
||||||
|
const clear = await converged(page, 'cockpit-clear');
|
||||||
|
|
||||||
|
// Overcast 30 km east: the movement makes the weather stale.
|
||||||
|
weather = WEATHER.overcast;
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const place = engine.camera.pose().cartographic;
|
||||||
|
const east = place.longitude + 30000 / (6378137 * Math.cos(place.latitude));
|
||||||
|
const { sin, cos } = Math;
|
||||||
|
// WGS84 Earth-fixed position of (east, latitude, height).
|
||||||
|
const a = 6378137;
|
||||||
|
const e2 = 0.00669437999014;
|
||||||
|
const n = a / Math.sqrt(1 - e2 * sin(place.latitude) ** 2);
|
||||||
|
const h = place.height;
|
||||||
|
engine.camera.setView({
|
||||||
|
destination: {
|
||||||
|
x: (n + h) * cos(place.latitude) * cos(east),
|
||||||
|
y: (n + h) * cos(place.latitude) * sin(east),
|
||||||
|
z: (n * (1 - e2) + h) * sin(place.latitude),
|
||||||
|
},
|
||||||
|
orientation: { heading: Math.PI / 2, pitch: -0.14, roll: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView.engine.render.cloudCover().coverage > 0.4,
|
||||||
|
{ timeout: 15000, polling: 100 },
|
||||||
|
);
|
||||||
|
const overcastCover = await coverage(page);
|
||||||
|
const overcast = await converged(page, 'cockpit-overcast');
|
||||||
|
const greyed = {
|
||||||
|
lookOwn: saturation(lookOwn),
|
||||||
|
clear: saturation(clear),
|
||||||
|
overcast: saturation(overcast),
|
||||||
|
clearToOvercast: diff(clear, overcast).mean,
|
||||||
|
};
|
||||||
|
check(
|
||||||
|
'overcast weather covers the view with Navara clouds, clear leaves it open',
|
||||||
|
overcastCover > 0.4 &&
|
||||||
|
greyed.overcast < greyed.clear * 0.6 &&
|
||||||
|
greyed.lookOwn < greyed.clear &&
|
||||||
|
greyed.clearToOvercast > 15,
|
||||||
|
{ overcastCover, greyed, requests: asked.length },
|
||||||
|
);
|
||||||
|
const contexts = await page.evaluate(() => ({
|
||||||
|
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
|
||||||
|
canvases: document.querySelectorAll('canvas').length,
|
||||||
|
}));
|
||||||
|
check(
|
||||||
|
'no second canvas or WebGL context in the cockpit',
|
||||||
|
!contexts.canvas && contexts.canvases === setup.canvases,
|
||||||
|
{ before: setup.canvases, after: contexts },
|
||||||
|
);
|
||||||
|
|
||||||
|
await cockpit(page, false);
|
||||||
|
check(
|
||||||
|
'leaving the cockpit gives the look its own clouds back',
|
||||||
|
(await coverage(page)) === null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cesium keeps its own cockpit cloud canvas; its engine draws none.
|
||||||
|
await strict.switchTo(page, 'cesium');
|
||||||
|
await freeze(page);
|
||||||
|
weather = WEATHER.overcast;
|
||||||
|
await cockpit(page, true);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
Number(
|
||||||
|
document.getElementById('cockpit-cloud-effects')?.dataset.cloudStrength,
|
||||||
|
) > 0.3,
|
||||||
|
{ timeout: 15000, polling: 100 },
|
||||||
|
);
|
||||||
|
const cesium = await page.evaluate(() => {
|
||||||
|
const canvas = document.getElementById('cockpit-cloud-effects');
|
||||||
|
return {
|
||||||
|
setCloudCover: typeof window.__godsEyeView.engine.render.setCloudCover,
|
||||||
|
strength: Number(canvas.dataset.cloudStrength),
|
||||||
|
cockpit: canvas.dataset.cockpit,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'Cesium: the cockpit draws its own cloud canvas, the engine none',
|
||||||
|
cesium.setCloudCover === 'undefined' && cesium.cockpit === 'true',
|
||||||
|
cesium,
|
||||||
|
);
|
||||||
|
await cockpit(page, false);
|
||||||
|
await weatherToggle(page, false);
|
||||||
|
|
||||||
|
check('no page errors', errors.length === 0, errors);
|
||||||
|
if (outDir)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(outDir, 'cockpit-clouds.json'),
|
||||||
|
JSON.stringify(report, null, 2),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// UTILITIES ▸ Draw on Navara under the strict network policy, with real
|
||||||
|
// clicks and keys: Draw takes the pointer, clicks on the globe are vertices
|
||||||
|
// with a live rubber band, a double click finishes an area that paints on
|
||||||
|
// the canvas and joins the session board, Enter finishes a line, Escape
|
||||||
|
// cancels a sketch and then leaves Draw, and a switch to Cesium in the
|
||||||
|
// middle of a sketch ends it (the pointer is free, shape and colour kept);
|
||||||
|
// the shapes drawn on either engine stay on the board and draw on both.
|
||||||
|
//
|
||||||
|
// node scripts/qa-navara-draw.mjs http://127.0.0.1:<PORT>/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||||
|
|
||||||
|
const VIEW = { lat: 37.7749, lon: -122.4194, alt: 6000 };
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
base.search = '?engine=navara&welcome=0';
|
||||||
|
base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||||
|
const strict = createStrict({ origin: base.origin, layers: ['draw'] });
|
||||||
|
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-draw';
|
||||||
|
fs.mkdirSync(SHOTS, { recursive: true });
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
// An area's corners, clear of the open UTILITIES panel and the rails.
|
||||||
|
const AREA = [
|
||||||
|
[560, 330],
|
||||||
|
[760, 330],
|
||||||
|
[760, 500],
|
||||||
|
[560, 500],
|
||||||
|
];
|
||||||
|
|
||||||
|
await strict.run(
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async (browser) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 860 });
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
await page.goto(base.href);
|
||||||
|
const ready = async (kind) => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(k) =>
|
||||||
|
window.__godsEyeView?.engine?.kind === k &&
|
||||||
|
window.__gevDrawTool?.attached &&
|
||||||
|
!document.querySelector('#globe-engine')?.disabled &&
|
||||||
|
!document.querySelector('.engine-switch-frame') &&
|
||||||
|
document.querySelector('#loading-screen.hidden'),
|
||||||
|
{ timeout: 120000 },
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
await page.evaluate(
|
||||||
|
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await ready('navara');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
'keyless preview required',
|
||||||
|
);
|
||||||
|
const tool = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const draw = window.__gevDrawTool;
|
||||||
|
return {
|
||||||
|
active: draw.active,
|
||||||
|
shape: draw.shape,
|
||||||
|
color: draw.color,
|
||||||
|
vertices: draw.session?.vertices.length ?? null,
|
||||||
|
board: window.__gevAnnotations.count(),
|
||||||
|
attached: window.__gevAnnotations.attached,
|
||||||
|
diagnostics: draw.diagnostics(),
|
||||||
|
band:
|
||||||
|
window.__godsEyeView.engine.lines
|
||||||
|
?.list?.()
|
||||||
|
.find(({ id }) => id === 'gev-draw-preview') ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
/** Amber pixels of the globe canvas alone (the SVG board hidden). */
|
||||||
|
const amber = async () => {
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: '.gev-screen-whiteboard{visibility:hidden!important}',
|
||||||
|
});
|
||||||
|
await wait(800);
|
||||||
|
const { data, info } = await sharp(
|
||||||
|
await page.screenshot({
|
||||||
|
clip: { x: 520, y: 300, width: 280, height: 230 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.removeAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.querySelectorAll('style').forEach((style) => {
|
||||||
|
if (style.textContent.includes('gev-screen-whiteboard{visibility'))
|
||||||
|
style.remove();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let n = 0;
|
||||||
|
for (let i = 0; i < data.length; i += info.channels) {
|
||||||
|
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||||
|
if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15)
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const openUtilities = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const panel = document.getElementById('utilities-panel');
|
||||||
|
if (panel?.classList.contains('collapsed'))
|
||||||
|
document
|
||||||
|
.querySelector(
|
||||||
|
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||||
|
)
|
||||||
|
?.click();
|
||||||
|
});
|
||||||
|
const drawShape = async (shape, points, finish) => {
|
||||||
|
await page.evaluate((s) => {
|
||||||
|
window.__gevDrawTool.setShape(s);
|
||||||
|
window.__gevDrawTool.setColor('amber');
|
||||||
|
}, shape);
|
||||||
|
for (const [x, y] of points) {
|
||||||
|
await page.mouse.click(x, y);
|
||||||
|
await wait(120);
|
||||||
|
}
|
||||||
|
await finish();
|
||||||
|
await wait(800);
|
||||||
|
};
|
||||||
|
|
||||||
|
await openUtilities();
|
||||||
|
await page.waitForSelector('#draw-toggle', { visible: true });
|
||||||
|
const bare = await amber();
|
||||||
|
await page.click('#draw-toggle');
|
||||||
|
await page.waitForFunction(() => window.__gevDrawTool.active);
|
||||||
|
const on = await page.evaluate(() => ({
|
||||||
|
owner: document.body.classList.contains('gev-drawing'),
|
||||||
|
}));
|
||||||
|
// Three corners, then the rubber band to the fourth under the pointer.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__gevDrawTool.setShape('area');
|
||||||
|
window.__gevDrawTool.setColor('amber');
|
||||||
|
});
|
||||||
|
// Each click is a vertex (the rubber band under the pointer never
|
||||||
|
// swallows one).
|
||||||
|
for (const [index, [x, y]] of AREA.slice(0, 3).entries()) {
|
||||||
|
await page.mouse.click(x, y);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(n) => window.__gevDrawTool.session?.vertices.length === n,
|
||||||
|
{ timeout: 3000 },
|
||||||
|
index + 1,
|
||||||
|
);
|
||||||
|
await wait(150);
|
||||||
|
}
|
||||||
|
await page.mouse.move(...AREA[3]);
|
||||||
|
await wait(300);
|
||||||
|
const sketch = await tool();
|
||||||
|
await page.screenshot({ path: `${SHOTS}/navara-sketch.png` });
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'draw',
|
||||||
|
'Draw takes the pointer; clicks on the globe are vertices with a live rubber band',
|
||||||
|
on.owner &&
|
||||||
|
sketch.active &&
|
||||||
|
sketch.vertices === 3 &&
|
||||||
|
sketch.diagnostics.sceneHandler &&
|
||||||
|
sketch.band?.shown === true,
|
||||||
|
sketch,
|
||||||
|
);
|
||||||
|
await page.mouse.click(AREA[3][0], AREA[3][1], { count: 2 });
|
||||||
|
await page.waitForFunction(() => window.__gevAnnotations.count() === 1, {
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
await wait(800);
|
||||||
|
const drawn = await amber();
|
||||||
|
await page.screenshot({ path: `${SHOTS}/navara-area.png` });
|
||||||
|
const area = await page.evaluate(() =>
|
||||||
|
window.__gevAnnotations.list().map((m) => [m.type, m.color, m.manual]),
|
||||||
|
);
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'draw',
|
||||||
|
'a double click finishes the area: it joins the board and paints amber on Navara',
|
||||||
|
area.length === 1 && area[0][0] === 'area' && drawn - bare > 1500,
|
||||||
|
{ area, bare, drawn },
|
||||||
|
);
|
||||||
|
// Enter finishes a line.
|
||||||
|
await drawShape(
|
||||||
|
'line',
|
||||||
|
[
|
||||||
|
[600, 560],
|
||||||
|
[760, 600],
|
||||||
|
],
|
||||||
|
() => page.keyboard.press('Enter'),
|
||||||
|
);
|
||||||
|
const lines = await page.evaluate(() =>
|
||||||
|
window.__gevAnnotations.list().map((m) => m.type),
|
||||||
|
);
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'draw',
|
||||||
|
'Enter on the map finishes a line',
|
||||||
|
lines.length === 2 && lines.includes('route'),
|
||||||
|
{ lines },
|
||||||
|
);
|
||||||
|
// Escape: the sketch, then Draw itself.
|
||||||
|
await page.evaluate(() => window.__gevDrawTool.setShape('line'));
|
||||||
|
await page.mouse.click(620, 360);
|
||||||
|
await page.mouse.click(700, 380);
|
||||||
|
const before = await tool();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
const cancelled = await tool();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
const left = await tool();
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'draw',
|
||||||
|
'Escape cancels the sketch, then leaves Draw and gives the pointer back',
|
||||||
|
before.vertices === 2 &&
|
||||||
|
cancelled.active &&
|
||||||
|
cancelled.vertices === 0 &&
|
||||||
|
!left.active &&
|
||||||
|
!left.diagnostics.sceneHandler &&
|
||||||
|
left.diagnostics.pointerOwner == null &&
|
||||||
|
left.band?.shown !== true,
|
||||||
|
{ before, cancelled, left },
|
||||||
|
);
|
||||||
|
assert.equal(left.board, 2, 'Escape keeps the finished shapes');
|
||||||
|
|
||||||
|
// A switch to Cesium in the middle of a sketch.
|
||||||
|
await page.click('#draw-toggle');
|
||||||
|
await page.waitForFunction(() => window.__gevDrawTool.active);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__gevDrawTool.setShape('line');
|
||||||
|
window.__gevDrawTool.setColor('green');
|
||||||
|
});
|
||||||
|
await page.mouse.click(620, 360);
|
||||||
|
await page.mouse.click(700, 380);
|
||||||
|
assert.equal((await tool()).vertices, 2);
|
||||||
|
await strict.pick(page, 'cesium');
|
||||||
|
await ready('cesium');
|
||||||
|
await wait(1500);
|
||||||
|
const switched = await tool();
|
||||||
|
await page.screenshot({ path: `${SHOTS}/cesium-after-switch.png` });
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'draw',
|
||||||
|
'a switch mid-sketch ends the sketch, frees the pointer and keeps shape, colour and the board',
|
||||||
|
!switched.active &&
|
||||||
|
switched.vertices === null &&
|
||||||
|
switched.diagnostics.pointerOwner == null &&
|
||||||
|
switched.shape === 'line' &&
|
||||||
|
switched.color === 'green' &&
|
||||||
|
switched.board === 2 &&
|
||||||
|
switched.attached,
|
||||||
|
switched,
|
||||||
|
);
|
||||||
|
// Draw on Cesium too, with the same clicks.
|
||||||
|
await openUtilities();
|
||||||
|
await page.click('#draw-toggle');
|
||||||
|
await page.waitForFunction(() => window.__gevDrawTool.active);
|
||||||
|
await drawShape(
|
||||||
|
'line',
|
||||||
|
[
|
||||||
|
[600, 420],
|
||||||
|
[760, 450],
|
||||||
|
],
|
||||||
|
() => page.keyboard.press('Enter'),
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
const onCesium = await tool();
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'draw',
|
||||||
|
'a shape drawn on Cesium joins the same board',
|
||||||
|
onCesium.board === 3 && !onCesium.active,
|
||||||
|
onCesium,
|
||||||
|
);
|
||||||
|
await strict.pick(page, 'navara');
|
||||||
|
await ready('navara');
|
||||||
|
await wait(2000);
|
||||||
|
const back = await tool();
|
||||||
|
const backAmber = await amber();
|
||||||
|
await page.screenshot({ path: `${SHOTS}/navara-back.png` });
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'draw',
|
||||||
|
'back on Navara the three shapes are on the board and the area paints again',
|
||||||
|
back.board === 3 && back.attached && backAmber - bare > 1500,
|
||||||
|
{ board: back.board, bare, backAmber },
|
||||||
|
);
|
||||||
|
await page.evaluate(() => window.__gevAnnotations.clear());
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log('PASS Draw on Navara with real clicks and keys');
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON,
|
||||||
|
// KML, KMZ (a packed icon and a ground overlay) and GPX through the real
|
||||||
|
// file input, each one row; Navara draws their shapes in the row colour and
|
||||||
|
// their pins, counts what it cannot draw (the KMZ ground overlay) and says
|
||||||
|
// so in the hint; hide, fly-to and remove work on the engine; the files
|
||||||
|
// survive a switch to Cesium and back and draw on each engine. No file makes
|
||||||
|
// the page request anything it names (the strict policy fails any request
|
||||||
|
// outside the preview and the free tile cache).
|
||||||
|
//
|
||||||
|
// node scripts/qa-navara-geo-import.mjs http://127.0.0.1:<PORT>/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
|
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||||
|
|
||||||
|
const VIEW = { lat: 37.7694, lon: -122.4862, alt: 4200 };
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
base.search = '?engine=navara&welcome=0';
|
||||||
|
base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||||
|
const strict = createStrict({ origin: base.origin, layers: ['geo-import'] });
|
||||||
|
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-geo-import';
|
||||||
|
fs.mkdirSync(SHOTS, { recursive: true });
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const TRAP = 'gev-qa-trap.invalid';
|
||||||
|
|
||||||
|
/* ── fixtures (as scripts/qa-geo-import.mjs) ───────────────────────────── */
|
||||||
|
const ring = [
|
||||||
|
[-122.511, 37.7645],
|
||||||
|
[-122.4545, 37.7645],
|
||||||
|
[-122.4545, 37.7745],
|
||||||
|
[-122.511, 37.7745],
|
||||||
|
[-122.511, 37.7645],
|
||||||
|
];
|
||||||
|
const geojson = JSON.stringify({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
name: 'QA park',
|
||||||
|
description: `<img src="https://${TRAP}/props.png">`,
|
||||||
|
},
|
||||||
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { name: 'QA pin' },
|
||||||
|
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const kml = (iconHref, overlay) => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||||
|
<Document>
|
||||||
|
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
|
||||||
|
<NetworkLink><Link><href>https://${TRAP}/link.kml</href></Link></NetworkLink>
|
||||||
|
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
|
||||||
|
<description><![CDATA[<img src="https://${TRAP}/balloon.png">]]></description>
|
||||||
|
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
|
||||||
|
<Placemark><name>QA KML line</name>
|
||||||
|
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
|
||||||
|
<GroundOverlay><Icon><href>${overlay}</href></Icon>
|
||||||
|
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
|
||||||
|
</Document>
|
||||||
|
</kml>`;
|
||||||
|
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
|
||||||
|
<trk><name>QA track</name><trkseg>
|
||||||
|
<trkpt lat="37.766" lon="-122.505"></trkpt><trkpt lat="37.768" lon="-122.49"></trkpt>
|
||||||
|
</trkseg></trk>
|
||||||
|
</gpx>`;
|
||||||
|
// 8×8 opaque white PNG.
|
||||||
|
const PNG = await sharp({
|
||||||
|
create: { width: 8, height: 8, channels: 4, background: '#ffffff' },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
function storedZip(files) {
|
||||||
|
const parts = [];
|
||||||
|
const central = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const [name, data] of files) {
|
||||||
|
const n = Buffer.from(name);
|
||||||
|
const local = Buffer.alloc(30);
|
||||||
|
local.writeUInt32LE(0x04034b50, 0);
|
||||||
|
local.writeUInt32LE(data.length, 18);
|
||||||
|
local.writeUInt32LE(data.length, 22);
|
||||||
|
local.writeUInt16LE(n.length, 26);
|
||||||
|
const c = Buffer.alloc(46);
|
||||||
|
c.writeUInt32LE(0x02014b50, 0);
|
||||||
|
c.writeUInt32LE(data.length, 20);
|
||||||
|
c.writeUInt32LE(data.length, 24);
|
||||||
|
c.writeUInt16LE(n.length, 28);
|
||||||
|
c.writeUInt32LE(offset, 42);
|
||||||
|
parts.push(local, n, data);
|
||||||
|
central.push(c, n);
|
||||||
|
offset += 30 + n.length + data.length;
|
||||||
|
}
|
||||||
|
const dir = Buffer.concat(central);
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
end.writeUInt32LE(0x06054b50, 0);
|
||||||
|
end.writeUInt16LE(files.length, 8);
|
||||||
|
end.writeUInt16LE(files.length, 10);
|
||||||
|
end.writeUInt32LE(dir.length, 12);
|
||||||
|
end.writeUInt32LE(offset, 16);
|
||||||
|
return Buffer.concat([...parts, dir, end]);
|
||||||
|
}
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-navara-import-'));
|
||||||
|
const fixture = (name, data) => {
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
fs.writeFileSync(file, data);
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
const FILES = [
|
||||||
|
fixture('park.geojson', geojson),
|
||||||
|
fixture(
|
||||||
|
'remote-bits.kml',
|
||||||
|
kml(`https://${TRAP}/icon.png`, `https://${TRAP}/overlay.png`),
|
||||||
|
),
|
||||||
|
fixture(
|
||||||
|
'packed.kmz',
|
||||||
|
storedZip([
|
||||||
|
['doc.kml', Buffer.from(kml('files/pin.png', 'files/overlay.png'))],
|
||||||
|
['files/pin.png', PNG],
|
||||||
|
['files/overlay.png', PNG],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
fixture('walk.gpx', gpx),
|
||||||
|
];
|
||||||
|
|
||||||
|
await strict.run(
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async (browser) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 860 });
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
await page.goto(base.href);
|
||||||
|
const ready = async (kind) => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(k) =>
|
||||||
|
window.__godsEyeView?.engine?.kind === k &&
|
||||||
|
window.__gevGeoImport?.diagnostics().attached &&
|
||||||
|
!document.querySelector('#globe-engine')?.disabled &&
|
||||||
|
!document.querySelector('.engine-switch-frame') &&
|
||||||
|
document.querySelector('#loading-screen.hidden'),
|
||||||
|
{ timeout: 120000 },
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
await page.evaluate(
|
||||||
|
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await ready('navara');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
'keyless preview required',
|
||||||
|
);
|
||||||
|
const state = () =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
files: window.__gevGeoImport.list(),
|
||||||
|
diagnostics: window.__gevGeoImport.diagnostics(),
|
||||||
|
rows: document.querySelectorAll('#geo-import-list .geo-import-row')
|
||||||
|
.length,
|
||||||
|
hint: document.getElementById('geo-import-hint')?.textContent || '',
|
||||||
|
// Movers are a Navara capability; Cesium draws the pins as entities.
|
||||||
|
pins: (window.__godsEyeView.engine.movers?.list() ?? [])
|
||||||
|
.filter(({ id }) => id.startsWith('geo-import-'))
|
||||||
|
.reduce((n, set) => n + set.count, 0),
|
||||||
|
}));
|
||||||
|
/** Pixels near the first file's colour (#39d0ff) on the canvas. */
|
||||||
|
const cyan = async () => {
|
||||||
|
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
|
||||||
|
await wait(900);
|
||||||
|
const { data, info } = await sharp(
|
||||||
|
await page.screenshot({
|
||||||
|
clip: { x: 400, y: 260, width: 480, height: 300 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.removeAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
let n = 0;
|
||||||
|
for (let i = 0; i < data.length; i += info.channels) {
|
||||||
|
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||||
|
if (b > 120 && g > 90 && b - r > 50) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const setAll = (visible) =>
|
||||||
|
page.evaluate((v) => {
|
||||||
|
for (const file of window.__gevGeoImport.list())
|
||||||
|
window.__gevGeoImport.setVisible(file.id, v);
|
||||||
|
}, visible);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const panel = document.getElementById('utilities-panel');
|
||||||
|
if (panel?.classList.contains('collapsed'))
|
||||||
|
document
|
||||||
|
.querySelector(
|
||||||
|
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||||
|
)
|
||||||
|
?.click();
|
||||||
|
});
|
||||||
|
const input = await page.$('#geo-import-input');
|
||||||
|
assert.ok(input, 'the Import file input');
|
||||||
|
await input.uploadFile(...FILES);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__gevGeoImport.list().length === 4 &&
|
||||||
|
!/Reading/.test(
|
||||||
|
document.getElementById('geo-import-hint')?.textContent || '',
|
||||||
|
),
|
||||||
|
{ timeout: 90000 },
|
||||||
|
);
|
||||||
|
const imported = await state();
|
||||||
|
console.log('navara import', JSON.stringify(imported));
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'geo-import',
|
||||||
|
'GeoJSON, KML, KMZ and GPX each become one row and one drawing on Navara',
|
||||||
|
imported.files.length === 4 &&
|
||||||
|
imported.rows === 4 &&
|
||||||
|
imported.diagnostics.drawn === 4,
|
||||||
|
{ files: imported.files.map((f) => f.format), rows: imported.rows },
|
||||||
|
);
|
||||||
|
// The KMZ's packed ground overlay is a rectangle Navara has no drawing
|
||||||
|
// for; the KML's remote one was left out of the file on import.
|
||||||
|
const undrawn = Object.fromEntries(
|
||||||
|
imported.files.map((f) => [f.name, f.undrawn]),
|
||||||
|
);
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'geo-import',
|
||||||
|
'the ground overlay Navara cannot draw is counted and reported in the hint',
|
||||||
|
undrawn['packed.kmz'] === 1 &&
|
||||||
|
undrawn['park.geojson'] === 0 &&
|
||||||
|
undrawn['remote-bits.kml'] === 0 &&
|
||||||
|
undrawn['walk.gpx'] === 0 &&
|
||||||
|
/leaves out of packed\.kmz: 1/.test(imported.hint),
|
||||||
|
{ undrawn, hint: imported.hint },
|
||||||
|
);
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'geo-import',
|
||||||
|
'the files’ pins are drawn as Navara sprites',
|
||||||
|
imported.pins >= 4,
|
||||||
|
{ pins: imported.pins },
|
||||||
|
);
|
||||||
|
// The first file is flown to after the batch; settle there.
|
||||||
|
await wait(3000);
|
||||||
|
await page.screenshot({ path: `${SHOTS}/navara-imported.png` });
|
||||||
|
|
||||||
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
|
if (
|
||||||
|
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
|
||||||
|
) {
|
||||||
|
await strict.pick(page, kind);
|
||||||
|
await ready(kind);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__gevGeoImport.diagnostics().drawn === 4,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await wait(2500);
|
||||||
|
await page.screenshot({ path: `${SHOTS}/${kind}-after-switch.png` });
|
||||||
|
}
|
||||||
|
const shown = await cyan();
|
||||||
|
await setAll(false);
|
||||||
|
const hidden = await cyan();
|
||||||
|
await setAll(true);
|
||||||
|
console.log(kind, 'cyan', { shown, hidden });
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'geo-import',
|
||||||
|
'the imported shapes paint the first file’s colour and stop when hidden',
|
||||||
|
shown - hidden > 1500,
|
||||||
|
{ shown, hidden },
|
||||||
|
);
|
||||||
|
const kept = await state();
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'geo-import',
|
||||||
|
'the same four files are listed and drawn on this engine',
|
||||||
|
kept.files.length === 4 && kept.diagnostics.drawn === 4,
|
||||||
|
kept.diagnostics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fly-to on Navara goes through the engine camera.
|
||||||
|
const flight = await page.evaluate(
|
||||||
|
async (destination) => {
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
engine.camera.setView({ destination });
|
||||||
|
const before = engine.camera.height();
|
||||||
|
const [first] = window.__gevGeoImport.list();
|
||||||
|
const ok = window.__gevGeoImport.flyTo(first.id);
|
||||||
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
|
return { ok, before, after: engine.camera.height() };
|
||||||
|
},
|
||||||
|
fromDegrees(-100, 40, 3e6),
|
||||||
|
);
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'geo-import',
|
||||||
|
'Fly to a file moves the Navara camera onto it',
|
||||||
|
flight.ok && flight.after < flight.before / 2,
|
||||||
|
flight,
|
||||||
|
);
|
||||||
|
// Remove gives the drawing back.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
for (const file of window.__gevGeoImport.list())
|
||||||
|
window.__gevGeoImport.remove(file.id);
|
||||||
|
});
|
||||||
|
const removed = await state();
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'geo-import',
|
||||||
|
'Remove takes every file and its pins off Navara',
|
||||||
|
removed.files.length === 0 && removed.rows === 0 && removed.pins === 0,
|
||||||
|
{ files: removed.files.length, pins: removed.pins },
|
||||||
|
);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log('PASS Navara import of GeoJSON, KML, KMZ and GPX');
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// The DISPLAY scope mask and the celestial ring on Navara, under the strict
|
||||||
|
// network policy: at city altitude the mask canvas sits in the engine's
|
||||||
|
// container with an opaque outside, the screen's edge goes dark with the
|
||||||
|
// scope on and shows the map with it off, and the ring overlay draws; the
|
||||||
|
// same holds after a switch to Cesium and back.
|
||||||
|
//
|
||||||
|
// node scripts/qa-navara-scope.mjs http://127.0.0.1:<PORT>/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
|
import {
|
||||||
|
createStrict,
|
||||||
|
localPreview,
|
||||||
|
waitForEngine,
|
||||||
|
} from './qa-strict-harness.mjs';
|
||||||
|
|
||||||
|
const base = localPreview(process.argv[2]);
|
||||||
|
base.search = '?engine=navara&welcome=0';
|
||||||
|
base.hash =
|
||||||
|
'v=2&lat=37.7749&lon=-122.4194&alt=3000&heading=0&pitch=-90&roll=0&map=osm&l=';
|
||||||
|
const strict = createStrict({
|
||||||
|
origin: base.origin,
|
||||||
|
layers: ['scope-mask', 'celestial-ring'],
|
||||||
|
});
|
||||||
|
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-scope';
|
||||||
|
fs.mkdirSync(SHOTS, { recursive: true });
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
// The screen's right edge below the rail: outside the keyhole, clear of UI.
|
||||||
|
const GLOBE = fromDegrees(-122.4194, 37.7749, 20_000_000);
|
||||||
|
const CITY = fromDegrees(-122.4194, 37.7749, 3000);
|
||||||
|
const EDGE = { x: 1250, y: 560, width: 26, height: 90 };
|
||||||
|
|
||||||
|
await strict.run(
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async (browser) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 860 });
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
await page.goto(base.href);
|
||||||
|
await waitForEngine(page, 'navara');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
'keyless preview required',
|
||||||
|
);
|
||||||
|
const edgeLuminance = async () => {
|
||||||
|
await wait(900);
|
||||||
|
const { data, info } = await sharp(await page.screenshot({ clip: EDGE }))
|
||||||
|
.removeAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < data.length; i += info.channels)
|
||||||
|
sum += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
|
||||||
|
return sum / (data.length / info.channels);
|
||||||
|
};
|
||||||
|
// DISPLAY ▸ Scope, as its checkbox sends it.
|
||||||
|
const setScope = async (enabled) => {
|
||||||
|
const found = await page.evaluate((on) => {
|
||||||
|
const toggle = document.getElementById('scope-toggle');
|
||||||
|
if (!toggle) return false;
|
||||||
|
toggle.checked = on;
|
||||||
|
toggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
}, enabled);
|
||||||
|
assert.ok(found, 'the DISPLAY scope toggle');
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [round, kind] of ['navara', 'cesium', 'navara'].entries()) {
|
||||||
|
if (
|
||||||
|
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
|
||||||
|
) {
|
||||||
|
await strict.switchTo(page, kind);
|
||||||
|
}
|
||||||
|
await wait(2500);
|
||||||
|
const mask = await page.evaluate(() => {
|
||||||
|
const canvas = document.getElementById('scope-mask');
|
||||||
|
const { engine } = window.__godsEyeView;
|
||||||
|
const context = canvas?.getContext('2d');
|
||||||
|
const corner = context?.getImageData(2, 2, 1, 1).data;
|
||||||
|
const middle = context?.getImageData(
|
||||||
|
Math.round(canvas.width / 2),
|
||||||
|
Math.round(canvas.height / 2),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
).data;
|
||||||
|
return {
|
||||||
|
exists: Boolean(canvas),
|
||||||
|
inContainer: canvas?.parentElement === engine.host.container,
|
||||||
|
count: document.querySelectorAll('#scope-mask').length,
|
||||||
|
height: Math.round(engine.camera.height()),
|
||||||
|
cornerAlpha: corner?.[3],
|
||||||
|
middleAlpha: middle?.[3],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: `${SHOTS}/${kind}-${round}-scope.png` });
|
||||||
|
const on = await edgeLuminance();
|
||||||
|
await setScope(false);
|
||||||
|
const off = await edgeLuminance();
|
||||||
|
const cleared = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.getElementById('scope-mask')
|
||||||
|
?.getContext('2d')
|
||||||
|
.getImageData(2, 2, 1, 1).data[3],
|
||||||
|
);
|
||||||
|
await setScope(true);
|
||||||
|
console.log(kind, JSON.stringify({ mask, on, off, cleared }));
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'scope-mask',
|
||||||
|
'one scope canvas in the engine container, opaque outside and clear inside at city altitude',
|
||||||
|
mask.exists &&
|
||||||
|
mask.inContainer &&
|
||||||
|
mask.count === 1 &&
|
||||||
|
mask.cornerAlpha === 255 &&
|
||||||
|
mask.middleAlpha === 0,
|
||||||
|
mask,
|
||||||
|
);
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'scope-mask',
|
||||||
|
'the screen edge is dark with the scope on and shows the map with it off',
|
||||||
|
on < 12 && off > on + 30 && cleared === 0,
|
||||||
|
{ on, off, cleared },
|
||||||
|
);
|
||||||
|
// The ring is a full-globe view: up to the whole Earth, ring on.
|
||||||
|
await page.evaluate((destination) => {
|
||||||
|
const { engine, styleManager } = window.__godsEyeView;
|
||||||
|
engine.camera.setView({ destination });
|
||||||
|
styleManager.setCelestialRingEnabled(true);
|
||||||
|
}, GLOBE);
|
||||||
|
await wait(2500);
|
||||||
|
const ring = await page.evaluate(() => {
|
||||||
|
const overlay = document.getElementById('celestial-ring-overlay');
|
||||||
|
const canvases = [...(overlay?.querySelectorAll('canvas') ?? [])];
|
||||||
|
return {
|
||||||
|
exists: Boolean(overlay),
|
||||||
|
disabled: overlay?.classList.contains('disabled') ?? null,
|
||||||
|
visible: overlay?.classList.contains('visible') ?? null,
|
||||||
|
updates: Number(overlay?.dataset.ephemerisUpdates ?? 0),
|
||||||
|
canvases: canvases.map((c) => [c.width, c.height]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: `${SHOTS}/${kind}-${round}-ring.png` });
|
||||||
|
console.log(kind, 'ring', JSON.stringify(ring));
|
||||||
|
await strict.check(
|
||||||
|
page,
|
||||||
|
'celestial-ring',
|
||||||
|
'at full-globe altitude the celestial ring draws its sun and moon',
|
||||||
|
ring.exists &&
|
||||||
|
ring.disabled === false &&
|
||||||
|
ring.visible === true &&
|
||||||
|
ring.updates >= 1 &&
|
||||||
|
ring.canvases.length === 2 &&
|
||||||
|
ring.canvases.every(([w, h]) => w > 0 && h > 0),
|
||||||
|
ring,
|
||||||
|
);
|
||||||
|
// Back down to the city for the next engine.
|
||||||
|
await page.evaluate((destination) => {
|
||||||
|
const { engine, styleManager } = window.__godsEyeView;
|
||||||
|
styleManager.setCelestialRingEnabled(false);
|
||||||
|
engine.camera.setView({ destination });
|
||||||
|
}, CITY);
|
||||||
|
await wait(1000);
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log('PASS scope mask and celestial ring on both engines');
|
||||||
|
},
|
||||||
|
);
|
||||||
Loaded 100 of 1313 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user