mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 21:32:23 +00:00
Compare commits
380
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b1694bad | ||
|
|
12116f0ed8 | ||
|
|
447cc08783 | ||
|
|
c87510fd2e | ||
|
|
05daaf267a | ||
|
|
3da4204e97 | ||
|
|
d1cd7f63ef | ||
|
|
9947c6b2e5 | ||
|
|
b97b49cf1d | ||
|
|
486a78ad8e | ||
|
|
8e25105a6c | ||
|
|
8fec5a76bf | ||
|
|
66a8ff06cb | ||
|
|
6eb0729832 | ||
|
|
bb45aacb24 | ||
|
|
c733e4d2ad | ||
|
|
59ac26ed8c | ||
|
|
9c0c85fd83 | ||
|
|
b6c2ba379b | ||
|
|
2919e90c63 | ||
|
|
7571e42556 | ||
|
|
c9559955f0 | ||
|
|
4ef0869a87 | ||
|
|
43706b8dd0 | ||
|
|
9cfac35e38 | ||
|
|
353eebf224 | ||
|
|
12a5630991 | ||
|
|
3521c96172 | ||
|
|
1b010fe328 | ||
|
|
5874773ad9 | ||
|
|
20d58cf888 | ||
|
|
0af33707da | ||
|
|
39246adaf7 | ||
|
|
8eb0173940 | ||
|
|
bb40ac5e54 | ||
|
|
9fe27c2d75 | ||
|
|
c620fac9dc | ||
|
|
a904562f0f | ||
|
|
1a8defbfc8 | ||
|
|
b996e35b0c | ||
|
|
4dc51927bc | ||
|
|
78c5b6c245 | ||
|
|
28907caf7b | ||
|
|
a7f96313e9 | ||
|
|
da134328ef | ||
|
|
09520bc34c | ||
|
|
9bce41d3c6 | ||
|
|
04b8c856ad | ||
|
|
f79ceb690f | ||
|
|
23609b94b6 | ||
|
|
3dd89af354 | ||
|
|
e35f38df8c | ||
|
|
25229a8e4e | ||
|
|
584732c3ec | ||
|
|
e5da073d33 | ||
|
|
ddd07f13f0 | ||
|
|
10784fa9c9 | ||
|
|
fce5ee6381 | ||
|
|
d90da158ea | ||
|
|
b04fcf191c | ||
|
|
581ef7eef4 | ||
|
|
015e3ff7fc | ||
|
|
72a240ba6f | ||
|
|
77df600329 | ||
|
|
b4c03a1c0c | ||
|
|
0dbc657bf9 | ||
|
|
e22348a8e1 | ||
|
|
75e8e7ddbd | ||
|
|
9787d9c4cc | ||
|
|
2725f307a6 | ||
|
|
cf05a29b7f | ||
|
|
45108a0b0b | ||
|
|
0600d86584 | ||
|
|
4da477a654 | ||
|
|
c1ca0f489c | ||
|
|
cdf52ab290 | ||
|
|
c3738c0fdb | ||
|
|
cbb0a391f2 | ||
|
|
ace88172d3 | ||
|
|
6a7a09372a | ||
|
|
3e630cf34f | ||
|
|
9d7fe8fcaa | ||
|
|
a45d6af5f7 | ||
|
|
caad795fa2 | ||
|
|
09bf01630b | ||
|
|
2ea4a78e75 | ||
|
|
61e9cf87f9 | ||
|
|
2c1b2c278d | ||
|
|
7a053c640d | ||
|
|
a5b40dfc3d | ||
|
|
be9c37bee2 | ||
|
|
394f3216f8 | ||
|
|
c62f7ef00e | ||
|
|
dbb082355a | ||
|
|
da6b63b6fd | ||
|
|
3150a460aa | ||
|
|
c2bff536a5 | ||
|
|
d52c719ef6 | ||
|
|
9f2dfdf820 | ||
|
|
027c9616a5 | ||
|
|
c1c71d1987 | ||
|
|
26718edc0b | ||
|
|
2f31828d28 | ||
|
|
c5e1951f51 | ||
|
|
f6dc89b4fa | ||
|
|
e954734379 | ||
|
|
5a6cf22be1 | ||
|
|
63aa45c520 | ||
|
|
1453794573 | ||
|
|
eb1969e874 | ||
|
|
034acb06a8 | ||
|
|
27847bd063 | ||
|
|
4ee617cf42 | ||
|
|
577170ee8e | ||
|
|
6be8af17f1 | ||
|
|
7d6314b981 | ||
|
|
7b844792be | ||
|
|
dd02068ce9 | ||
|
|
fad62a4ba8 | ||
|
|
569c7cfb1b | ||
|
|
b18336d3d4 | ||
|
|
dd063b0e83 | ||
|
|
567c833582 | ||
|
|
745c11969b | ||
|
|
4396ba4506 | ||
|
|
b953c3f1dc | ||
|
|
a274d10038 | ||
|
|
9a16fb8cbc | ||
|
|
e944f75275 | ||
|
|
83a24b8d27 | ||
|
|
4ab23733e9 | ||
|
|
f1ed762e84 | ||
|
|
30fbfe238f | ||
|
|
573479d7ab | ||
|
|
3bf7e9edb7 | ||
|
|
e968233a52 | ||
|
|
85d0f84066 | ||
|
|
bf29408d00 | ||
|
|
b72f453fea | ||
|
|
6ca53febad | ||
|
|
5c25bc54e6 | ||
|
|
a5df391d6c | ||
|
|
af8dc37df5 | ||
|
|
19d7148b06 | ||
|
|
59c0997636 | ||
|
|
fe5470a852 | ||
|
|
d0ef5250ac | ||
|
|
048293fc2a | ||
|
|
a648c9b665 | ||
|
|
eaf2b8f5b4 | ||
|
|
83dd1af22e | ||
|
|
608ab4adea | ||
|
|
cc24da165b | ||
|
|
3bb742ae7e | ||
|
|
7dcf671b2e | ||
|
|
b59eb8b18c | ||
|
|
8bd16b0be9 | ||
|
|
c84cfcadeb | ||
|
|
8ae56587c3 | ||
|
|
d404445da3 | ||
|
|
63e6338c19 | ||
|
|
c56f098d07 | ||
|
|
922712121c | ||
|
|
f62d947a82 | ||
|
|
e9346cef19 | ||
|
|
1b9bb844e6 | ||
|
|
1bc4f980e0 | ||
|
|
80a33ba52c | ||
|
|
5c937e67e1 | ||
|
|
f96a89a9ef | ||
|
|
0ba7ba6512 | ||
|
|
9abc105266 | ||
|
|
0faa29dfbd | ||
|
|
af21219911 | ||
|
|
620a18608e | ||
|
|
bbe33b48c7 | ||
|
|
3ce3fce46f | ||
|
|
052731202d | ||
|
|
18c15d0352 | ||
|
|
36a2216528 | ||
|
|
b98bc4b0dc | ||
|
|
7ee7dc26b8 | ||
|
|
ff0b4d8449 | ||
|
|
424bcb0843 | ||
|
|
137639dd38 | ||
|
|
84f3476dd3 | ||
|
|
e631839a4e | ||
|
|
543c607b23 | ||
|
|
923287eb92 | ||
|
|
71b694ab62 | ||
|
|
e0ba12e5a9 | ||
|
|
c5bdcdf37f | ||
|
|
83bd48102a | ||
|
|
dfd4ba04f8 | ||
|
|
d55acd2ac5 | ||
|
|
72112ecb45 | ||
|
|
52187c91d1 | ||
|
|
084a63ab5f | ||
|
|
6af14f7f13 | ||
|
|
8a14029501 | ||
|
|
cc3d390911 | ||
|
|
b99c2f1ade | ||
|
|
6150e21ebb | ||
|
|
a1d3003b57 | ||
|
|
ca6eace79e | ||
|
|
39d70fa1c0 | ||
|
|
8455c4bce1 | ||
|
|
872bbefb63 | ||
|
|
2213d3d8cf | ||
|
|
6cadfc9583 | ||
|
|
2cac463c6f | ||
|
|
479286a6a6 | ||
|
|
9b65d41bab | ||
|
|
891eb56091 | ||
|
|
c0a5d4141d | ||
|
|
f5d5da7023 | ||
|
|
021e887f94 | ||
|
|
8ec92eb48c | ||
|
|
5e42a83d88 | ||
|
|
36c29d6ab4 | ||
|
|
b86c650428 | ||
|
|
6e4da23cf7 | ||
|
|
574430fb1d | ||
|
|
9c8bb206ef | ||
|
|
c06eb89190 | ||
|
|
c56d5f2f06 | ||
|
|
8da8c05eaf | ||
|
|
5dd7f41436 | ||
|
|
efa4374644 | ||
|
|
13219828e3 | ||
|
|
4c62435910 | ||
|
|
ea76fc4bc0 | ||
|
|
096b891099 | ||
|
|
3cd7ed68aa | ||
|
|
4effadd3e9 | ||
|
|
ddc14c8f51 | ||
|
|
f7b9a2e5af | ||
|
|
8a7402d80c | ||
|
|
72e2eba70d | ||
|
|
41cc57d630 | ||
|
|
61dfdc771e | ||
|
|
b35c810284 | ||
|
|
d5d824f705 | ||
|
|
91ab9b5811 | ||
|
|
d544613aae | ||
|
|
c4298c4450 | ||
|
|
c9f40a837c | ||
|
|
5c4918affb | ||
|
|
8917eebe12 | ||
|
|
5bab5ec26d | ||
|
|
5e64e599e1 | ||
|
|
7673351ce0 | ||
|
|
3a78f40024 | ||
|
|
910e63a56a | ||
|
|
e1ea524b68 | ||
|
|
ea22d16c1a | ||
|
|
4e518821b5 | ||
|
|
3d6423c6f8 | ||
|
|
15fdf65a73 | ||
|
|
aca5317a2a | ||
|
|
8b1a573ef3 | ||
|
|
141181f83a | ||
|
|
dda5ac6c00 | ||
|
|
ecbcc273dd | ||
|
|
0dbbd9047e | ||
|
|
579acf5081 | ||
|
|
697b6b59e6 | ||
|
|
1015b16664 | ||
|
|
0de347c328 | ||
|
|
433b336c2d | ||
|
|
7f8556c691 | ||
|
|
f717f8a0d7 | ||
|
|
d0bf21b8c3 | ||
|
|
90db4eb2c9 | ||
|
|
da00b096d2 | ||
|
|
655d93cc92 | ||
|
|
df87419e64 | ||
|
|
e5ab5f945a | ||
|
|
ed0ad9cf0f | ||
|
|
ee3466dff9 | ||
|
|
c64ed40c62 | ||
|
|
092e7e71a8 | ||
|
|
652f3c8ea1 | ||
|
|
25c33893f6 | ||
|
|
b6ad32009f | ||
|
|
5c04b12164 | ||
|
|
33a2876bc0 | ||
|
|
6172be3995 | ||
|
|
b37025414d | ||
|
|
79c867c8cb | ||
|
|
e2ccac56ae | ||
|
|
a1cee50d86 | ||
|
|
c2a04c11b7 | ||
|
|
b342e405d7 | ||
|
|
84c1fef8d3 | ||
|
|
29a95211c5 | ||
|
|
1c2cdc6775 | ||
|
|
4f43ded5da | ||
|
|
13c3b011be | ||
|
|
a64b41b3f0 | ||
|
|
0e0557e7f5 | ||
|
|
134597c3cf | ||
|
|
36fceb5675 | ||
|
|
2c936ae131 | ||
|
|
6dca8d943a | ||
|
|
4e40594d4f | ||
|
|
b68cd25e9e | ||
|
|
50cd03eb03 | ||
|
|
b47c91965c | ||
|
|
90f9050b4c | ||
|
|
66a5c483c2 | ||
|
|
5f686b7289 | ||
|
|
b7de7c1113 | ||
|
|
44581dccaf | ||
|
|
db8afbae81 | ||
|
|
ccd83c5fa2 | ||
|
|
d1ed2e8e6f | ||
|
|
ae55963a0e | ||
|
|
92666fdbaf | ||
|
|
fa4ce4b2a5 | ||
|
|
bc61a39738 | ||
|
|
9ab54675ba | ||
|
|
3cecc8efbe | ||
|
|
8eab6a76a8 | ||
|
|
4fc9722356 | ||
|
|
287c881c17 | ||
|
|
497016b084 | ||
|
|
724109ee17 | ||
|
|
09d833ed96 | ||
|
|
0d96fe6acd | ||
|
|
32e08f5043 | ||
|
|
72ceeb2fce | ||
|
|
056b3b58c6 | ||
|
|
d04e304502 | ||
|
|
e7973e81cf | ||
|
|
0aa54dfa97 | ||
|
|
16965c4fbc | ||
|
|
ed2aaa2c50 | ||
|
|
48093108b1 | ||
|
|
9faec3028e | ||
|
|
3534398771 | ||
|
|
690a6637e2 | ||
|
|
434fc9602d | ||
|
|
32ec6ed436 | ||
|
|
0c4da3cdb2 | ||
|
|
bbfb23a765 | ||
|
|
11dd283712 | ||
|
|
5f23c785ca | ||
|
|
73f03771d8 | ||
|
|
cbca6a228d | ||
|
|
445af0875b | ||
|
|
a160a9e953 | ||
|
|
6a086cb16f | ||
|
|
deb02f635e | ||
|
|
d167cf0ccb | ||
|
|
c6decfced4 | ||
|
|
b358b5dba1 | ||
|
|
1330605c18 | ||
|
|
3685c416f3 | ||
|
|
586ed4f8ea | ||
|
|
9a5cafca27 | ||
|
|
6977770067 | ||
|
|
2b7c4d1c65 | ||
|
|
d3370d6bc4 | ||
|
|
1133e6327a | ||
|
|
0e2ae76f0a | ||
|
|
3a7c7b7c6a | ||
|
|
abaaf0c254 | ||
|
|
a803be1af2 | ||
|
|
46717548c4 | ||
|
|
85ce864bc4 | ||
|
|
81f49406b8 | ||
|
|
28f3243090 | ||
|
|
e7dd71e9bc | ||
|
|
47b13ab18e | ||
|
|
daed0be99a | ||
|
|
2e5dfce1af | ||
|
|
fb6b4e34f0 | ||
|
|
b09db55149 | ||
|
|
da689e03ac |
No files matched your search
+9
-6
@@ -218,6 +218,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
||||
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
|
||||
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
|
||||
# OTTAWA_CAMERA_CERTIFICATE= # City of Ottawa Traffic Camera Images API certificate (free; request it via the form on traffic.ottawa.ca 'About the data'); without it the Ottawa pack is skipped
|
||||
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
|
||||
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
||||
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
||||
@@ -238,6 +239,8 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
|
||||
# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras
|
||||
# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today)
|
||||
# CCTV_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras
|
||||
# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views exist today)
|
||||
# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras
|
||||
# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200
|
||||
# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL
|
||||
@@ -268,15 +271,15 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
|
||||
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
||||
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
|
||||
# CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first)
|
||||
# CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links)
|
||||
# CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md)
|
||||
# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Oregon and MLIT (Japan) still cameras
|
||||
# CCTV_MAX_SOURCES=7350 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
# Optional: Data Library (DATA LAYERS ▸ Browse data library). Every entry is
|
||||
# fetched live from its provider; nothing is hosted here. See docs/DATA-LIBRARY.md.
|
||||
# GEV_LIBRARY_ENABLE= # comma-separated ids of operator opt-in entries to serve (packs use their host's own switch, e.g. CCTV_NYC_ENABLED)
|
||||
# GEV_LIBRARY_DISABLE= # comma-separated ids to remove from this instance entirely
|
||||
|
||||
# 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=
|
||||
|
||||
@@ -53,10 +53,6 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
run: npm run layer-token:check -- --base-ref origin/main
|
||||
|
||||
- name: Check Data Library manifests and share tokens
|
||||
if: github.event_name == 'pull_request'
|
||||
run: npm run library:check -- --base-ref origin/main
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Announce merged PRs in Discord
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
if: github.repository == 'bilawalsidhu/gods-eye-view' && github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
# This uses the trusted base workflow and never checks out or runs PR code.
|
||||
- name: Post to Discord contributions
|
||||
shell: bash
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_CONTRIBUTIONS_WEBHOOK_URL }}
|
||||
run: |
|
||||
if [[ ! "$DISCORD_WEBHOOK_URL" =~ ^https://discord\.com/api/webhooks/[0-9]+/[A-Za-z0-9_-]+$ ]]; then
|
||||
echo '::error::Set DISCORD_CONTRIBUTIONS_WEBHOOK_URL to the Discord channel webhook URL.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
payload=$(jq -ce '
|
||||
.pull_request | {
|
||||
allowed_mentions: {parse: []},
|
||||
embeds: [{
|
||||
title: ("Merged #" + (.number | tostring) + ": " + (.title | .[0:115])),
|
||||
url: .html_url,
|
||||
footer: {text: ("By " + .user.login + " · into " + (.base.ref | .[0:100]))}
|
||||
}]
|
||||
}
|
||||
' "$GITHUB_EVENT_PATH")
|
||||
|
||||
curl --silent --show-error --fail --output /dev/null \
|
||||
--connect-timeout 10 --max-time 20 --retry 3 --retry-max-time 50 \
|
||||
--header 'Content-Type: application/json' --data-binary "$payload" \
|
||||
--url "$DISCORD_WEBHOOK_URL?wait=true"
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- An application's own tool can take the pointer as Draw does, and its click
|
||||
handlers can yield while a tool holds it, as the layers' do (`pointer` from
|
||||
`createApplicationTools`) (samehkhamis, #1641).
|
||||
|
||||
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
|
||||
|
||||
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
|
||||
|
||||
+3
-4
@@ -128,9 +128,8 @@ prints anything; never fix that by renaming or deleting PR A's published token.
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
|
||||
- **📚 Add a Data Library entry.** A regional or niche source that is not a core layer belongs in the Data Library: one JSON manifest pointing at a live API, provider-hosted file or tile endpoint, with its licence and attribution, and no JavaScript. See [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). Library entries use their own share-token ledger, not the layer tokens below.
|
||||
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template.
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md).
|
||||
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
|
||||
- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
|
||||
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
||||
- **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md).
|
||||
@@ -229,7 +228,7 @@ ownership and adoption process.
|
||||
1. Branch off `main`.
|
||||
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched.
|
||||
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/<pr-number>-<slug>.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly.
|
||||
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution (for a Data Library entry, `npm run library:docs` writes its row). **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
|
||||
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
|
||||
5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
|
||||
6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer).
|
||||
|
||||
|
||||
+74
-126
@@ -30,8 +30,9 @@ How to read this:
|
||||
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
|
||||
| **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo, linked to its Planespotters page; "Planespotters.net" in Data attribution |
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
| **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" |
|
||||
@@ -65,9 +66,11 @@ How to read this:
|
||||
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
|
||||
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
|
||||
| **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner |
|
||||
| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 |
|
||||
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
|
||||
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
|
||||
| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" |
|
||||
| **Webcam Romania — Giroc** (`webcamromania.ro`, `w4.webcamromania.ro`) | Optional Giroc camera metadata, JPEG snapshot and live HLS video | [Provider terms](https://webcamromania.ro/termeni-si-conditii/): video belongs to the original camera operator; no open video redistribution licence asserted. The [camera page](https://webcamromania.ro/webcam-orase/webcam-giroc/) offers an embed. Local opt-in via `CCTV_SOURCES_FILE`; media is fetched at runtime, never bundled. | "Webcam Romania · webcamromania.ro"; source link and rights notice in the Data attribution popover |
|
||||
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
|
||||
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
||||
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
||||
@@ -101,6 +104,40 @@ How to read this:
|
||||
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
|
||||
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
||||
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
|
||||
| **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy |
|
||||
| **Carris Metropolitana** (api.carrismetropolitana.pt) | Transit layer live vehicles, Lisbon Metropolitan Area, Portugal | Keyless GTFS-Realtime documented in the operator’s public API repository (github.com/carrismetropolitana/api). No licence is stated for the realtime endpoint; the operator’s static GTFS is CC BY 4.0. Included on the same basis as the Metro Transit and OVapi feeds and credited as a courtesy; it can be switched off if Carris objects. Speeds are published in km/h and converted to m/s by the proxy. Route ids are read as the line number per the operator’s `/v2/routes`. | Courtesy credit to Carris Metropolitana |
|
||||
| **Prague PID** (api.golemio.cz) | Transit layer live vehicles, Prague and Central Bohemia, Czechia | CC BY (https://pid.cz/o-systemu/opendata/). Stated: “Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY” The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1), read from PID’s GTFS routes.txt. | Pražská integrovaná doprava (PID) / Golemio |
|
||||
| **Warsaw buses and trams** (mkuran.pl) | Transit layer live vehicles, Warsaw, Poland | Warsaw Open Data Project Usage Terms (https://mkuran.pl/gtfs/). Stated: “The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.” Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API and carries the date of the feed timestamp, the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs. | Miasto Stołeczne Warszawa (api.um.warszawa.pl) |
|
||||
| **Bordeaux TBM** (bdx.mecatran.com) | Transit layer live vehicles, Bordeaux, France | Licence Ouverte / Etalab 2.0 (https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite). The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), mapped from TBM’s static GTFS; other numbered lines are buses. | Bordeaux Métropole (TBM) |
|
||||
| **Sendai City Bus** (api-public.odpt.org) | Transit layer live vehicles, Sendai, Japan | CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s. | 仙台市交通局 (Sendai City Transportation Bureau) / ODPT |
|
||||
| **Poznań buses and trams** (ztm.poznan.pl) | Transit layer live vehicles, Poznań, Poland | ZTM Poznań open-data terms (https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/). Stated: “Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.” Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp as the update date. The feed carries no bearing, so heading comes from movement between fixes. | Zarząd Transportu Miejskiego w Poznaniu (processed) |
|
||||
| **Gdańsk buses and trams** (ckan2.multimediagdansk.pl) | Transit layer live vehicles, Gdańsk, Poland | CC BY 4.0 (https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt). The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s. | Zarząd Transportu Miejskiego w Gdańsku |
|
||||
| **Edmonton Transit Service** (gtfs.edmonton.ca) | Transit layer live vehicles, Edmonton, AB | City of Edmonton Open Data Agreement (https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf). The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s. | Contains information licensed under the Open Data Agreement – City of Edmonton |
|
||||
| **Halifax Transit** (gtfs.halifax.ca) | Transit layer live vehicles, Halifax, NS | Open Government Licence – Halifax v1.0 (https://www.halifax.ca/home/open-data/open-data-license), modelled on OGL – Canada 2.0. Stated: “The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.” No logos or implied endorsement. Speeds are m/s. | Contains information licenced under the Open Government Licence – Halifax |
|
||||
| **Victoria Regional Transit** (bct.tmix.se) | Transit layer live vehicles, Victoria, BC | BC Transit Open Data Terms of Use (https://www.bctransit.com/open-data/terms-of-use). Stated: “BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.” The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s. | BC Transit |
|
||||
| **Montpellier TaM** (gtfsproxy.e-tam.fr) | Transit layer live vehicles, Montpellier, France | ODbL (https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain). The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them. | Montpellier Méditerranée Métropole (TaM) |
|
||||
| **Kraków buses** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects. | Zarząd Transportu Publicznego w Krakowie |
|
||||
| **Kraków trams** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). Same server and basis as the bus feed. | Zarząd Transportu Publicznego w Krakowie |
|
||||
| **Vilnius public transport** (stops.lt) | Transit layer live vehicles, Vilnius, Lithuania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383). Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since. | SĮ Susisiekimo paslaugos (stops.lt) |
|
||||
| **Bucharest public transport** (gtfs.tpbi.ro) | Transit layer live vehicles, Bucharest, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104). Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi. | TPBI (Transport Public București–Ilfov) |
|
||||
| **Rome public transport** (romamobilita.it) | Transit layer live vehicles, Rome, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776). Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement. | Roma Servizi per la Mobilità |
|
||||
| **Turin GTT** (percorsieorari.gtt.to.it) | Transit layer live vehicles, Turin, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines. | Gruppo Torinese Trasporti (GTT) |
|
||||
| **Venice ACTV buses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`). | ACTV S.p.A. |
|
||||
| **Venice ACTV waterbuses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). Same catalogue and basis as the ACTV bus feed. | ACTV S.p.A. |
|
||||
| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Transit layer live vehicles, Adelaide, SA | No licence stated for the realtime feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772). Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override. | Adelaide Metro |
|
||||
| **MARTA buses** (gtfs-rt.itsmarta.com) | Transit layer live vehicles, Atlanta, GA | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s. | MARTA |
|
||||
| **Denver RTD** (open-data.rtd-denver.com) | Transit layer live vehicles, Denver, CO | RTD GTFS-realtime licence agreement (https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement). Stated: “RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data”. Read 2026-11-09: a click-through agreement that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from use with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s. | Regional Transportation District (RTD) |
|
||||
| **Cluj-Napoca CTP** (cluj-rt-feed.gtfs.ro) | Transit layer live vehicles, Cluj-Napoca, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Cluj-Napoca |
|
||||
| **Iași CTP** (iasi-rt-feed.gtfs.ro) | Transit layer live vehicles, Iași, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Iași |
|
||||
| **Rzeszów city transport** (mpkrzeszow.pl) | Transit layer live vehicles, Rzeszów, Poland | MPK Rzeszów open data (https://www.mpkrzeszow.pl/otwartedane/). Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement. | MPK Rzeszów |
|
||||
| **Málaga EMT** (gtfs.emtmalaga.es) | Transit layer live vehicles, Málaga, Spain | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213). Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement. | EMT Málaga |
|
||||
| **Carris (Lisbon city)** (gateway.carris.pt) | Transit layer live vehicles, Lisbon, Portugal | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930). Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant. | Carris |
|
||||
| **BAS.MY Johor Bahru** (api.data.gov.my) | Transit layer live vehicles, Johor Bahru, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Stated: “All GTFS Realtime feeds are licensed under CC BY 4.0.” Served keyless by Malaysia’s national open-data API, which caps GTFS Realtime at 4 requests a minute and refreshes positions every 30 seconds, so the proxy caches this feed for 30 seconds. Without the trailing slash the API redirects. Route ids append an operator code. | BAS.MY / data.gov.my |
|
||||
| **BAS.MY Kota Bharu** (api.data.gov.my) | Transit layer live vehicles, Kota Bharu, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Same API, licence and rate limit as Johor Bahru. | BAS.MY / data.gov.my |
|
||||
| **Rouen Astuce** (api.mrn.cityway.fr) | Transit layer live vehicles, Rouen, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie). The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted); and this is its own TCAR vehicle feed from that record; the suburban TAE and TNI networks are separate feeds and are not read. Route ids carry the network prefix (`TCAR:`); line 90 is the Métro (a street-running light rail, drawn as a tram) and 99 the Calypso boat. | Métropole Rouen Normandie (Astuce) |
|
||||
| **Toulon Mistral** (feed-rdtpm-toulon.ratpdev.com) | Transit layer live vehicles, Toulon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee). Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded. | Métropole Toulon Provence Méditerranée (Mistral) |
|
||||
| **Le Havre LiA** (opendata.transports-lia.fr) | Transit layer live vehicles, Le Havre, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri). The network publishes under ODbL (dataset record quoted); and this is its own GTFS-RT vehicle feed from that record; route ids carry the network prefix `8-`. The layer draws positions and redistributes no database. Lines A and B are the tramway. | Le Havre Seine Métropole (LiA) |
|
||||
| **Tours Fil Bleu** (data.filbleu.fr) | Transit layer live vehicles, Tours, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt). Dataset record’s licence field quoted. | Fil Bleu – Syndicat des Mobilités de Touraine |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
@@ -122,7 +159,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
|
||||
|
||||
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
|
||||
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
|
||||
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The MTQ video is **opt-in** (`CCTV_QUEBEC_VIDEO_ENABLED=1`): the CC BY 4.0 licence covers the camera locations only, and Québec 511's terms for embedding its clips on another site are unconfirmed, so by default MTQ cameras link to the Québec 511 viewer page instead. With the flag set, the 680 MTQ cameras play the operator's five-second MP4 clip directly in the viewer's browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`, only after the viewer presses Play, so Québec 511 sees the viewer's IP address; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. The selected-camera credit names both sources: locations MTQ under CC BY 4.0, video Québec 511. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack. Confirm Québec 511's terms of use before setting `CCTV_QUEBEC_VIDEO_ENABLED=1` on a public deployment.
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||
@@ -163,13 +201,16 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
contributors, ODbL 1.0; GPSJAM for aggregation only.
|
||||
|
||||
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, whose page is an `https://www.planespotters.net` URL, and which names its photographer is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name is visible text next to it ("Photo © {photographer}"), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
|
||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
|
||||
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
|
||||
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
|
||||
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
|
||||
- **City of Ottawa traffic cameras.** Camera images come from the city's [Traffic Camera Images API](https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data), which requires an access certificate requested through the city's registration form and allows one certificate and user id to fetch a camera at most once every 60 seconds. Set `OTTAWA_CAMERA_CERTIFICATE`; without it the pack is skipped and makes no request. The camera list is the documented keyless `traffic.ottawa.ca/map/service/camera` JSON (camera number, English and French names, coordinates, owner). Frames are `traffic.ottawa.ca/opendata/camera?c=<number>`; the server adds the certificate and the user id `gev` at fetch time, so no catalog response carries the key, and serves each camera from one shared in-memory frame per minute. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are never stored on disk. The city states no redistribution licence for the images; the certificate request asks how they will be used, so describe the deployment there. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
|
||||
- **World cams image subset.** The normal CCTV catalog adds 1,176 Oregon TripCheck stills and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The export's Vegagerðin rows are left out because the Iceland pack already loads those cameras. The server validates and pins each image URL to its provider path, fetches a frame only when a camera is viewed, and never stores or redistributes it. [ODOT allows businesses, private citizens and organizations to use TripCheck images and asks for credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions) ("Camera courtesy of ODOT"); TripCheck also carries partner cameras run by Oregon counties and cities, Caltrans and WSDOT, which keep their owner's name in the camera label. MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) follow the Government of Japan Standard Terms of Use 2.0 (CC BY 4.0 compatible, commercial use allowed, source credit required) and ask tools not to collect data periodically, pointing regular collection to the river information data service; frames are therefore fetched only on view and refresh at most every ten minutes per camera. Both credits are registered in the Data attribution popover.
|
||||
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
|
||||
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
|
||||
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
|
||||
@@ -178,6 +219,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
||||
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
|
||||
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
|
||||
- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`.
|
||||
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
|
||||
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
|
||||
- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK.
|
||||
@@ -188,7 +230,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
|
||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All fifty-two registered feeds, covering forty-nine regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
|
||||
|
||||
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
@@ -209,124 +251,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
|
||||
---
|
||||
|
||||
<!-- data-library:start (generated by npm run library:docs) -->
|
||||
|
||||
## Data Library entries (fetched at runtime — not stored in this repo)
|
||||
|
||||
Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`.
|
||||
|
||||
| Entry | How it is served | Licence / terms | Attribution |
|
||||
| --- | --- | --- | --- |
|
||||
| **Alaska Statewide Imagery (2023)** (`ak-statewide-imagery`, `apps.geo.fpac.usda.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USDA / Alaska Geospatial Council" |
|
||||
| **Amsterdam traffic and ANPR camera positions** (`amsterdam-traffic-cameras`, `api.data.amsterdam.nl`) | dataset, geojson, browser-direct; operator adds `amsterdam-traffic-cameras` to `GEV_LIBRARY_ENABLE` | [Gemeente Amsterdam open data: public unless stated otherwise; no named licence](https://schemas.data.amsterdam.nl/datasets/verkeersinformatiesystemen/dataset) | "Gemeente Amsterdam, verkeersinformatiesystemen register (entry contributed by @Maher-Reven)" |
|
||||
| **Arkansas Statewide Imagery (2023)** (`ar-statewide-imagery`, `gis.arkansas.gov`) | tiles, bbox, browser-direct | [Public domain — Arkansas GIS Office open data](https://gis.arkansas.gov/) | "Arkansas GIS Office" |
|
||||
| **Arizona Statewide Imagery (2017)** (`az-statewide-imagery`, `azgeo.az.gov`) | tiles, xyz, browser-direct; operator adds `az-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://azgeo.az.gov/) | "AZGEO" |
|
||||
| **Bangor, Maine Orthoimagery** (`bangor-me`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `bangor-me` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.bangormaine.gov/) | "City of Bangor GIS" |
|
||||
| **basemap.at Orthofoto** (`basemap-at-orthofoto`, `mapsneu.wien.gv.at`) | tiles, xyz, browser-direct | [CC BY 4.0](https://basemap.at/nutzungsbedingungen/) | "basemap.at — Datenquelle: basemap.at" |
|
||||
| **Digitales Orthophoto Bayern (DOP 40 cm, open)** (`bayern-dop40`, `geoservices.bayern.de`) | tiles, bbox, browser-direct | [CC BY 4.0](https://geodaten.bayern.de/opengeodata/) | "© Bayerische Vermessungsverwaltung" |
|
||||
| **Bismarck–Mandan, North Dakota Aerial Photography** (`bismarck-mandan-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `bismarck-mandan-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "95West Aerial Mapping" |
|
||||
| **TopPlusOpen basemap (BKG)** (`bkg-topplus-open`, `sgx.geodatenzentrum.de`) | tiles, xyz, browser-direct | [Datenlizenz Deutschland – Namensnennung 2.0](https://www.govdata.de/dl-de/by-2-0) | "© BKG, dl-de/by-2-0" |
|
||||
| **Florida cameras (FL511)** (`cctv-florida-fl511`, `fl511.com`) | CCTV pack; operator sets `CCTV_FLORIDA_ENABLED=1` | [FL511 content: individual personal use only](https://fl511.com/privacy#disclaimer) | "Florida DOT — FL511.com" |
|
||||
| **Lithuania road cameras (Via Lietuva)** (`cctv-lithuania`, `eismoinfo.lt`) | CCTV pack; operator sets `CCTV_LITHUANIA_ENABLED=1` | [Via Lietuva open-data statement; camera photo coverage unconfirmed](https://eismoinfo.lt/eismoinfo-backend/content?id=44) | "Via Lietuva / eismoinfo.lt" |
|
||||
| **New York City cameras (NYC DOT TMC)** (`cctv-nyc-dot`, `webcams.nyctmc.org`) | CCTV pack; operator sets `CCTV_NYC_ENABLED=1` | [NYC DOT camera feed: data-sharing agreement required](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) | "NYC DOT Traffic Management Center" |
|
||||
| **Connecticut Statewide Imagery (2019)** (`ct-statewide-imagery`, `cteco.uconn.edu`) | tiles, bbox, browser-direct; operator adds `ct-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://ct-deep-gis-open-data-website-ctdeep.hub.arcgis.com/) | "CT ECO — UConn CLEAR / CT DEEP" |
|
||||
| **Dakota County, Minnesota Air Photo** (`dakota-county-mn`, `gisimg.co.dakota.mn.us`) | tiles, bbox, browser-direct; operator adds `dakota-county-mn` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis.co.dakota.mn.us/) | "Dakota County GIS" |
|
||||
| **District of Columbia Statewide Imagery (2025)** (`dc-statewide-imagery`, `imagery.dcgis.dc.gov`) | tiles, bbox, browser-direct | [Open data — DC public domain terms](https://opendata.dc.gov/pages/terms-of-use) | "Office of the Chief Technology Officer" |
|
||||
| **Delaware Statewide Imagery (2024)** (`de-statewide-imagery`, `imagery.firstmap.delaware.gov`) | tiles, bbox, browser-direct; operator adds `de-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://firstmap.delaware.gov/) | "This data is meant for use by requested parties. This data cannot be used without express consent.This data is meant for use by requested parties. This data cannot be used without express consent." |
|
||||
| **Eesti ortofoto (Maa-amet)** (`ee-maa-amet-ortofoto`, `tiles.maaamet.ee`) | tiles, xyz, browser-direct | [Maa-amet open data licence (attribution)](https://geoportaal.maaamet.ee/eng/Spatial-Data/Orthophotos-p309.html) | "© Maa-amet (Estonian Land Board)" |
|
||||
| **US power plants, 100 MW and up (EIA)** (`eia-power-plants`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. Energy Information Administration data: public domain](https://www.eia.gov/about/copyrights_reuse.php) | "U.S. Energy Information Administration (EIA-860, EIA-860M, EIA-923)" |
|
||||
| **European seabed depth (EMODnet)** (`emodnet-bathymetry`, `tiles.emodnet-bathymetry.eu`) | tiles, xyz, browser-direct | [EMODnet data: free use with attribution](https://emodnet.ec.europa.eu/en/emodnet-data-policy) | "EMODnet Bathymetry Consortium — EMODnet Digital Bathymetry (DTM)" |
|
||||
| **Superfund sites (US EPA)** (`epa-superfund-sites`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Facility Registry Service (EnviroMapper)" |
|
||||
| **Toxic release facilities (US EPA TRI)** (`epa-toxic-release-facilities`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Toxics Release Inventory via the Facility Registry Service" |
|
||||
| **Home energy ratings, England and Wales (EPC)** (`epc-england-wales`, `s3.us-west-2.amazonaws.com`) | dataset, geoparquet, browser-direct | [Open Government Licence v3.0 (EPC data); OS Open UPRN under OGL](https://epc.opendatacommunities.org/docs/copyright) | "Energy Performance of Buildings Data, DLUHC; geocoded by Addresscloud with OS Open UPRN — contains OS data © Crown copyright and database right; via Source Cooperative" |
|
||||
| **US building footprints (FEMA USA Structures)** (`fema-usa-structures`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "FEMA Response Geospatial Office and Oak Ridge National Laboratory — USA Structures" |
|
||||
| **Biodiversity records (GBIF)** (`gbif-occurrence-density`, `api.gbif.org`) | tiles, xyz, browser-direct | [GBIF data use agreement (records under CC0, CC BY or CC BY-NC)](https://www.gbif.org/terms) | "GBIF.org — Global Biodiversity Information Facility" |
|
||||
| **Disaster alerts (GDACS)** (`gdacs-disaster-alerts`, `www.gdacs.org`) | dataset, geojson, browser-direct | [GDACS terms of use (free, attribution required)](https://www.gdacs.org/About/termofuse.aspx) | "GDACS — Global Disaster Alert and Coordination System (UN, European Commission)" |
|
||||
| **Orthophotos Canton de Genève (SITG)** (`geneva-sitg-ortho`, `ge.ch`) | tiles, bbox, browser-direct | [SITG open data — free use with attribution](https://ge.ch/sitg/media/sitg/documents/CGU_donnees_libres.pdf) | "© SITG — Etat de Genève" |
|
||||
| **Population density 2020 (SEDAC GPW)** (`gpw-population-density`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [CC BY 4.0](https://sedac.ciesin.columbia.edu/data/collection/gpw-v4/terms-of-use) | "CIESIN, Columbia University — Gridded Population of the World v4.11, via NASA GIBS" |
|
||||
| **Grand Forks, North Dakota Aerial Photography** (`grand-forks-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `grand-forks-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "Grand Forks/East Grand Forks Metropolitan Planning Organization (MPO)" |
|
||||
| **Japan standard map (GSI)** (`gsi-japan-map`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" |
|
||||
| **Japan aerial photos (GSI)** (`gsi-japan-photo`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" |
|
||||
| **Holocene volcanoes (Smithsonian GVP)** (`gvp-holocene-volcanoes`, `webservices.volcano.si.edu`) | dataset, geojson, proxied | [Smithsonian Terms of Use (personal, educational, non-commercial use; cite the Global Volcanism Program)](https://www.si.edu/termsofuse) | "Global Volcanism Program, Smithsonian Institution — Volcanoes of the World" |
|
||||
| **Iowa Statewide Imagery (2023)** (`ia-statewide-imagery`, `ortho.gis.iastate.edu`) | tiles, bbox, browser-direct; operator adds `ia-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://geodata.iowa.gov/) | "USDA NAIP, Iowa State University GIS Facility" |
|
||||
| **Ortofoto de Catalunya 25 cm** (`icgc-orto-catalunya`, `geoserveis.icgc.cat`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.icgc.cat/en/Ambits-tematics/Ortofoto) | "© Institut Cartogràfic i Geològic de Catalunya" |
|
||||
| **Idaho Statewide Imagery (2023)** (`id-statewide-imagery`, `insideidaho.org`) | tiles, bbox, browser-direct | [Public domain / open — INSIDE Idaho terms](https://insideidaho.org/) | "United States Department of Agriculture, Farm Production and Conservation Business Center, Geospatial Enterprise Operations (FPAC-BC-GEO)" |
|
||||
| **France aerial photos (IGN)** (`ign-orthophotos-france`, `data.geopf.fr`) | tiles, xyz, browser-direct | [Licence Ouverte / Etalab 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/) | "© IGN — Géoplateforme" |
|
||||
| **Illinois Statewide Imagery** (`il-statewide-imagery`, `data.isgs.illinois.edu`) | tiles, bbox, browser-direct; operator adds `il-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://clearinghouse.isgs.illinois.edu/) | "Illinois State Geological Survey / IDOT" |
|
||||
| **Maritime chokepoints (IMF PortWatch)** (`imf-portwatch-chokepoints`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-chokepoints` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org)" |
|
||||
| **Port disruptions (IMF PortWatch)** (`imf-portwatch-disruptions`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-disruptions` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org), with GDACS event data" |
|
||||
| **Indiana Statewide Imagery (2016)** (`in-statewide-imagery`, `di-ingov.img.arcgis.com`) | tiles, bbox, browser-direct | [CC0 1.0 Universal (public domain dedication)](https://creativecommons.org/publicdomain/zero/1.0/legalcode) | "Indiana Geographic Information Office (CC0)" |
|
||||
| **Surface water occurrence (JRC)** (`jrc-surface-water`, `storage.googleapis.com`) | tiles, xyz, browser-direct | [Copernicus programme: free, without restriction of use](https://global-surface-water.appspot.com/download) | "Source: EC JRC/Google — Pekel et al. (2016), Global Surface Water" |
|
||||
| **Norway counties and municipalities (Kartverket)** (`kartverket-admin-units`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
|
||||
| **Norway cadastral map (Kartverket)** (`kartverket-cadastre`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
|
||||
| **Norway sea depths (Kartverket)** (`kartverket-depth`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
|
||||
| **Norway hillshade (Kartverket)** (`kartverket-hillshade`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
|
||||
| **Norway nautical charts (Kartverket)** (`kartverket-nautical-charts`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
|
||||
| **Norway terrain relief (Kartverket DTM)** (`kartverket-terrain-relief`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
|
||||
| **Norway topographic map (Kartverket)** (`kartverket-topo`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
|
||||
| **Norway topographic map, greyscale (Kartverket)** (`kartverket-topo-grey`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
|
||||
| **Norway topographic raster map (Kartverket)** (`kartverket-toporaster`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" |
|
||||
| **Kansas Statewide Imagery (2021)** (`ks-statewide-imagery`, `services.kgs.ku.edu`) | tiles, bbox, browser-direct; operator adds `ks-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.kansasgis.org/) | "FSA, USDA," |
|
||||
| **Kentucky Statewide Imagery** (`ky-statewide-imagery`, `kyraster.ky.gov`) | tiles, bbox, browser-direct | [Public domain with attribution](https://kygeonet.ky.gov/) | "KyFromAbove" |
|
||||
| **Lake County, Indiana Aerial Imagery** (`lake-county-in`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lake-county-in` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lakecountyin.org/) | "Lake County Surveyor's Office" |
|
||||
| **Lawrence, Kansas Aerial Photos** (`lawrence-ks`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lawrence-ks` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis-lawrenceks.opendata.arcgis.com/) | "City of Lawrence GIS" |
|
||||
| **Lee County, Florida Aerial Imagery** (`lee-county-fl`, `gisimageserver.leegov.com`) | tiles, bbox, browser-direct; operator adds `lee-county-fl` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.leegov.com/gis) | "Eagle View, Lee County Property Appraiser, Lee County GIS" |
|
||||
| **Loudoun County, Virginia Imagery** (`loudoun-county-va`, `logis.loudoun.gov`) | tiles, bbox, browser-direct; operator adds `loudoun-county-va` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://geohub-loudoungis.opendata.arcgis.com/) | "Loudoun County GIS" |
|
||||
| **Louisville / LOJIC, Kentucky Imagery** (`louisville-lojic-ky`, `services.lojic.org`) | tiles, bbox, browser-direct; operator adds `louisville-lojic-ky` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lojic.org/) | "LOJIC, USDA-NAIP" |
|
||||
| **Orthophoto officielle du Luxembourg** (`lu-ortho-latest`, `wmts1.geoportail.lu`) | tiles, xyz, browser-direct | [CC0 1.0 (Licence ouverte Luxembourg)](https://data.public.lu/en/datasets/orthophoto-officelle-du-grand-duche-de-luxembourg-edition-2023/) | "Administration du cadastre et de la topographie, Luxembourg (CC0)" |
|
||||
| **Massachusetts Statewide Imagery** (`ma-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ma-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis.data.mass.gov/) | "MassGIS, Commonwealth of Massachusetts" |
|
||||
| **Geologic map (Macrostrat)** (`macrostrat-geology`, `tiles.macrostrat.org`) | tiles, xyz, browser-direct | [CC BY 4.0 (Macrostrat; source maps credited by their publishers)](https://macrostrat.org/about) | "Macrostrat — geologic maps from their original publishers" |
|
||||
| **Global terrain shaded relief (Mapterhorn)** (`mapterhorn-terrain`, `tiles.mapterhorn.com`) | tiles, xyz, browser-direct | [Open data, per source (mostly CC BY 4.0 and public domain)](https://mapterhorn.com/attribution) | "© Mapterhorn and its open elevation sources" |
|
||||
| **MARIS Imagery, Mississippi** (`maris-ms`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `maris-ms` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.ms.gov/) | "MARIS" |
|
||||
| **Maryland Statewide Imagery** (`md-statewide-imagery`, `mdgeodata.md.gov`) | tiles, bbox, browser-direct; operator adds `md-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://imap.maryland.gov/) | "Maryland iMAP" |
|
||||
| **Maine Statewide Imagery** (`me-statewide-imagery`, `gis.maine.gov`) | tiles, bbox, browser-direct; operator adds `me-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.maine.gov/geolib/) | "USDA Aerial Photography Field Office" |
|
||||
| **Michigan Statewide Imagery (2024)** (`mi-statewide-imagery`, `imagery.michigan.gov`) | tiles, bbox, browser-direct; operator adds `mi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis-michigan.opendata.arcgis.com/) | "State of Michigan" |
|
||||
| **Minnesota Statewide Imagery** (`mn-statewide-imagery`, `imageserver.gisdata.mn.gov`) | tiles, bbox, browser-direct | [Public domain / open — MnGeo terms](https://www.mngeo.state.mn.us/) | "MnGeo / State of Minnesota" |
|
||||
| **Mountain Village, Colorado Orthoimagery** (`mountain-village-co`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `mountain-village-co` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.townofmountainvillage.com/) | "Town of Mountain Village" |
|
||||
| **Mississippi Statewide Imagery** (`ms-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ms-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.ms.gov/) | "USDA-FSA-APFO" |
|
||||
| **Montana Statewide Imagery (2023)** (`mt-statewide-imagery`, `gisservice.mt.gov`) | tiles, bbox, browser-direct | [Public domain — Montana State Library open data](https://msl.mt.gov/geoinfo/) | "Montana State Library" |
|
||||
| **NAIP — National Agriculture Imagery Program** (`naip`, `imagery.nationalmap.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://naip-usdaonline.hub.arcgis.com/) | "USDA NAIP" |
|
||||
| **Earth at night (NASA Black Marble 2016)** (`nasa-black-marble`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.earthdata.nasa.gov/engage/open-data-services-software-policies) | "NASA Earth Observatory / GIBS — VIIRS Black Marble" |
|
||||
| **NASA EONET open natural events** (`nasa-eonet-events`, `eonet.gsfc.nasa.gov`) | dataset, geojson, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.nasa.gov/nasa-brand-center/images-and-media/) | "NASA Earth Observatory Natural Event Tracker (EONET)" |
|
||||
| **North Carolina Statewide Imagery (2023)** (`nc-statewide-imagery`, `services.nconemap.gov`) | tiles, bbox, browser-direct | [Public domain — NC OneMap terms of use](https://www.nconemap.gov/pages/terms) | "NC911, NCCGIA" |
|
||||
| **North Central Texas (NCTCOG) Ortho** (`nctcog-tx`, `mapitimage.tarrantcounty.com`) | tiles, bbox, browser-direct; operator adds `nctcog-tx` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nctcog.org/) | "North Central Texas Council of Governments" |
|
||||
| **North Dakota Statewide Imagery (2013)** (`nd-statewide-imagery`, `ndgishub.nd.gov`) | tiles, xyz, browser-direct; operator adds `nd-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.nd.gov/) | "ND Dept. of Transportation, ND Dept. of Emergency Services, and the ND GIS Hub" |
|
||||
| **New Orleans Regional Planning Commission Imagery** (`new-orleans-rpc-la`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `new-orleans-rpc-la` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.norpc.org/) | "Regional Planning Commission for Jefferson, Orleans, Plaquemines, St. Bernard, St. Charles, St. John the Baptist, St. Tammany, and Tangipahoa Parishes (RPC)" |
|
||||
| **New Hampshire Statewide Imagery (2022)** (`nh-statewide-imagery`, `nhgeodata.unh.edu`) | tiles, bbox, browser-direct; operator adds `nh-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — NH GRANIT terms](https://www.granit.unh.edu/) | "NH GRANIT, University of New Hampshire" |
|
||||
| **New Jersey Statewide Imagery (2015)** (`nj-statewide-imagery`, `maps.nj.gov`) | tiles, xyz, browser-direct; operator adds `nj-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — State of New Jersey provides data 'as is'](https://njgin.nj.gov/) | "Project definition, funding requests, and data distribution by NJ Office of Information Technology, Office of Geographic Information Systems (NJOIT - OGIS). Funding for the imagery project provided by" |
|
||||
| **US tide stations (NOAA CO-OPS)** (`noaa-tide-stations`, `api.tidesandcurrents.noaa.gov`) | dataset, json, browser-direct | [NOAA data: U.S. public domain](https://tidesandcurrents.noaa.gov/disclaimers.html) | "NOAA Center for Operational Oceanographic Products and Services (CO-OPS)" |
|
||||
| **Digitale Orthophotos NRW (DOP 10 cm)** (`nrw-dop`, `www.wms.nrw.de`) | tiles, bbox, browser-direct | [Datenlizenz Deutschland – Zero (public-domain-like)](https://www.govdata.de/dl-de/zero-2-0) | "Geobasis NRW (dl-de/zero-2-0)" |
|
||||
| **NSW Imagery (Six Maps)** (`nsw-imagery`, `maps.six.nsw.gov.au`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.spatial.nsw.gov.au/products_and_services/web_services) | "© Spatial Services NSW" |
|
||||
| **US bridges and their condition (NBI)** (`ntad-national-bridges`, `services.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. government work: unrestricted public use](https://www.bts.gov/ntad) | "USDOT Bureau of Transportation Statistics — National Transportation Atlas Database, National Bridge Inventory" |
|
||||
| **Northwest Arkansas (Benton & Washington County) Imagery** (`nwa-benton-washington-ar`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `nwa-benton-washington-ar` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nwarpc.org/) | "NWARPC; Washington County; Benton County" |
|
||||
| **US weather alerts with drawn areas (NWS)** (`nws-active-alerts`, `api.weather.gov`) | dataset, geojson, browser-direct | [NWS data: U.S. public domain](https://www.weather.gov/disclaimer) | "NOAA National Weather Service" |
|
||||
| **New York Statewide Imagery (2019)** (`ny-statewide-imagery`, `orthos.its.ny.gov`) | tiles, xyz, browser-direct | [Public domain — NYS open data](https://data.gis.ny.gov/) | "USDA; NYS ITS Geospatial Services" |
|
||||
| **Oakland County, Michigan Ortho Imagery** (`oakland-county-mi`, `gisservices.oakgov.com`) | tiles, bbox, browser-direct; operator adds `oakland-county-mi` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://oakgov.maps.arcgis.com/) | "Oakland County GIS" |
|
||||
| **Ohio Statewide Imagery** (`oh-statewide-imagery`, `maps.ohio.gov`) | tiles, xyz, browser-direct | [Public domain — Ohio open data](https://maps.ohio.gov/) | "Ohio Geographically Referenced Information Program (OGRIP)" |
|
||||
| **Seamarks (OpenSeaMap)** (`openseamap-seamarks`, `tiles.openseamap.org`) | tiles, xyz, browser-direct | [CC BY-SA 2.0; data © OpenStreetMap contributors (ODbL)](https://openseamap.org/index.php?id=imprint&L=1) | "© OpenSeaMap contributors, CC BY-SA; data © OpenStreetMap contributors" |
|
||||
| **Oregon Statewide Imagery (2000)** (`or-statewide-imagery`, `imagery.oregonexplorer.info`) | tiles, bbox, browser-direct | [Public domain — Oregon open data](https://www.oregon.gov/geo/) | "Oregon, GEO, DAS" |
|
||||
| **Pennsylvania Statewide Imagery** (`pa-statewide-imagery`, `imagery.pasda.psu.edu`) | tiles, bbox, browser-direct | [Public domain — PASDA open data](https://www.pasda.psu.edu/) | "PASDA / PAMAP, Commonwealth of Pennsylvania" |
|
||||
| **Netherlands aerial photos (PDOK)** (`pdok-aerial-netherlands`, `service.pdok.nl`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.pdok.nl/introductie/-/article/luchtfoto-pdok) | "Beeldmateriaal Nederland via PDOK" |
|
||||
| **Ortofotomapa Polski (GUGiK)** (`pl-ortofotomapa`, `mapy.geoportal.gov.pl`) | tiles, bbox, browser-direct | [Polish open geodata (free reuse, Prawo geodezyjne art. 40a)](https://www.geoportal.gov.pl/) | "© GUGiK / geoportal.gov.pl" |
|
||||
| **PNOA Máxima Actualidad** (`pnoa-ma`, `www.ign.es`) | tiles, bbox, browser-direct | [CC BY 4.0 (compatible) — Orden FOM/2807/2015](https://www.scne.es/productos.html) | "PNOA © Instituto Geográfico Nacional de España" |
|
||||
| **Portland Metro (RLIS), Oregon Aerial Photos** (`portland-metro-or`, `utility.arcgis.com`) | tiles, xyz, browser-direct; operator adds `portland-metro-or` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://rlisdiscovery.oregonmetro.gov/) | "Oregon Metro RLIS" |
|
||||
| **Ortofotos de Portugal Continental (DGT)** (`pt-dgt-ortos`, `ortos.dgterritorio.gov.pt`) | tiles, bbox, browser-direct | [CC BY 4.0](https://www.dgterritorio.gov.pt/) | "© Direção-Geral do Território" |
|
||||
| **Rhode Island Statewide Imagery (2019)** (`ri-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ri-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — RIGIS terms](https://www.rigis.org/) | "URI EDC, RIGIS" |
|
||||
| **Switzerland aerial photos (SWISSIMAGE 10 cm)** (`swisstopo-swissimage`, `wmts.geo.admin.ch`) | tiles, xyz, browser-direct | [swisstopo free geodata (OGD): free use, including commercial, with source credit](https://www.swisstopo.admin.ch/en/terms-of-use-free-geodata-and-geoservices) | "©swisstopo — SWISSIMAGE 10 cm" |
|
||||
| **Texas Statewide Imagery (2022)** (`tx-statewide-imagery`, `imagery.geographic.texas.gov`) | tiles, bbox, browser-direct | [Public domain — TxGIO open data](https://geographic.texas.gov/) | "Texas Geographic Information Office (TxGIO)" |
|
||||
| **Union County, Ohio Aerial Imagery** (`union-county-oh`, `www7.co.union.oh.us`) | tiles, bbox, browser-direct; operator adds `union-county-oh` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://union-county-auditor-unionco.hub.arcgis.com/) | "Union County / City of Marysville GIS" |
|
||||
| **US rivers and lakes (USGS)** (`usgs-hydrography`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map — National Hydrography Dataset" |
|
||||
| **US topographic map (USGS)** (`usgs-topo`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map" |
|
||||
| **Virginia Statewide Imagery** (`va-statewide-imagery`, `vginmaps.vdem.virginia.gov`) | tiles, xyz, browser-direct; operator adds `va-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — VGIN VBMP terms](https://vgin.vdem.virginia.gov/) | "Virginia Geographic Information Network (VGIN)" |
|
||||
| **Orthofotomozaïek Vlaanderen (winter)** (`vlaanderen-omw`, `geo.api.vlaanderen.be`) | tiles, bbox, browser-direct | [Vlaanderen model licence for free reuse](https://www.vlaanderen.be/digitaal-vlaanderen/onze-oplossingen/orthofotomozaieken) | "© Digitaal Vlaanderen" |
|
||||
| **Vermont Statewide Imagery** (`vt-statewide-imagery`, `maps.vcgi.vermont.gov`) | tiles, bbox, browser-direct | [Public domain — Vermont open data](https://vcgi.vermont.gov/) | "Vermont Center for Geographic Information (VCGI)" |
|
||||
| **Orthophotos de Wallonie** (`wallonie-ortho`, `geoservices.wallonie.be`) | tiles, bbox, browser-direct | [Conditions ouvertes SPW (attribution)](https://geoportail.wallonie.be/catalogue-donnees) | "© Service public de Wallonie" |
|
||||
| **Wisconsin Statewide Imagery (2020)** (`wi-statewide-imagery`, `di-wisdot.img.arcgis.com`) | tiles, bbox, browser-direct; operator adds `wi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WisconsinView / state open data](https://www.wisconsinview.org/) | "WisDOT - NAIP 2020" |
|
||||
| **Global power plants (WRI)** (`wri-global-power-plants`, `raw.githubusercontent.com`) | dataset, csv, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "World Resources Institute, Global Power Plant Database (2021)" |
|
||||
| **West Virginia Statewide Imagery** (`wv-statewide-imagery`, `services.wvgis.wvu.edu`) | tiles, bbox, browser-direct; operator adds `wv-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WV GIS Technical Center terms](https://wvgis.wvu.edu/) | "WV GIS Technical Center, West Virginia University" |
|
||||
|
||||
<!-- data-library:end -->
|
||||
|
||||
## Bundled snapshots
|
||||
|
||||
Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README.
|
||||
@@ -338,10 +262,15 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
|
||||
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
|
||||
| **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel |
|
||||
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
|
||||
| **Imaging sensor pack** (46 Earth-observation platforms: swath, resolution) | `imaging_sensors/` | Individual specification facts from operator and agency pages, cited per entry | ✅ (facts only) | "Imaging sensor swaths: operator and agency specifications" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
|
||||
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
|
||||
@@ -397,11 +326,20 @@ Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons
|
||||
> We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS).
|
||||
|
||||
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
|
||||
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
|
||||
Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
`/api/firms` server-side proxy merges the two VIIRS NRT sources (NOAA-20, NOAA-21)
|
||||
and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16. Suomi NPP (`VIIRS_SNPP_NRT`)
|
||||
is not requested: NASA stopped delivering it on 2026-11-02.
|
||||
|
||||
### UCS Satellite Database (`ucs_satellites/`)
|
||||
|
||||
Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
|
||||
|
||||
### Imaging sensor pack (`imaging_sensors/`)
|
||||
|
||||
Swath widths and ground resolutions for 46 Earth-observation platforms (Landsat, Sentinel, MODIS, VIIRS, GOES, Himawari, Gaofen, CBERS, RADARSAT, TerraSAR-X, COSMO-SkyMed, ALOS, KOMPSAT, SPOT, Pléiades, WorldView, SkySat, PlanetScope, ICEYE, Capella, Umbra and others), compiled 2026-11-04 from the operator and agency pages cited per entry. The orbital view draws each satellite's sensor swath to scale and caps its zoom at that swath. Facts only; no text or images copied. A swath is confirmed on an operator, agency (ESA's eoPortal included) or academic page; resellers are not cited. The CEOS MIM Database (CC BY 4.0) is not yet used. Sources and unverified values: the folder README.
|
||||
|
||||
### Natural Earth physical regions (`natural_earth/`)
|
||||
|
||||
@@ -422,6 +360,16 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w
|
||||
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
|
||||
wiring (see below).
|
||||
|
||||
### Natural Earth populated places (`natural_earth/populated_places.json`)
|
||||
|
||||
Natural Earth 10m populated places (`ne_10m_populated_places_simple`, the same pinned
|
||||
`nvkelso/natural-earth-vector` commit and SHA-256 as the admin-1 build reads), reduced to one
|
||||
row per place: name, latitude and longitude rounded to 3 decimals, `scalerank` and `rank_max`
|
||||
(7,342 places, 229 KB). From 8x the orbital view names the most prominent place within half
|
||||
its width ("Sichuan, China · near Chengdu"), offline, with the states and provinces pack giving
|
||||
the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild:
|
||||
`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README.
|
||||
|
||||
### Sky background (`sky/`)
|
||||
|
||||
Sources, licences, the changes CC BY asks to state, hashes and build commands
|
||||
@@ -592,4 +540,4 @@ NOAA density imagery with the raw GLM product.
|
||||
|
||||
## In-app attribution
|
||||
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
@@ -340,7 +340,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
||||
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
|
||||
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
|
||||
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
|
||||
---
|
||||
|
||||
@@ -436,15 +436,15 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
|
||||
| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 |
|
||||
| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 |
|
||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🛰️ **Satellites** | About 1,300 objects in six classes (Stations, Imaging, Telecom, Starlink, Probes, Debris) with a toggle and a live count each; Starlink loads its whole shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | up to 7,350 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,230 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), Szczecin, Lisbon (Carris Metropolitana and the city’s Carris), Prague, Warsaw, Kraków, Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
|
||||
@@ -485,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key).
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default when its token is set; otherwise KartaView is. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
|
||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
|
||||
@@ -639,7 +639,7 @@ and panel. DelDOT uses its official HTTPS HLS catalog links. Disable that
|
||||
pack with `CCTV_DELDOT_ENABLED=0`.
|
||||
|
||||
The server allows two concurrent sessions. Each retains at most 12 segments
|
||||
and 24 MiB in memory; individual downloads are capped at 4 MiB with a ten
|
||||
and 24 MiB in memory; individual downloads are capped at 8 MiB with a ten
|
||||
second deadline. There are no segment files or ffmpeg processes. Redirects,
|
||||
off-origin references, encrypted playlists and non-MPEG-TS segments are refused.
|
||||
Each decoder has its own client lease (at most eight per session), including
|
||||
|
||||
+3
-3
@@ -69,14 +69,14 @@ browser-key fallback for existing single-key setups.
|
||||
The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay:
|
||||
|
||||
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists to 256 KiB and segment bodies to 8 MiB while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
|
||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||
- **The Data Library proxy fetches manifest upstreams only.** `/api/library/data/<id>`, `/api/library/tile/<id>/<z>/<x>/<y>` and `/api/library/index/<id>` (a partitioned GeoParquet entry's file index, built from an S3 ListObjectsV2 listing and one ranged footer read per file, cached in memory for the entry's `cacheSeconds`) resolve the id against the validated manifests in `src/library/entries/`; the browser never supplies a URL, and every derived request must stay on the manifest's https host. Redirects are refused, dataset bodies are capped while streaming at the manifest's `maxBytes` (at most 32 MiB) and tiles at 2 MiB of `image/png`, `image/jpeg` or `image/webp`, responses are cached in memory only (at most 512 tiles), requests are rate-limited per client, and upstream error text is never returned. A keyed entry must be proxied; its key is read from this server's environment through the key registry and its routes refuse cross-site and forwarded requests like the other cost-bearing routes. Browser-direct entries are fetched by the browser with no cookies or referrer.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
|
||||
+2
-17
@@ -1,8 +1,8 @@
|
||||
# Third-party software notices
|
||||
|
||||
The project depends on third-party npm packages whose own licenses apply. This
|
||||
file records the packages added for the browser-local SDR feature, the
|
||||
Street Level viewer and the Data Library's GeoParquet reader; the complete resolved dependency inventory remains in
|
||||
file records the packages added for the browser-local SDR feature and the
|
||||
Street Level viewer; the complete resolved dependency inventory remains in
|
||||
`package-lock.json`.
|
||||
|
||||
## Web RTL-SDR
|
||||
@@ -45,18 +45,3 @@ Street Level viewer and the Data Library's GeoParquet reader; the complete resol
|
||||
- Source: <https://github.com/mrdoob/three.js>
|
||||
- License: MIT
|
||||
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
|
||||
|
||||
## hyparquet
|
||||
|
||||
- Package: `hyparquet` 1.31.2 (loaded on demand when a Data Library
|
||||
GeoParquet entry is switched on)
|
||||
- Author: Hyperparam
|
||||
- Source: <https://github.com/hyparam/hyparquet>
|
||||
- License: MIT
|
||||
|
||||
## fzstd (hyparquet ZSTD decompressor)
|
||||
|
||||
- Package: `fzstd` 0.1.1
|
||||
- Author: Copyright (c) 2020 Arjun Barrett
|
||||
- Source: <https://github.com/101arrowz/fzstd>
|
||||
- License: MIT
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep outside text in voice and MCP tool results to one line of 180
|
||||
characters: ALPR tags, station names and other display fields (asturksever,
|
||||
#1280).
|
||||
@@ -0,0 +1,8 @@
|
||||
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120
|
||||
Québec camera locations, City of Ottawa traffic cameras (needs a free city
|
||||
access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,230 curated
|
||||
image-backed cameras from Oregon and Japan (InoukT, #1506).
|
||||
- Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and
|
||||
loads only after the viewer presses Play; enforce provider-specific frame
|
||||
refresh limits, and report recognized MLIT maintenance images as degraded
|
||||
instead of healthy camera snapshots.
|
||||
@@ -0,0 +1,5 @@
|
||||
- One world clock now drives everything with a time of its own: the sky,
|
||||
the HUD clock, satellites, launches and weather. A timeline along the
|
||||
bottom of the screen, shown while one of those layers is on, plays, pauses
|
||||
and scrubs it, with a tick for each launch or frame and a way back to
|
||||
live. Share links made away from live carry the time (samehkhamis, #1512).
|
||||
@@ -0,0 +1,7 @@
|
||||
- The timeline brings launches up as the time passes them, offers play
|
||||
speeds from real time to a week a second, and plays smoothly, holding only
|
||||
when the next weather frame has yet to load, with a spinner by the time
|
||||
while it waits. The time rides in a bubble over the handle. The track zooms
|
||||
with a scroll or a pinch, drags along, and goes to the time under a tap, on
|
||||
a phone as on a desktop. Zooming out on the globe stops while the globe is
|
||||
still in view instead of leaving it a speck (samehkhamis, #1513).
|
||||
@@ -0,0 +1,3 @@
|
||||
- The agent and voice query tools wrap whole-degree directions into 0–359, so
|
||||
a vessel course or heading, aircraft course, transit bearing, camera heading
|
||||
or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Add an adjustable event-age filter across S2 GhostMaps products, defaulting
|
||||
to seven days with 1-, 3-, 14- and 30-day choices and an All markers option.
|
||||
Preserve undated features, save the selected window and expire old events
|
||||
while the map stays open. Thanks @dylanmaniatakes (#1528).
|
||||
@@ -0,0 +1,3 @@
|
||||
- The Nominatim disk-cache test checks the cache directory and file modes
|
||||
only where POSIX modes exist, so it no longer always fails on Windows
|
||||
(quanticsoul4772, #1547).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Stop requesting Suomi NPP in Active Fires. NASA stopped delivering its data
|
||||
on November 2, 2026, so `VIIRS_SNPP_NRT` returned no rows while still costing
|
||||
a MAP_KEY transaction on every refresh; the layer now merges VIIRS NOAA-20,
|
||||
VIIRS NOAA-21 and MODIS (IMBrandao, #1552).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Ride the selected AIS vessel from its bridge: an eye height by vessel type,
|
||||
dead reckoning between fixes, a bridge HUD with a 3–24 NM radar, look-around
|
||||
with the arrow keys, and a waterline read from the scene so river boats sit on
|
||||
the river rather than at sea level (Denis Groshev, @ZloyS, #1557).
|
||||
@@ -0,0 +1,4 @@
|
||||
- File satellites under six classes, Stations, Imaging, Telecom, Starlink,
|
||||
Probes and Debris, each with a toggle; all but Starlink show by default.
|
||||
Classes come from CelesTrak SATCAT, the UCS Satellite Database and name rules
|
||||
(Denis Groshev, @ZloyS, #1558).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Ride a tracked satellite in an orbital view: a track-up nadir camera, 1–8×
|
||||
zoom, off-nadir tilt with an approximate swath, a CURRENTLY OVER line, and the
|
||||
owner's flag, operator and TLE identity. `C` no longer enters or leaves first
|
||||
person; double-click and the entry buttons do (Denis Groshev, @ZloyS, #1559).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Zoom imaging satellites past 8×, up to 128×, as far as their sensor's
|
||||
confirmed swath allows at the live altitude, with a to-scale swath strip, a
|
||||
SENSOR step, mouse-wheel zoom, imagery pre-loaded ahead of the ground track and
|
||||
the TLE's age (Denis Groshev, @ZloyS, #1560).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Show a fire perimeter under one acre at its reported size. The incident card
|
||||
rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac"
|
||||
(Rudy Celekli, @rudycelekli, #1569).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Give transit vehicle icons a readable front: the bus, tram, metro and unknown
|
||||
glyphs now carry a nose or cab in their outline, so the direction of travel
|
||||
shows at fleet size and survives the sensor profiles. A vehicle with no known
|
||||
course gets a symmetric outline instead of one that points north, and
|
||||
switches to the pointed glyph once a course arrives (IMBrandao, #1574).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Say why Transit and Bike Share are empty outside coverage. Over a city with
|
||||
no feed or system (London, Tokyo) their Layers rows say so and count the
|
||||
covered regions or systems, or say how low to fly, instead of `never`;
|
||||
leaving coverage clears the last-update time (German, @gekh, #1576).
|
||||
@@ -0,0 +1,3 @@
|
||||
- A vessel whose ambient card is on the map no longer gets a Detection callout
|
||||
showing through the card and covering its speed and course. A vessel whose
|
||||
card the overlay did not paint keeps its callout (German, @gekh, #1577).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Space Missions no longer locks the workspace when its mission index fails to
|
||||
load (429, timeout or bad payload): entry rolls back to the previous layers
|
||||
and camera view and a toast says the index could not load (German, @gekh,
|
||||
#1578).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Hold the orbital camera on a mapped site as the satellite passes over it,
|
||||
from a click on its marker or its Live Signals row, and list nearby sites
|
||||
and satellites in orbit's Live Signals, where a row focuses a site or rides
|
||||
a satellite (Denis Groshev, @ZloyS, #1580).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Fix the translations of the voice area cards, the fire confidence filter,
|
||||
the drone airspace layer and Sea Marks: one word for "landmark" per
|
||||
language, the airspace layer names the airspace again, and Traditional
|
||||
Chinese follows Simplified Chinese again (German, @gekh, #1581).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Add Hebrew (he), the second right-to-left language: the title, rails, dock
|
||||
and corner row mirror, numbers keep Western digits, and plurals use the
|
||||
one/two/other Hebrew forms. Noto Sans Hebrew joins the font stacks and the
|
||||
style, plural and glossary notes arrive in docs/translations/he.md
|
||||
(Hebrew, @meirm, #1594).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Without a Mapillary token, Street Level starts on the first keyless source
|
||||
(KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on
|
||||
the globe. A share link that turns a source off is respected
|
||||
(asturksever, #1601).
|
||||
@@ -1,29 +0,0 @@
|
||||
- Add the Data Library. New regional and niche sources no longer join the
|
||||
Data Layers panel: **Browse data library** at its foot lists live entries
|
||||
(a manifest each in `src/library/entries/`, fetched from the provider, never
|
||||
bundled) with their licence, coverage and what this instance needs to serve
|
||||
them, and **Add** puts one under a Library heading. Datasets (GeoJSON, CSV,
|
||||
ArcGIS), raster tiles and host-layer packs are supported; selections persist
|
||||
and travel in share links as `lib=`, separate from the layer tokens; one
|
||||
generic `/api/library` proxy serves entries without CORS. Seeded with NASA
|
||||
EONET events, EIA and WRI power plants, NWS alerts, NOAA tide stations,
|
||||
Smithsonian volcanoes, NASA Black Marble, OpenSeaMap seamarks, and the
|
||||
opt-in IMF PortWatch and CCTV packs, plus the City of Amsterdam's
|
||||
traffic and ANPR camera positions (opt-in; contributed by @Maher-Reven in
|
||||
#251). FEMA USA Structures and the
|
||||
National Bridge Inventory load what is in view once you zoom in, and
|
||||
Addresscloud's EPC ratings stream from Source Cooperative's GeoParquet on
|
||||
S3, coloured A–G with live legend counts. Open tile overlays (geology,
|
||||
biodiversity, population, surface water, national aerial photos and maps)
|
||||
join GDACS alerts and EPA Superfund and TRI facilities. 64 aerial photo
|
||||
layers from the Open Imagery Register (national programmes across Europe,
|
||||
NSW, NAIP and US state, county and city services, WMS and ArcGIS export
|
||||
included) show their resolution, true-ortho flag, years flown and refresh
|
||||
cycle in Browse; aerial photos stay above every other drape, are clipped to
|
||||
their coverage, and the providers' blank fill no longer whitens the globe.
|
||||
Kartverket's open Norwegian maps (topographic, nautical charts, hillshade,
|
||||
terrain relief, sea depths, boundaries and the cadastre) and Mapterhorn's
|
||||
global terrain, drawn in the browser as shaded relief with an elevation
|
||||
legend, are in the library too. Stat tiles head the dialog. A library layer's row has
|
||||
Zoom to layer and Remove icons, and the dialog has Zoom and a confirmed
|
||||
Remove all (asturksever, #1604).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Street Traffic offers separate Lines and Dots toggles while live flow is on:
|
||||
Lines draws each road with flow in green, amber or red, and Dots shows or
|
||||
hides the moving dots. A host can choose which road modes it offers; without
|
||||
OpenStreetMap the layer never draws simulated roads (#1609).
|
||||
@@ -0,0 +1,2 @@
|
||||
- French catalog review: missing narrow no-break spaces, apostrophes, one
|
||||
mistranslated message and the Starlink shell wording (abiyeenzo, #1611).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Typed chat on OpenAI answers again: OpenAI refused every message because
|
||||
`resolve_area`'s area-id pattern uses Unicode classes Chat Completions does
|
||||
not accept. Chat leaves those patterns out; area ids are still checked
|
||||
before the tool runs (samehkhamis, #1616).
|
||||
@@ -0,0 +1,12 @@
|
||||
- Add Icelandic road cameras from the Icelandic Road and Coastal Administration
|
||||
(Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to
|
||||
`gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166
|
||||
stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`,
|
||||
and only links inside that directory are registered. The catalog is fetched
|
||||
over https with redirects refused and a 2 MiB body cap. An explicit facing in
|
||||
the view description ("til vesturs", "í suður", "séð austur") sets a
|
||||
high-confidence heading; other views keep the low-confidence fallback pose.
|
||||
IRCA publishes the images under CC BY 4.0, credited in the Data attribution
|
||||
popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir,
|
||||
Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and
|
||||
`CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Add an optional Giroc / Webcam Romania CCTV source pack with corrected
|
||||
coordinates, live HLS, a snapshot fallback and provider attribution. Allow
|
||||
HLS segments up to 8 MiB so high-resolution cameras whose segments exceed
|
||||
4 MiB can play, while retaining the 24 MiB per-session cache and existing
|
||||
origin and session limits (NeoXrated23, #1619).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Turning CCTV on opens the camera nearest you instead of the first camera in
|
||||
the catalog, which showed a Sydney camera in London. A share link opens on
|
||||
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
|
||||
@@ -0,0 +1,14 @@
|
||||
- Add 34 keyless feeds to the Transit layer, the first in Central and Eastern
|
||||
Europe, Italy, Spain, France, Japan and Malaysia: Szczecin, Lisbon (Carris
|
||||
Metropolitana and the city's Carris), Prague, Warsaw, Kraków, Poznań,
|
||||
Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice
|
||||
(buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre,
|
||||
Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver,
|
||||
Edmonton, Halifax and Victoria. Each feed records its licence, or that none
|
||||
is stated, in the registry and DATA_SOURCES.md. Speeds published in km/h
|
||||
(Lisbon, Montpellier, Toulon) are converted to m/s by the proxy; route ids
|
||||
read as the line on the vehicle; Prague metro, trains, trams and ferries,
|
||||
Bordeaux trams and boats, the Rouen Métro, Le Havre trams and Venice
|
||||
waterbuses take their own mode. The Warsaw and Poznań credits carry the
|
||||
data date their terms ask for, and the data.gov.my feeds are fetched at
|
||||
most twice a minute to stay inside the API's rate limit (IMBrandao).
|
||||
@@ -0,0 +1,3 @@
|
||||
- An application built with fewer layers no longer restores, saves or shares a
|
||||
layer it does not have when a link or saved state from a full build names it
|
||||
(samehkhamis, #1634).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Draw rows take an application's own actions: `createApplicationTools({
|
||||
drawMarkActions })` adds buttons to drawn areas' and lines' rows, each given
|
||||
the shape's `[lon, lat]` geometry, with what it answers shown under the row.
|
||||
The standalone build passes none and is unchanged (samehkhamis, #1636).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Keep the orbital cockpit's sensor strip steady through a zoom step, load the
|
||||
satellite owner flags at startup from one generated 15 KB sheet, and name
|
||||
"Place names" in the loading status while the place-name packs load on first
|
||||
lookup (Sameh Khamis, @samehkhamis, #1637).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Voice on OpenAI connects again: OpenAI Realtime refused every session, so
|
||||
the mic failed with "Failed to create Realtime token", because
|
||||
`resolve_area`'s area-id pattern uses Unicode classes it no longer accepts.
|
||||
Voice leaves those patterns out, as typed chat does; area ids are still
|
||||
checked before the tool runs (avaldini, #1638).
|
||||
@@ -0,0 +1,7 @@
|
||||
- Keep buses and trains off rooftops and out of bridge decks on the photoreal
|
||||
3D tiles: the ground floor probes the rendered mesh where each vehicle is,
|
||||
keeps each reading at the point it was taken (up to sixteen a cell) and
|
||||
answers from the reading nearest the vehicle, instead of one probe at each
|
||||
110 m cell's centre that lifted every vehicle in the cell onto whatever lay
|
||||
there. Transit probes each vehicle's latest point, and grounded flights read
|
||||
the floor at their own position (Sameh Khamis, @samehkhamis, #1642).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Street Traffic Lines build one set at a time while a city loads, instead of
|
||||
one per paint, so panning with Lines on no longer piles up line builds
|
||||
(Sameh Khamis, @samehkhamis, #1645).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Show a photo of the tracked aircraft. Tracking a civil flight asks
|
||||
Planespotters for that aircraft's photo, and the CONTEXT panel's CONTACTS view
|
||||
shows it with the photographer's credit and a link to the photo's page. A
|
||||
flight lookup (type, route or photo) that failed temporarily is now asked
|
||||
again after a minute instead of staying empty for the session, and a callsign
|
||||
change drops the previous callsign's route (Sameh Khamis, @samehkhamis, #1647).
|
||||
@@ -0,0 +1,4 @@
|
||||
- A FIRMS snapshot that ends at a set time (`at`, epoch ms, from a source that
|
||||
serves one) is no longer labelled live: the Active Fires row reads
|
||||
`NASA FIRMS · 24 h to 10-21 15:00 UTC`, and `get_active_fires` says "in the
|
||||
24 hours to" that time and reports it as `window_end` (samehkhamis, #1650).
|
||||
@@ -0,0 +1,7 @@
|
||||
- A tracked flight's trail splits at feed gaps, impossible jumps and reports
|
||||
without altitude instead of joining a day of history into one line, keeps
|
||||
every history point, and follows the path the aircraft was drawn along so it
|
||||
no longer cuts across turns. A tracked aircraft that stops reporting shows
|
||||
STALE, keeps flying on its last heading for five minutes, then stops dimmed
|
||||
as SIGNAL LOST and is kept ten minutes after its last report; aircraft on or
|
||||
near the ground hold instead (Sameh Khamis, @samehkhamis, #1654).
|
||||
@@ -0,0 +1,4 @@
|
||||
- A long flight keeps its origin and destination when weather or airways take
|
||||
it far off the great circle: the route check now allows 200 km or 15% of the
|
||||
route's length, whichever is larger (623 km for JFK–SFO), while a short hop
|
||||
still allows 200 km (Sameh Khamis, @samehkhamis, #1655).
|
||||
@@ -0,0 +1,3 @@
|
||||
- A tracked aircraft is dimmed as STALE while the whole flight feed is
|
||||
stalled, as it is after a missed update, instead of staying at full
|
||||
brightness (Sameh Khamis, @samehkhamis, #1656).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Move the tile budget helpers `utcDayKey`, `normalizeBudget` and
|
||||
`isOverBudget` out of the TomTom module into `src/data/tileBudget.js`, so
|
||||
other keyed tile proxies can share them. `src/data/tomtomTiles.js`
|
||||
re-exports them; behaviour does not change (monwiss, #762).
|
||||
@@ -0,0 +1 @@
|
||||
- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (BreezeDelegate, #766; fixes #39).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET` on
|
||||
`/api/flights`, `/api/military` and `/api/route`, as the other query proxies
|
||||
do. They answered every verb with the same body. The refusal comes before
|
||||
rate limiting and any upstream call (daikaginza, #794).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Compress the live flight snapshot on the wire. `/api/flights` gzips its
|
||||
cached global snapshot and its regional adsb.lol fallback once per refresh
|
||||
and sends that frame to clients that offer gzip: about 860 KB becomes about
|
||||
250 KB. Other clients get the same bytes uncompressed, and both answers carry
|
||||
`Vary: Accept-Encoding` (daikaginza, #795).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Let the voice assistant read the storm you select in Cyclone advisories:
|
||||
its official advisory, position, intensity and movement. Clearing the
|
||||
selection and the storm leaving the feed are reported apart (kvnloo, #847).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Reduce dead-reckoning work for flights already hidden behind the horizon.
|
||||
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
|
||||
second) while visible contacts and contacts with fleet models retain the full
|
||||
update path (Wilmer Alzate, #870).
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"id": "ro-giroc-webcamromania",
|
||||
"name": "Giroc — Webcam Romania",
|
||||
"city": "Giroc, Timiș",
|
||||
"cityId": "giroc",
|
||||
"provider": "Webcam Romania · webcamromania.ro",
|
||||
"sourceKind": "configured",
|
||||
"feedType": "hls",
|
||||
"url": "https://w4.webcamromania.ro/giroc/index.m3u8",
|
||||
"snapshotUrl": "https://webcamromania.ro/media/snapshots/01a0267e-d893-7203-a7eb-7ce3c8c99703/latest.jpg",
|
||||
"lat": 45.70028947266781,
|
||||
"lon": 21.233675914004227,
|
||||
"headingDeg": 0,
|
||||
"headingConfidence": "low",
|
||||
"pitchDeg": -17,
|
||||
"fovDeg": 74,
|
||||
"rangeM": 700,
|
||||
"mountHeightM": 24,
|
||||
"groundElevationM": 87,
|
||||
"license": "Public camera: https://webcamromania.ro/webcam-orase/webcam-giroc/ . Provider terms: https://webcamromania.ro/termeni-si-conditii/ . Video rights remain with the camera operator. Coordinates corrected by the contributor; direction, height, elevation and field of view are uncalibrated estimates."
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,155 @@
|
||||
# CCTV integration workflow
|
||||
|
||||
Use this workflow when adding a camera provider or repairing an existing feed.
|
||||
The CCTV catalog comes through the local server; media normally does too. Keep
|
||||
provider-specific URL policy on the server, including any validated browser-only
|
||||
media URL permitted by the exception below.
|
||||
|
||||
## 1. Check the source before coding
|
||||
|
||||
- Identify the catalog endpoint and the actual frame or live-media endpoint.
|
||||
They may be different hosts or use different formats.
|
||||
- Read the provider's license, terms, attribution, rate limits, and access
|
||||
requirements. Publicly reachable data is not automatically licensed for
|
||||
reuse. Record whether the app may display it and whether any use is
|
||||
restricted.
|
||||
- Prefer a public, keyless feed. If credentials are required, keep them in a
|
||||
server-only environment variable; never put a secret in browser code or a
|
||||
Vite-exposed variable. A pack that needs a key is opt-in by that key: with
|
||||
the variable unset it makes no request and logs once (Ontario 511 and
|
||||
Ottawa). Keep the key out of catalog URLs and add it only at fetch time. Use
|
||||
the provider's documented API, not a website endpoint that skips its key. If
|
||||
access terms require an agreement or remain unconfirmed, make the pack
|
||||
opt-in and ensure it makes no request by default.
|
||||
- Confirm the feed can be fetched from the app's Node server environment. A URL
|
||||
opening in a browser does not prove the server can reach it, and browser CORS
|
||||
behavior does not diagnose a server-side fetch.
|
||||
- Treat camera directories and aggregators such as [TW Live](https://tw.live/)
|
||||
as discovery aids. Trace each listing to the original agency or camera owner;
|
||||
their data and media terms govern reuse. For example, SkylineWebcams says
|
||||
only camera hosts may embed its live feed and its terms restrict copying or
|
||||
retransmitting footage, so do not proxy its stream without written
|
||||
authorization ([FAQ](https://www.skylinewebcams.com/support/faq.html),
|
||||
[terms](https://www.skylinewebcams.com/en/terms-of-use.html)).
|
||||
- Decide whether the provider supplies still snapshots or continuous video.
|
||||
Prefer stills unless live video is necessary and supported by the existing
|
||||
proxy path.
|
||||
|
||||
## 2. Add and register the provider pack
|
||||
|
||||
Use the smallest existing provider as a template. Current owners are:
|
||||
|
||||
- `server/providers/cctv/sources.js` for standard open-data loaders.
|
||||
- `server/providers/cctv/<provider>.js` for a provider with its own parser,
|
||||
fallback, or more involved URL policy.
|
||||
- `server/providers/cctv/catalog.js` to import the loader and add it to
|
||||
`LIVE_PACKS`. Each pack is independently gated and the shared catalog cap
|
||||
allocates space across packs.
|
||||
- `server/providers/cctv/constants.js` for provider endpoints, caps, and
|
||||
timeouts.
|
||||
|
||||
Return normalized camera records with stable unique IDs, readable names,
|
||||
`city`/`cityId`, provider attribution, valid `lat`/`lon`, and the correct
|
||||
`feedType`. Include camera heading only when the source actually provides a
|
||||
bearing; descriptive direction text is not a numeric heading. Mark uncertain
|
||||
heading data with the existing low-confidence conventions. Set `sourceKind` so
|
||||
health and source diagnostics identify the provider. Apply a bounded per-pack
|
||||
camera limit and geographic filtering where appropriate; avoid one provider
|
||||
silently consuming the whole shared catalog cap.
|
||||
|
||||
Camera coordinates need their own verification. Prefer the original provider's
|
||||
camera coordinates, then corroborate them against its location description,
|
||||
route/mile marker, or official map. An aggregator's map dot, a town/county name,
|
||||
or a geocoded attraction name is not a precise camera position. Do not pin a
|
||||
camera to a city center or nearby landmark; if its actual location cannot be
|
||||
verified, leave it out until it can. For manually curated coordinates, record
|
||||
the evidence in the provider data or an adjacent comment. For example, TW Live
|
||||
camera pages identify the originating agency; use that agency's catalog for
|
||||
the camera point when available.
|
||||
|
||||
If the source becomes opt-in, use the catalog's opt-in gate (`envOptIn`) and
|
||||
document the flag and reason in `DATA_SOURCES.md`. Otherwise use the normal
|
||||
enabled-by-default flag pattern and document how to disable the pack.
|
||||
|
||||
## 3. Keep frame and media requests server-registered
|
||||
|
||||
The browser should request the local `/api/cctv/frame/:id` or
|
||||
`/api/cctv/media/:id` endpoint. Do not pass an arbitrary upstream URL from the
|
||||
browser, and do not fetch an unvalidated URL supplied by a catalog record.
|
||||
Every provider must have a server-side rule that pins frame and media URLs to
|
||||
the expected scheme, host, and path; use HTTPS where the provider supports it.
|
||||
Use `pinnedCameraUrl()` for URLs that belong under a fixed provider directory,
|
||||
or a stricter provider-specific validator when hosts or paths vary. Register
|
||||
any new permitted image/media origin in the existing
|
||||
`server/providers/cctv` media policy.
|
||||
|
||||
Catalog and media loaders must also:
|
||||
|
||||
- refuse unsafe schemes, credentials, unexpected ports, redirects outside the
|
||||
registered origin/path, and malformed identifiers;
|
||||
- set request deadlines and cap catalog and buffered frame bodies;
|
||||
- parse only the expected format and validate coordinates before returning a
|
||||
camera;
|
||||
- handle offline cameras and partial provider failures without failing the
|
||||
entire merged catalog; and
|
||||
- avoid persistent frame storage unless the provider's terms explicitly allow
|
||||
it and the design has been reviewed.
|
||||
|
||||
For live media, prefer the existing proxy and supported feed type (`mjpeg`,
|
||||
`mp4`, `webm`, or `hls`). A direct browser-only video is a narrow fallback for
|
||||
a source whose terms allow embedding when the browser can play it but the app
|
||||
server cannot fetch it. Unconfirmed embedding terms make it opt-in like any
|
||||
other pack, and the browser loads it only after an explicit play action, never
|
||||
on selection, because the provider sees the viewer's IP address. Build its URL
|
||||
from a validated provider ID on the server; never accept a browser-supplied
|
||||
media URL. Render it in a native DOM
|
||||
`<video>` without `crossOrigin`, and do not read its pixels into canvas, WebGL,
|
||||
or a Cesium texture. If a map preview is needed, a second video request may be
|
||||
paused as a DOM overlay at the camera location; label it as an approximate
|
||||
still and scale it with map distance. If terms disallow embedding, use a source
|
||||
link instead. Read [SECURITY.md](../SECURITY.md) before changing URL
|
||||
validation, redirects, or proxy behavior.
|
||||
|
||||
When an image provider sets a minimum refresh interval, enforce it at the
|
||||
server frame boundary as well as in the client. A bounded in-memory cache and
|
||||
single-flight request per camera prevent projection, panel, and re-selection
|
||||
paths from multiplying upstream requests; do not persist frames unless the
|
||||
provider's terms explicitly allow it.
|
||||
|
||||
## 4. Add focused regression coverage
|
||||
|
||||
Add provider tests under `src/data/` following the existing CCTV test naming
|
||||
and fixtures. Cover at least:
|
||||
|
||||
- representative valid catalog rows and their normalized output;
|
||||
- missing or invalid IDs, coordinates, and frame URLs;
|
||||
- duplicate records and the pack's cap/filter behavior;
|
||||
- unsafe host/path, credentials, and redirect cases;
|
||||
- timeout, oversized, malformed, and unavailable upstream responses; and
|
||||
- the frame or live-media content type the provider actually serves.
|
||||
|
||||
Use `src/data/cctvProxy.test.mjs` for shared proxy/media behavior and
|
||||
`src/data/cctvCatalogCap.test.mjs` for shared catalog allocation. For a
|
||||
browser-only source, verify native playback, denied canvas pixel access, the
|
||||
paused second-player overlay, and its cleanup separately from proxy tests. Run
|
||||
the focused provider and proxy tests, then the standard checks from
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md) that apply to the change. For runtime
|
||||
verification, inspect `/api/cctv/sources`, `/api/cctv/health`, and the camera's
|
||||
`/api/cctv/frame/:id` or `/api/cctv/stream/:id` response locally. Confirm a
|
||||
successful frame is from the upstream camera by checking its content type and
|
||||
`X-CCTV-Source`; a fallback image is not proof that the upstream feed works.
|
||||
|
||||
## 5. Update source documentation and submit
|
||||
|
||||
- Add the provider, endpoint, terms/license, required attribution, key status,
|
||||
caching behavior, and any restrictions to `DATA_SOURCES.md`.
|
||||
- Update the CCTV source section in `docs/CURRENT-STATE.md` when runtime
|
||||
behavior, flags, caps, or fallback behavior changes.
|
||||
- Register in-app attribution through the existing source/provider credit
|
||||
fields; update `src/data/dataCredits.js` when the source needs a layer-level
|
||||
credit.
|
||||
- Add the required `changelog.d/<pr-number>-<slug>.md` entry for a runtime
|
||||
change, and include the source, license, attribution, key/runtime-fetch
|
||||
details, and verification evidence in the PR description.
|
||||
- For a new layer, also follow the camera/source requirements in the
|
||||
[new-layer PR checklist](../CONTRIBUTING.md#adding-a-new-layer).
|
||||
@@ -136,6 +136,9 @@ socket/watchdog on server close and re-reads configuration after restart.
|
||||
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
|
||||
records without importing Node middleware or a renderer. Browser layer/controller
|
||||
separation is outside this server extraction.
|
||||
The portable `gods-eye-view/sources/planespotters` export reads a Planespotters
|
||||
photo answer (upstream or a server's cached shape) into one host-checked,
|
||||
credited thumbnail; it has no fetch, DOM or Node dependency.
|
||||
|
||||
## Place-search and routing providers
|
||||
|
||||
@@ -163,7 +166,8 @@ local composition mounts them in the same order. Importing the entry performs
|
||||
no acquisition; factory calls create independent cache state.
|
||||
|
||||
`gods-eye-view/sources/space` owns only fixed upstream URL construction: the
|
||||
CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed.
|
||||
CelesTrak group/TLE and SATCAT record queries and Launch Library's recent
|
||||
30-day detailed feed.
|
||||
Callers supply the group or end date and own validation, credentials, transport,
|
||||
response limits and cache policy. The boundary gate checks this portable entry
|
||||
separately from the Node providers. Satellite rendering and launch replay remain
|
||||
|
||||
+664
-308
File diff suppressed because it is too large.
Load diff
@@ -1,183 +0,0 @@
|
||||
# Data Library
|
||||
|
||||
DATA LAYERS is the curated set of core layers. The Data Library holds
|
||||
everything else: regional sources, niche themes and community contributions.
|
||||
A viewer opens it from **DATA LAYERS ▸ Browse data library**, adds what they
|
||||
want, and the entry appears in the panel under **Library**. Nothing in the
|
||||
library is on until someone adds it.
|
||||
|
||||
Runtime behaviour is described in [CURRENT-STATE.md](CURRENT-STATE.md#data-library--october-21-2026).
|
||||
This page is the authoring guide.
|
||||
|
||||
## The one rule: live sources only
|
||||
|
||||
A library entry points at data its provider hosts. That can be a query API, a
|
||||
static file at a stable URL the provider controls, or a tile endpoint. The
|
||||
data is never copied into this repository. If the upstream goes away, the
|
||||
entry goes stale; this project never becomes the host of record. That is what
|
||||
lets every self-hosted instance serve the whole library with no data of its
|
||||
own.
|
||||
|
||||
It also keeps the existing rule in [CONTRIBUTING.md](../CONTRIBUTING.md): do
|
||||
not add data you have no right to redistribute. A live source still needs a
|
||||
licence that allows this use, and the manifest must say what it is.
|
||||
|
||||
## Kinds
|
||||
|
||||
| Kind | What it is | Renders through |
|
||||
| --- | --- | --- |
|
||||
| `dataset` | Vector features: `geojson`, `geojsonl`, `csv` (lat/lon columns), `json` (a record list with lat/lon fields), `arcgis` (a FeatureServer or MapServer layer, paged) or `geoparquet` (one file, or a partitioned set on S3) | The GeoJSON layer Datacenters and Dams use; view-bounded and GeoParquet sets draw as ground-clamped dots and polygons |
|
||||
| `tiles` | Raster tiles: `xyz` with a `{z}/{x}/{y}` template (`{reverseY}` for TMS rows), or `bbox` with a `{bbox-epsg-3857}` template (WMS GetMap, ArcGIS `export`/`exportImage`) | The active map's imagery host (globe or Google 3D tiles) |
|
||||
| `pack` | A source pack an existing host layer already loads (today: CCTV) | The host layer, switched on by its own environment variable |
|
||||
|
||||
A `dataset` or `tiles` entry needs no JavaScript. A `pack` documents a host
|
||||
layer's opt-in source so viewers can see it and operators know the switch; the
|
||||
host layer owns its code. Provider entries (code implementing a family
|
||||
contract, such as a Street Level provider) are not supported yet and are
|
||||
refused by the validator.
|
||||
|
||||
## Add an entry
|
||||
|
||||
1. Create `src/library/entries/<id>.json`. The id is 3–48 lowercase letters,
|
||||
digits or dashes and is permanent.
|
||||
2. Add one import line for it in `src/library/catalog.js` (keep the list in
|
||||
file-name order).
|
||||
3. For a `dataset` or `tiles` entry, run `npm run library:next -- <id>` and
|
||||
append `["<id>", "<token>"]` to `src/library/ledger.json`. Tokens are
|
||||
permanent: never change or reuse a row, even when an entry is removed.
|
||||
Packs take no token.
|
||||
4. Run `npm run library:docs` to regenerate the DATA_SOURCES.md table.
|
||||
5. Run `npm run library:check -- --base-ref origin/main`,
|
||||
`npm run library:probe`, `npm test` and `npm run qa:data-library`.
|
||||
6. Open a pull request with a `changelog.d/` fragment.
|
||||
|
||||
Start new entries at `"status": "community"`. Maintainers move an entry to
|
||||
`verified` after reviewing its licence and seeing it stay healthy; `stale` and
|
||||
`archived` mark entries whose upstream has stopped answering. Archived entries
|
||||
are hidden from Browse unless a viewer already added them.
|
||||
|
||||
## Manifest reference
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "eia-power-plants",
|
||||
"kind": "dataset",
|
||||
"family": "infrastructure",
|
||||
"title": "US power plants, 100 MW and up (EIA)",
|
||||
"provider": "EIA",
|
||||
"description": "Operating US power plants with at least 100 MW of capacity.",
|
||||
"icon": "ϟ",
|
||||
"maintainer": "github:your-handle",
|
||||
"coverage": { "countries": ["US"], "bbox": [-180, 13, -64, 72] },
|
||||
"source": {
|
||||
"format": "arcgis",
|
||||
"url": "https://services2.arcgis.com/…/Power_Plants_in_the_US/FeatureServer/0",
|
||||
"query": { "where": "Total_MW >= 100", "outFields": "Plant_Name,PrimSource,Total_MW" },
|
||||
"fetch": "browser-direct",
|
||||
"cacheSeconds": 86400,
|
||||
"maxFeatures": 4000,
|
||||
"maxPages": 3
|
||||
},
|
||||
"display": {
|
||||
"color": "#ffc23d",
|
||||
"label": "Plant_Name",
|
||||
"fields": ["PrimSource", "Total_MW"],
|
||||
"rank": { "field": "Total_MW", "order": "desc" }
|
||||
},
|
||||
"licence": { "name": "EIA data: public domain", "url": "https://www.eia.gov/about/copyrights_reuse.php" },
|
||||
"attribution": { "text": "U.S. Energy Information Administration", "url": "https://atlas.eia.gov/" },
|
||||
"status": "community",
|
||||
"reviewed": "2026-10-21"
|
||||
}
|
||||
```
|
||||
|
||||
Unknown fields are errors, so a typo cannot silently drop a cap or a licence.
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id`, `kind`, `family`, `title` (≤ 60), `provider` (≤ 32), `description` (≤ 240), `maintainer`, `status` | yes | `family` is one of movement, cameras, infrastructure, events, weather, environment, imagery. `provider` is the short name the panel row shows. |
|
||||
| `coverage` | yes | `global: true`, ISO 3166-1 alpha-2 `countries`, and/or a `bbox` `[west, south, east, north]`. |
|
||||
| `source.url` | yes | https, a real hostname, no credentials or fragment. |
|
||||
| `source.format` | yes | Datasets: `geojson`, `geojsonl`, `csv`, `json`, `arcgis`, `geoparquet`. Tiles: `xyz` (`{z}`, `{x}` and `{y}` or `{reverseY}`) or `bbox` (one `{bbox-epsg-3857}`, filled with the tile's Web Mercator bounds in metres, as MapLibre does). Packs: `host`. |
|
||||
| `source.records` | no | JSON only: the property path to the record list, such as `stations` for `{ "stations": [ … ] }`. Omit when the response is the list itself. |
|
||||
| `source.fetch` | datasets, tiles | `browser-direct` when the upstream sends CORS headers and needs no key; otherwise `proxied`. |
|
||||
| `source.query` | no | Query parameters added to the URL. For `arcgis`, `where` and `outFields`; geometry is always WGS84 GeoJSON. |
|
||||
| `source.columns` | no | CSV or JSON: `{ "lat": "…", "lon": "…" }` when the fields are not lat/latitude/y and lon/lng/longitude/x. |
|
||||
| `source.cacheSeconds` | no | 60–86,400, default 3,600. How long the proxy keeps a response. |
|
||||
| `source.maxBytes` | no | Body cap, default 16 MiB, at most 32 MiB. |
|
||||
| `source.maxFeatures` | no | Feature cap, default 10,000, at most 20,000. A capped dataset reads PARTIAL. |
|
||||
| `source.maxPages` | no | ArcGIS pages to follow, default 5, at most 10. |
|
||||
| `source.view` | no | `{ "maxAltitudeM": 300–50000, "sample": [w, s, e, n] }`. Makes a huge ArcGIS layer view-bounded: below that camera height it loads what is in view, one page at a time; above it the row asks the viewer to zoom in. Needs `arcgis` or `geoparquet`, and `browser-direct`. `sample` is where Zoom to layer lands and what `library:probe` queries. |
|
||||
| `source.partitions` | no | GeoParquet only: `{ "listUrl": "<S3 ListObjectsV2 URL with prefix>", "keyBase": "<URL keys are relative to>" }`, both on the source host. Needs `source.view`. The library indexes each file by its footer bbox and reads only the files in view. |
|
||||
| `source.minZoom`, `source.maxZoom` | no | Tiles only, 0–22. |
|
||||
| `source.encoding` | no | Tiles (`xyz`): `terrarium` when the pixels are elevation as terrain-RGB (height = R × 256 + G + B / 256 − 32768 m). The browser draws them as shaded relief: one orange ramp in six elevation classes (legend in metres), lit from the north-west; sea (exactly 0 m) is transparent. |
|
||||
| `source.tileSize` | no | Tiles: 256 (default) or 512 px. |
|
||||
| `source.bounded` | no | Tiles with `coverage.bbox`: `true` requests tiles inside the bbox only. Required for aerial imagery, whose servers paint a fill (white, a flat ocean, a coarse world mosaic) everywhere they have no photos. A 4xx tile answer is treated as "no imagery here", never retried or counted as an error. |
|
||||
| `source.key` | no | `{ "id": "<key registry id>", "in": "query" \| "header", "name": "…" }`. The id must exist in `src/keySetupCore.mjs`. A keyed entry must be `proxied`, so the key never reaches the browser. |
|
||||
| `display.color`, `display.label`, `display.fields` (≤ 4, two shown) | no | Datasets only, except `opacity`. |
|
||||
| `display.rank` | no | `{ "field": "…", "order": "desc" \| "asc" }`. Keeps the most important features when capped and lets the map prefer them at global zoom. Numbers or ISO dates. Without it, a zoomed-out view shows whichever features come first. |
|
||||
| `display.colorBy` | no | View-bounded datasets: `{ "field": "…", "colors": { "<value>": "#rrggbb" }, "labels": { "<value>": "…" } }`, up to 12 values. Colours each feature by value; the row legend lists each label with the count in view. |
|
||||
| `display.legend` | no | `[{ "label": "…", "color": "#rrggbb" }]`, up to 12: a static legend, such as a tile layer's colour scale. Take the colours from the provider's own legend or colour map. |
|
||||
| `display.outline` | no | Tiles: `countries` outlines every part of each covered country, `coverage` only the parts inside `coverage.bbox`, `bbox` the bbox rectangle itself (a state, county or city inside a country). Use it for aerial imagery so its edge shows. |
|
||||
| `display.aerial` | no | Tiles: `true` for aerial or satellite photography. It drapes above every other imagery layer, including core overlays, and stays there when another layer is switched on. Pair it with `source.bounded`. |
|
||||
| `display.noDataColor` | no | Tiles: `#rrggbb` of the provider's no-imagery fill, drawn transparent (within a small tolerance for JPEG noise along its edge). Measure it from a tile outside the coverage; do not guess. Globe maps only: Google 3D tiles ignore it. |
|
||||
| `display.opacity` | no | Tiles: (0, 1]. |
|
||||
| `imagery` | no | Tiles: what a photo layer is. `type` (`ortho`, `satellite-mosaic`, `oblique`, `historic`), `resolution` as the provider publishes it ("10 cm (plains) / 25 cm (Alps)"), `resolutionM` (the finest, in metres), `trueOrtho`, and optional `vintage` ("2021–present"), `updateCycle`, `orthoQuality` and `registry` (an https link to where the metadata came from). Browse shows it as a spec list on the card. |
|
||||
| `pack` | packs | `{ "host": "cctv", "env": "CCTV_EXAMPLE_ENABLED" }`. Packs are always `licence.operatorOptIn: true`. |
|
||||
| `licence` | yes | `name`, an https `url`, and `operatorOptIn: true` when the terms need the operator's own agreement or consent. |
|
||||
| `attribution` | yes | `text` and an optional https `url`; shown in the Data attribution popover while the entry is in use. |
|
||||
| `regionsServed` | no | ISO codes, when the upstream only answers from some countries. |
|
||||
| `reviewed` | no | `YYYY-MM-DD` of the last licence and endpoint review. |
|
||||
|
||||
## Aerial imagery from the Open Imagery Register
|
||||
|
||||
The aerial photo entries came from the [Open Imagery Register](https://imagery-registry.netlify.app/)
|
||||
on 2026-10-23: every register source with a map preview, less the ones that
|
||||
fail the library's rules. Each entry keeps the register's resolution, product
|
||||
type, true-ortho flag, years flown and refresh cycle in `imagery`. Before an
|
||||
entry was written, a tile inside its coverage was fetched in a browser from
|
||||
the app's origin: it had to be a real photo (not a blank fill), and the
|
||||
provider had to send CORS headers (or the entry would be `proxied`). The
|
||||
fill a provider paints at its coverage corners became `display.noDataColor`.
|
||||
|
||||
Left out, with the reason:
|
||||
|
||||
| Register source | Why |
|
||||
| --- | --- |
|
||||
| NASA GIBS true colour | Already in core (Recent imagery). |
|
||||
| Sentinel-2 cloudless (EOX) | CC BY-NC-SA: non-commercial. |
|
||||
| SWISSIMAGE Bern and Marion County PMTiles | Extracts hosted by the register, not the provider. |
|
||||
| Cabarrus County NC, Hawaii, Nashville, Sioux Falls | Credit a commercial imagery vendor (EagleView, Maxar WorldView, Pictometry, Nearmap). |
|
||||
| DVRPC, Missouri, Washington | The preview serves a blank white tile across the coverage centre. |
|
||||
| Sarasota County FL | The service now requires a token. |
|
||||
|
||||
Entries whose register licence reads "see publisher terms" or "verify portal
|
||||
terms" are `licence.operatorOptIn: true`: an operator reads the publisher's
|
||||
terms and lists the id in `GEV_LIBRARY_ENABLE`. Re-check an entry's terms and
|
||||
drop the opt-in when its publisher states an open licence.
|
||||
|
||||
## Choosing a fetch mode
|
||||
|
||||
Use `browser-direct` when the upstream answers a browser on another origin
|
||||
(`Access-Control-Allow-Origin`) and needs no key. Tile servers must send CORS
|
||||
headers for WebGL to use them; without them, use `proxied`.
|
||||
|
||||
Use `proxied` otherwise. The proxy is generic: there is no per-entry server
|
||||
code. It reaches only the manifest's host, refuses redirects, caps bodies,
|
||||
caches per `cacheSeconds` and never returns upstream error text. The browser
|
||||
names an entry id, never a URL, so the proxy cannot be pointed anywhere else.
|
||||
|
||||
## Operator switches
|
||||
|
||||
| Variable | Effect |
|
||||
| --- | --- |
|
||||
| `GEV_LIBRARY_ENABLE` | Comma-separated ids of `operatorOptIn` datasets and tiles to serve. |
|
||||
| `GEV_LIBRARY_DISABLE` | Comma-separated ids to remove from this instance. |
|
||||
| A pack's own variable | For example `CCTV_NYC_ENABLED=1`; Browse then shows the pack as switched on. |
|
||||
|
||||
## When an entry belongs in core instead
|
||||
|
||||
A library entry can be proposed for DATA LAYERS when it has global or
|
||||
near-global coverage, is keyless or uses a key the registry already has, has
|
||||
stayed `verified` with no stale period for about six months, and a core
|
||||
maintainer will own it. Promotion and demotion are maintainer decisions.
|
||||
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||
|
||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||
so v1 never writes it; a later view kind writes its code.
|
||||
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
|
||||
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
||||
layout and restores it as `tactical`. The code stays reserved.
|
||||
|
||||
Field 28 is the world time the link was made at, written only when it was away
|
||||
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
|
||||
link's creation (a forecast moment), and it opens paused at that time.
|
||||
|
||||
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
# Translating God's Eye View
|
||||
|
||||
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
|
||||
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Hebrew** (`he`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -45,9 +45,10 @@ Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change,
|
||||
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
|
||||
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
|
||||
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`; the chrome mirrors through logical CSS properties (`inset-inline-*`, `margin-inline-*`), and `src/ui/styles/rtl.css` holds what those cannot express (see the Arabic notes). If the language should keep Western digits, map it in `INTL_TAGS` in `locale.js`.
|
||||
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
|
||||
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json` and in `scripts/format-scope.json`.
|
||||
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
|
||||
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
||||
6. If the language is not written in Latin letters, also add it to `NON_LATIN_LOCALES` in `src/hudSummaryResponse.js` — that guard falls back to the translated text when a summary provider answers in English.
|
||||
7. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -63,4 +64,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
|
||||
|
||||
## Language notes
|
||||
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Hebrew](./translations/he.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Hebrew (`he`)
|
||||
|
||||
Catalog: `src/i18n/locales/he.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||
|
||||
## Style
|
||||
|
||||
- Address the user with a masculine imperative, Google style: הסתר, הצג, נסה שוב, התקרב. Menu items and tooltips take the noun form: הסרת {name}, הרחבת {name}, שינוי הצבע של {name}.
|
||||
- Sentence case: Hebrew has no case forms, so UPPERCASE English is normal sentence style here; short HUD labels stay short, favor an abbreviation over a wrapped line.
|
||||
- Units follow the catalog: ק"מ, מ׳, קמ"ש, שנ׳, דק׳, שע׳; an abbreviation always ends in the geresh (׳). Instrument readouts and formatted shared-unit labels keep the Latin units (km, NM, FT, KTS, MW, KM/S); catalog prose outside those readouts keeps ק"מ/מ׳. Codes like HDG, FOV, CAL, SRC, REC stay exactly as in English — see `same-as-english.json`.
|
||||
|
||||
## Digits and direction
|
||||
|
||||
- Western digits 0–9 only, like every catalog: "5 מטוסים", "26.08.2026". Dates in scene labels use dd.mm.yyyy.
|
||||
- `{direction}` values are adverbs (שמאלה, ימינה, ישר); maneuver templates are written to place them after the verb: פנה {direction} אל {road}.
|
||||
- Values inserted by t() are isolated (U+2068…U+2069); do not add manual RTL marks — write the sentence as natural Hebrew and let isolation do the rest.
|
||||
|
||||
## Plurals
|
||||
|
||||
A `tn()` entry needs three forms under `Intl.PluralRules('he')`:
|
||||
|
||||
| Form | Counts | Example |
|
||||
| ------- | ---------------------------- | -------------- |
|
||||
| `one` | 1 and fractions below 1 | 1 מצלמה · 0.5 |
|
||||
| `two` | 2 | 2 מצלמות |
|
||||
| `other` | 0, 3, 10, 100, integers… | 5 מצלמות |
|
||||
|
||||
With {count} shown as a digit, `two` and `other` share the plural noun (2 מצלמות, 5 מצלמות); `one` takes the singular (1 מצלמה). For constructs that resist agreement with a noun (אין, הכול, זמין), phrase around the number: "{count} תחנות מדווחות" is fine, but prefer "ממתין: {count}" over a mismatched genitive chain.
|
||||
|
||||
## Glossary
|
||||
|
||||
| English | עברית |
|
||||
| ---------------------------------------------- | ----------------------------------------------------- |
|
||||
| layer / data layer | שכבה / שכבת נתונים |
|
||||
| feed | פיד |
|
||||
| source / data source / Provider Settings | מקור / מקור נתונים / הגדרות הספקים |
|
||||
| LIVE / live data / LIVE TRACK | חי / נתונים חיים / מעקב חי |
|
||||
| SIMULATED | מדומה |
|
||||
| stale / degraded / unavailable | מיושן / מופחת / לא זמין |
|
||||
| key (API key) / POWER UP | מפתח / שדרוג |
|
||||
| globe / map / map source / basemap | גלובוס / מפה / מקור מפה / מפת בסיס |
|
||||
| aircraft / flight / military flight | מטוס (כלי טיס in class lists) / טיסה / טיסה צבאית |
|
||||
| vessel / ship / ships | אונייה / אוניות |
|
||||
| satellite | לוויין |
|
||||
| launch (rocket) / space mission | שיגור / משימת חלל |
|
||||
| contact / tracking | מגע / עקיבה |
|
||||
| installation / site | מתקן / אתר |
|
||||
| camera / CCTV camera / ALPR camera | מצלמה / מצלמת CCTV / מצלמת ALPR |
|
||||
| earthquake / active fire / cyclone | רעידת אדמה / שריפה פעילה / ציקלון |
|
||||
| scene / shot | סצנה / שוט |
|
||||
| briefing / panel / dock | תדריך / פאנל / עגינה |
|
||||
| visual preset / style | ערכת ראייה / סגנון |
|
||||
| Scope (circular viewport mask) / keyhole | סקופ / חור המנעול |
|
||||
| heading (compass °) / course | כיוון / מהלך |
|
||||
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | בלי שינוי — קודי מכשיר |
|
||||
| advisory (NHC/CPHC cyclone) | עדכון; Advisory center — מרכז הסערה לפי העדכון |
|
||||
| Street View (Google) | Street View, בלי תרגום |
|
||||
| Street Level / Street Level layer | Street Level, בלי תרגום (שם שכבה) |
|
||||
| WHOT/BHOT, Ironbow, CRT, NVG, FLIR | בלי שינוי — קודים ושמות מצבים |
|
||||
| PREV / NEXT | הקודם / הבא |
|
||||
| anchor / data pack / asset bundle (scene) | עוגן / חבילת נתונים / חבילת נכסים |
|
||||
| forecast (weather) | תחזית |
|
||||
| witness / geolocated source | עד ראייה / מקור מאוכן |
|
||||
| contained (wildland fire) / FRP | הכלה / FRP, בלי שינוי |
|
||||
|
||||
+2
-2
@@ -21,12 +21,12 @@
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&family=Noto+Sans+Hebrew:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- Icon font subset to the glyphs this app renders. Keep `icon_names` in sync
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
Generated
+8
-14
@@ -15,9 +15,7 @@
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"cesium": "^1.124.0",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"fzstd": "0.1.1",
|
||||
"hls.js": "^1.7.3",
|
||||
"hyparquet": "1.31.2",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"mgrs": "^2.1.0",
|
||||
"pbf": "^5.1.2",
|
||||
@@ -30,6 +28,7 @@
|
||||
"@happy-dom/global-registrator": "^20.14.6",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"flag-icons": "7.5.0",
|
||||
"happy-dom": "^20.14.6",
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
@@ -2413,6 +2412,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/flag-icons": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -2454,12 +2460,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fzstd": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz",
|
||||
"integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -2570,12 +2570,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/hyparquet": {
|
||||
"version": "1.31.2",
|
||||
"resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.31.2.tgz",
|
||||
"integrity": "sha512-dpVTCGkPPIdfDExa7YZGaJpuBTmLFDKGeZ6meHUj1mz/Q3qmaQHGLLqCyPBkQjfxrYNb08YTax682qeGO+zkVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
|
||||
+3
-7
@@ -38,10 +38,6 @@
|
||||
"i18n": "node scripts/i18n.mjs",
|
||||
"layer-token:next": "node scripts/next-layer-state-token.mjs",
|
||||
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
|
||||
"library:check": "node scripts/library.mjs check",
|
||||
"library:next": "node scripts/library.mjs next",
|
||||
"library:docs": "node scripts/library.mjs docs",
|
||||
"library:probe": "node scripts/library.mjs probe",
|
||||
"changelog": "node scripts/assemble-changelog.mjs --write",
|
||||
"changelog:check": "node scripts/assemble-changelog.mjs --check",
|
||||
"dev": "vite",
|
||||
@@ -58,7 +54,6 @@
|
||||
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
|
||||
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
||||
"qa:transit": "node scripts/qa-transit.mjs",
|
||||
"qa:data-library": "node scripts/qa-data-library.mjs",
|
||||
"mcp": "node server/mcp/stdio.js"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -68,9 +63,7 @@
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"cesium": "^1.124.0",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"fzstd": "0.1.1",
|
||||
"hls.js": "^1.7.3",
|
||||
"hyparquet": "1.31.2",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"mgrs": "^2.1.0",
|
||||
"pbf": "^5.1.2",
|
||||
@@ -83,6 +76,7 @@
|
||||
"@happy-dom/global-registrator": "^20.14.6",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"flag-icons": "7.5.0",
|
||||
"happy-dom": "^20.14.6",
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
@@ -97,6 +91,7 @@
|
||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||
"./application": "./src/app/application.js",
|
||||
"./application/viewer": "./src/app/viewer.js",
|
||||
"./time": "./src/time/worldClock.js",
|
||||
"./build/vite": {
|
||||
"node": "./build/vite.js"
|
||||
},
|
||||
@@ -104,6 +99,7 @@
|
||||
"node": "./server/providers/live.js"
|
||||
},
|
||||
"./sources/adsb-lol": "./src/data/adsbLolFallback.js",
|
||||
"./sources/planespotters": "./src/data/planespottersPhoto.js",
|
||||
"./server/providers/places": {
|
||||
"node": "./server/providers/places.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"source":"Revos addons/Qc cams/quebec_all_cameras.geojson","count":1120,"cameras":[{"id":"4103","kind":"traffic","name":"Route 117 à la jonction de la route 101 et à la hauteur du carrefour giratoire (Rouyn-Noranda)","nameEn":"Junction of route 117 and route 101 and at roundabout (Rouyn-Noranda)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Abitibi-Témiscamingue","lat":48.26461,"lon":-79.01808,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=4103","notes":"Live since 2025/11/26"},{"id":"3820","kind":"traffic","name":"Aut. 20 à la hauteur de la montée Industrielle-et-Commerciale","nameEn":"Aut. 20 at montée Industrielle-et-Commerciale","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.43993,"lon":-68.47784,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3820","notes":"Live since 2018/12/12"},{"id":"3821","kind":"traffic","name":"Montée Industrielle-et-Commerciale à Rimouski","nameEn":"Montée Industrielle-et-Commerciale at Rimouski","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.44639,"lon":-68.48528,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3821","notes":"Live since 2018/12/12"},{"id":"3822","kind":"traffic","name":"Route 132 à la hauteur du Bic à la jonction de l'Aut. 20","nameEn":"Route 132 at Bic at the junction of Aut. 20","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.3775,"lon":-68.68694,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3822","notes":"Live since 2018/12/12"},{"id":"3834","kind":"traffic","name":"Route 132 à la hauteur de Saint-Fabien","nameEn":"Route 132 at Saint-Fabien","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.32927,"lon":-68.80262,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3834","notes":"Live since 2019/02/07"},{"id":"3835","kind":"traffic","name":"Route 132 à la hauteur de Cap-Chat (secteur Capucins)","nameEn":"Route 132 at Cap-Chat (Capucins sector)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.04153,"lon":-66.86847,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3835","notes":"Live since 2019/02/07"},{"id":"3856","kind":"traffic","name":"Route 132 à l'est de Gros-Morne","nameEn":"Route 132, east of Gros-Morne","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.25302,"lon":-65.48355,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3856","notes":"Live since 2019/12/06"},{"id":"3836","kind":"traffic","name":"Route 132 à la hauteur de la baie du Petit Pabos","nameEn":"Route 132 at baie du Petit Pabos","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.37216,"lon":-64.58866,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3836","notes":"Live since 2019/02/07"},{"id":"3837","kind":"traffic","name":"Route 132 à la hauteur de Bonaventure","nameEn":"Route 132 at Bonaventure","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.03448,"lon":-65.46953,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3837","Line truncated
|
||||
@@ -7,13 +7,16 @@
|
||||
* Natural Earth 10m admin-1 states and provinces (public domain).
|
||||
* src/data/local_data/us_census_counties/counties.json
|
||||
* US Census Bureau cartographic boundary counties (public domain).
|
||||
* src/data/local_data/natural_earth/populated_places.json
|
||||
* Natural Earth 10m populated places as points, for the orbital view's
|
||||
* "near <city>" (`src/data/placeBelow.js`; public domain).
|
||||
*
|
||||
* Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs
|
||||
* byte for byte. Downloads are cached outside the repository (default: the
|
||||
* system temp directory) and are never committed.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties]
|
||||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties|places]
|
||||
*
|
||||
* The provenance READMEs beside each pack record these parameters.
|
||||
*/
|
||||
@@ -101,6 +104,9 @@ export const PARAMS = Object.freeze({
|
||||
elsewhereMinPopulation: 50_000,
|
||||
cityStateMaxKm2: 1_000,
|
||||
},
|
||||
// Every populated place, kept as a point: name, position and the two
|
||||
// prominence ranks the orbital view picks "near <city>" by.
|
||||
places: { decimals: 3 },
|
||||
});
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -688,6 +694,47 @@ async function buildAdmin1() {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPlaces() {
|
||||
const params = PARAMS.places;
|
||||
const source = await fetchPinned('places');
|
||||
const factor = 10 ** params.decimals;
|
||||
const round = (value) => Math.round(value * factor) / factor;
|
||||
const places = JSON.parse(source.bytes.toString('utf8'))
|
||||
.features.map((f) => f.properties)
|
||||
.filter(
|
||||
(p) =>
|
||||
p.name &&
|
||||
Number.isFinite(p.latitude) &&
|
||||
Number.isFinite(p.longitude) &&
|
||||
Number.isFinite(p.scalerank),
|
||||
)
|
||||
.map((p) => [
|
||||
p.name,
|
||||
round(p.latitude),
|
||||
round(p.longitude),
|
||||
p.scalerank,
|
||||
Number.isFinite(p.rank_max) ? p.rank_max : 0,
|
||||
])
|
||||
.sort((a, b) => a[3] - b[3] || b[4] - a[4] || a[0].localeCompare(b[0]));
|
||||
return {
|
||||
meta: {
|
||||
title: 'Natural Earth populated places',
|
||||
source: 'ne_10m_populated_places_simple',
|
||||
url: SOURCES.places.url,
|
||||
sha256: source.digest,
|
||||
commit: NE_COMMIT,
|
||||
license:
|
||||
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
|
||||
schema:
|
||||
'places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]',
|
||||
decimals: params.decimals,
|
||||
curation: params,
|
||||
script: 'scripts/build-admin-packs.mjs',
|
||||
},
|
||||
places,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCounties() {
|
||||
const params = PARAMS.counties;
|
||||
const source = await fetchPinned('counties');
|
||||
@@ -768,4 +815,13 @@ if (isMain) {
|
||||
'src/data/local_data/us_census_counties/counties.json',
|
||||
await buildCounties(),
|
||||
);
|
||||
if (!only || only === 'places') {
|
||||
const relative = 'src/data/local_data/natural_earth/populated_places.json';
|
||||
const pack = await buildPlaces();
|
||||
const text = JSON.stringify(pack) + '\n';
|
||||
await writeFile(path.join(ROOT, relative), text);
|
||||
console.log(
|
||||
`${relative}: ${pack.places.length} places, ${Buffer.byteLength(text)} bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the owner flag sheet the orbital Cockpit marks a satellite's owner
|
||||
* country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES
|
||||
* (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs
|
||||
* at twice the readout's 20 × 15 px, in one PNG the app loads at startup.
|
||||
*
|
||||
* src/data/local_data/owner_flags/owner_flags.png the sheet
|
||||
* src/ui/ownerFlagSheet.js its layout (generated)
|
||||
*
|
||||
* Flag i (in the sorted code list) sits at column i % COLUMNS, row
|
||||
* floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions
|
||||
* reproduces both files.
|
||||
*
|
||||
* Usage: node scripts/build-owner-flags.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as prettier from 'prettier';
|
||||
import sharp from 'sharp';
|
||||
import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const FLAGS_DIR = path.join(
|
||||
path.dirname(require.resolve('flag-icons/package.json')),
|
||||
'flags/4x3',
|
||||
);
|
||||
const FLAG_ICONS_VERSION = require('flag-icons/package.json').version;
|
||||
|
||||
/** One flag's cell, CSS pixels × 2. */
|
||||
export const CELL = Object.freeze({ width: 40, height: 30 });
|
||||
/**
|
||||
* Transparent sheet pixels after each flag, so a browser smoothing a scaled
|
||||
* flag (a 125% display) never reaches into its neighbour.
|
||||
*/
|
||||
export const GUTTER = 2;
|
||||
export const COLUMNS = 16;
|
||||
|
||||
const SHEET = path.join(
|
||||
ROOT,
|
||||
'src/data/local_data/owner_flags/owner_flags.png',
|
||||
);
|
||||
const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js');
|
||||
|
||||
async function flagCell(code) {
|
||||
const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`));
|
||||
return sharp(svg, { density: 300 })
|
||||
.resize(CELL.width, CELL.height, { fit: 'fill' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const codes = [...OWNER_COUNTRY_CODES];
|
||||
const rows = Math.ceil(codes.length / COLUMNS);
|
||||
const pitchX = CELL.width + GUTTER;
|
||||
const pitchY = CELL.height + GUTTER;
|
||||
const cells = await Promise.all(codes.map(flagCell));
|
||||
const sheet = await sharp({
|
||||
create: {
|
||||
width: COLUMNS * pitchX,
|
||||
height: rows * pitchY,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
})
|
||||
.composite(
|
||||
cells.map((input, i) => ({
|
||||
input,
|
||||
left: (i % COLUMNS) * pitchX,
|
||||
top: Math.floor(i / COLUMNS) * pitchY,
|
||||
})),
|
||||
)
|
||||
.png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 })
|
||||
.toBuffer();
|
||||
await writeFile(SHEET, sheet);
|
||||
|
||||
const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit.
|
||||
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
|
||||
export const OWNER_FLAG_SHEET = Object.freeze({
|
||||
cellWidthPx: ${CELL.width},
|
||||
cellHeightPx: ${CELL.height},
|
||||
gutterPx: ${GUTTER},
|
||||
columns: ${COLUMNS},
|
||||
rows: ${rows},
|
||||
codes: Object.freeze(${JSON.stringify(codes)}),
|
||||
});
|
||||
`;
|
||||
await writeFile(
|
||||
LAYOUT,
|
||||
await prettier.format(layout, {
|
||||
...(await prettier.resolveConfig(LAYOUT)),
|
||||
filepath: LAYOUT,
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
`Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`,
|
||||
);
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Maintainer-only UCS Satellite Database pack build (openpyxl).
|
||||
|
||||
Reads the UCS Excel release and writes the compact pack the satellites layer
|
||||
loads: one row per satellite, keyed by NORAD number, with string-table indexes
|
||||
for the repeated text columns. Starlink rows are left out: the layer files
|
||||
every STARLINK object under its own class and operator without them.
|
||||
|
||||
Usage: python scripts/build-ucs-satellites.py "<path>/UCS-Satellite-Database 5-1-2023.xlsx"
|
||||
'''
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import openpyxl
|
||||
|
||||
LOG = logging.getLogger('build-ucs-satellites')
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PACK = ROOT / 'src/data/local_data/ucs_satellites'
|
||||
VERSION = 'UCS-Satellite-Database 5-1-2023'
|
||||
DATA_CURRENT_TO = '2023-05-01'
|
||||
URL = 'https://www.ucsusa.org/satellite_database'
|
||||
|
||||
# Output column -> spreadsheet header.
|
||||
COLUMNS = {
|
||||
'operator': 'Operator/Owner',
|
||||
'operatorCountry': 'Country of Operator/Owner',
|
||||
'purpose': 'Purpose',
|
||||
}
|
||||
STARLINK = re.compile(r'\bstarlink\b', re.I)
|
||||
|
||||
|
||||
def clean(value: object) -> str:
|
||||
'''One cell as tidy text; UCS's placeholders for nothing become ''.'''
|
||||
text = re.sub(r'\s+', ' ', str(value or '')).strip()
|
||||
return '' if text.lower() in ('', 'none', 'n/a') else text
|
||||
|
||||
|
||||
def build(xlsx: Path) -> dict:
|
||||
'''Read the release's first sheet into the pack the layer loads.'''
|
||||
sheet = openpyxl.load_workbook(xlsx, read_only=True, data_only=True).worksheets[0]
|
||||
rows = sheet.iter_rows(values_only=True)
|
||||
header = {str(h).strip(): i for i, h in enumerate(next(rows)) if h}
|
||||
names = [header['Current Official Name of Satellite'], header['Name of Satellite, Alternate Names']]
|
||||
norad_col = header['NORAD Number']
|
||||
tables = {key: [''] for key in COLUMNS}
|
||||
index = {key: {'': 0} for key in COLUMNS}
|
||||
records = {}
|
||||
source_rows = skipped_starlink = invalid_norad = 0
|
||||
for row in rows:
|
||||
if not any(row):
|
||||
continue
|
||||
source_rows += 1
|
||||
if any(STARLINK.search(clean(row[i])) for i in names):
|
||||
skipped_starlink += 1
|
||||
continue
|
||||
try:
|
||||
norad = int(float(row[norad_col]))
|
||||
except (TypeError, ValueError):
|
||||
invalid_norad += 1
|
||||
continue
|
||||
if norad <= 0:
|
||||
invalid_norad += 1
|
||||
continue
|
||||
record = [norad]
|
||||
for key, column in COLUMNS.items():
|
||||
text = clean(row[header[column]])
|
||||
if text not in index[key]:
|
||||
index[key][text] = len(tables[key])
|
||||
tables[key].append(text)
|
||||
record.append(index[key][text])
|
||||
# A duplicate NORAD number keeps the first row, as UCS lists it.
|
||||
records.setdefault(norad, record)
|
||||
ordered = [records[n] for n in sorted(records)]
|
||||
return {
|
||||
'meta': {
|
||||
'source': 'Union of Concerned Scientists Satellite Database',
|
||||
'version': VERSION,
|
||||
'url': URL,
|
||||
'dataCurrentTo': DATA_CURRENT_TO,
|
||||
'license': 'Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)',
|
||||
'curation': {
|
||||
'sourceRows': source_rows,
|
||||
'starlinkRowsDropped': skipped_starlink,
|
||||
'invalidNoradDropped': invalid_norad,
|
||||
'duplicateNoradDropped': source_rows - skipped_starlink - invalid_norad - len(ordered),
|
||||
'columns': COLUMNS,
|
||||
},
|
||||
'recordCount': len(ordered),
|
||||
},
|
||||
'fields': ['noradId', *COLUMNS],
|
||||
'strings': tables,
|
||||
'records': ordered,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
'''Build the pack from the release given on the command line.'''
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
parser = argparse.ArgumentParser(description='Build the UCS satellite pack from the UCS Excel release.')
|
||||
parser.add_argument('xlsx', type=Path, help='UCS-Satellite-Database <version>.xlsx')
|
||||
pack = build(parser.parse_args().xlsx)
|
||||
PACK.mkdir(parents=True, exist_ok=True)
|
||||
out = PACK / 'ucs_satellites.json'
|
||||
# LF on every platform: the repository stores text with LF endings.
|
||||
out.write_text(
|
||||
json.dumps(pack, ensure_ascii=False, separators=(',', ':')) + '\n',
|
||||
encoding='utf-8',
|
||||
newline='\n',
|
||||
)
|
||||
LOG.info('%s: %d records, %d bytes', out.relative_to(ROOT), pack['meta']['recordCount'], out.stat().st_size)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
|
||||
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
||||
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
|
||||
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}"
|
||||
|
||||
# Capture which provider credentials genuinely came from the parent shell
|
||||
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
"src/data/terrainHeightsProxy.test.mjs",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/tomtomTiles.test.mjs",
|
||||
"src/data/tileBudget.js",
|
||||
"src/data/tileBudget.test.mjs",
|
||||
"src/data/firmsCsv.js",
|
||||
"src/data/firmsCsv.test.mjs",
|
||||
"src/tooling/environmentProviders.test.mjs",
|
||||
@@ -328,6 +330,12 @@
|
||||
"src/ui/styles/responsive.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/cockpitOrbital.css",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/layers/satellites/sensors.test.mjs",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/ui/styles/command-dock-compact.css",
|
||||
"src/app/stateChannel.js",
|
||||
"src/app/stateChannel.test.mjs",
|
||||
@@ -347,6 +355,7 @@
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/hiddenContactRotation.test.mjs",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/queries.js",
|
||||
@@ -474,6 +483,7 @@
|
||||
"src/layers/satellites/stale.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/launches/failedEntry.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
@@ -581,6 +591,7 @@
|
||||
"src/data/cctvHk.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvA22.test.mjs",
|
||||
"src/data/cctvIceland.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvHouston.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
@@ -1066,6 +1077,7 @@
|
||||
"src/layers/traffic/roadModes.test.mjs",
|
||||
"scripts/qa-traffic-motion.mjs",
|
||||
"src/layers/traffic/motion.test.mjs",
|
||||
"src/layers/traffic/lineRebuild.test.mjs",
|
||||
"scripts/qa-journey-recorder.mjs",
|
||||
"scripts/qa-alpr-journey.mjs",
|
||||
"scripts/qa-context-arrivals.mjs",
|
||||
@@ -1117,6 +1129,7 @@
|
||||
"src/sky/starrySky.test.mjs",
|
||||
"src/tooling/skyAssets.test.mjs",
|
||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||
"src/tooling/responseCompression.test.mjs",
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
@@ -1281,6 +1294,7 @@
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
@@ -1354,5 +1368,10 @@
|
||||
"src/layers/wildlifeSightings/cache.integration.test.mjs",
|
||||
"src/layers/wildlifeSightings/density.test.mjs",
|
||||
"src/layers/wildlifeSightings/selection.test.mjs",
|
||||
"server/providers/inaturalist/proxy.fixture.mjs"
|
||||
"server/providers/inaturalist/proxy.fixture.mjs",
|
||||
"scripts/build-owner-flags.mjs",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/data/placePacksLoading.test.mjs",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/placePacksLoading.test.mjs"
|
||||
]
|
||||
@@ -1,321 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Data Library maintenance. See docs/DATA-LIBRARY.md.
|
||||
*
|
||||
* npm run library:check [-- --base-ref origin/main]
|
||||
* Validate every manifest, the share-token ledger and the generated
|
||||
* DATA_SOURCES.md section. With --base-ref, also prove the ledger only
|
||||
* appended rows since that ref (published tokens never change).
|
||||
* npm run library:next -- <entry-id>
|
||||
* Print the next free share token for a new switchable entry.
|
||||
* npm run library:docs
|
||||
* Rewrite the generated Data Library section of DATA_SOURCES.md.
|
||||
* npm run library:probe [-- --json] [-- --strict]
|
||||
* Hit every dataset and tile upstream once (bounded) and report
|
||||
* status, latency and feature count. --strict exits 1 on a failure.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from '../src/library/catalog.js';
|
||||
import { libraryTileUrl } from '../src/library/manifest.js';
|
||||
import {
|
||||
arcgisPageUrl,
|
||||
datasetRequestUrl,
|
||||
parseLibraryBody,
|
||||
} from '../src/library/formats.js';
|
||||
import {
|
||||
nextLibraryToken,
|
||||
readLibraryLedger,
|
||||
} from '../src/library/selection.js';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DATA_SOURCES = path.join(root, 'DATA_SOURCES.md');
|
||||
const START = '<!-- data-library:start (generated by npm run library:docs) -->';
|
||||
const END = '<!-- data-library:end -->';
|
||||
const LEDGER_PATH = 'src/library/ledger.json';
|
||||
|
||||
const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s+/g, ' ');
|
||||
|
||||
/** The generated DATA_SOURCES.md section, one row per entry. */
|
||||
export function libraryDocsSection(entries = LIBRARY_ENTRIES) {
|
||||
const rows = [...entries]
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((entry) => {
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(entry.source.url.replace(/\{[xyz]\}/g, '0')).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
const how =
|
||||
entry.kind === 'pack'
|
||||
? `CCTV pack; operator sets \`${entry.pack.env}=1\``
|
||||
: `${entry.kind}, ${entry.source.format}, ${entry.source.fetch}` +
|
||||
(entry.licence.operatorOptIn
|
||||
? `; operator adds \`${entry.id}\` to \`GEV_LIBRARY_ENABLE\``
|
||||
: '') +
|
||||
(entry.source.key
|
||||
? `; needs the \`${entry.source.key.id}\` key`
|
||||
: '');
|
||||
return `| **${cell(entry.title)}** (\`${entry.id}\`, \`${host}\`) | ${cell(how)} | [${cell(entry.licence.name)}](${entry.licence.url}) | "${cell(entry.attribution.text)}" |`;
|
||||
});
|
||||
return [
|
||||
START,
|
||||
'',
|
||||
'## Data Library entries (fetched at runtime — not stored in this repo)',
|
||||
'',
|
||||
'Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`.',
|
||||
'',
|
||||
'| Entry | How it is served | Licence / terms | Attribution |',
|
||||
'| --- | --- | --- | --- |',
|
||||
...rows,
|
||||
'',
|
||||
END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function replaceSection(text, section) {
|
||||
const start = text.indexOf(START);
|
||||
const end = text.indexOf(END);
|
||||
if (start < 0 || end < start) {
|
||||
const anchor = text.indexOf('\n## Bundled snapshots');
|
||||
if (anchor < 0)
|
||||
throw new Error('DATA_SOURCES.md has no Bundled snapshots heading');
|
||||
return `${text.slice(0, anchor)}\n${section}\n${text.slice(anchor)}`;
|
||||
}
|
||||
return text.slice(0, start) + section + text.slice(end + END.length);
|
||||
}
|
||||
|
||||
function check(baseRef) {
|
||||
const errors = [];
|
||||
const ledger = readLibraryLedger(LIBRARY_LEDGER);
|
||||
errors.push(...ledger.errors.map((error) => `ledger ${error}`));
|
||||
for (const entry of LIBRARY_ENTRIES) {
|
||||
const switchable = entry.kind !== 'pack';
|
||||
if (switchable && !ledger.tokenById.has(entry.id))
|
||||
errors.push(
|
||||
`${entry.id}: no share token (npm run library:next -- ${entry.id})`,
|
||||
);
|
||||
if (!switchable && ledger.tokenById.has(entry.id))
|
||||
errors.push(
|
||||
`${entry.id}: packs are switched by their host and take no token`,
|
||||
);
|
||||
}
|
||||
if (baseRef) {
|
||||
let published = [];
|
||||
try {
|
||||
published = JSON.parse(
|
||||
execFileSync('git', ['show', `${baseRef}:${LEDGER_PATH}`], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
published = [];
|
||||
}
|
||||
published.forEach(([id, token], index) => {
|
||||
const row = LIBRARY_LEDGER[index];
|
||||
if (!row || row[0] !== id || row[1] !== token)
|
||||
errors.push(
|
||||
`ledger row ${index} (${id} → ${token}) was published on ${baseRef} and must not change`,
|
||||
);
|
||||
});
|
||||
LIBRARY_LEDGER.slice(published.length).forEach(([id, token], offset) => {
|
||||
const expected = nextLibraryToken(
|
||||
LIBRARY_LEDGER.slice(0, published.length + offset),
|
||||
);
|
||||
if (token !== expected)
|
||||
errors.push(
|
||||
`ledger row for ${id} should take ${expected}, not ${token}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
const text = readFileSync(DATA_SOURCES, 'utf8');
|
||||
if (!text.includes(libraryDocsSection()))
|
||||
errors.push(
|
||||
'DATA_SOURCES.md Data Library section is out of date (npm run library:docs)',
|
||||
);
|
||||
if (errors.length) {
|
||||
for (const error of errors) console.error(`✗ ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`Data Library valid: ${LIBRARY_ENTRIES.length} entries, ${LIBRARY_LEDGER.length} share tokens${baseRef ? `, ledger append-only against ${baseRef}` : ''}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Points inside a coverage bbox to try, centre first, as [fx, fy] shares. */
|
||||
const PROBE_POINTS = [
|
||||
[0.5, 0.5],
|
||||
[0.4, 0.45],
|
||||
[0.6, 0.55],
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.75],
|
||||
];
|
||||
|
||||
/**
|
||||
* A Web Mercator tile to probe. Regional entries try points inside their
|
||||
* coverage bbox (a regional server answers 404 or a blank fill outside its
|
||||
* photos, and a bbox centre can be sea); global entries probe a mid-world
|
||||
* tile.
|
||||
*/
|
||||
function probeTile(entry, z, [fx, fy] = PROBE_POINTS[0]) {
|
||||
const n = 2 ** z;
|
||||
const bbox = entry.coverage?.bbox;
|
||||
if (!bbox) return { x: Math.floor(n / 2), y: Math.floor(n / 3) };
|
||||
const lon = bbox[0] + (bbox[2] - bbox[0]) * fx;
|
||||
const lat = Math.max(-85, Math.min(85, bbox[1] + (bbox[3] - bbox[1]) * fy));
|
||||
const rad = (lat * Math.PI) / 180;
|
||||
const clamp = (value) => Math.min(n - 1, Math.max(0, Math.floor(value)));
|
||||
return {
|
||||
x: clamp(((lon + 180) / 360) * n),
|
||||
y: clamp(
|
||||
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function probeEntry(entry) {
|
||||
const started = Date.now();
|
||||
const signal = AbortSignal.timeout(30_000);
|
||||
try {
|
||||
if (entry.kind === 'tiles') {
|
||||
// Regional imagery is probed at z13, the Open Imagery Register's
|
||||
// validation zoom (some caches start there); global layers at their
|
||||
// coarsest level.
|
||||
const minZoom = entry.source.minZoom ?? 0;
|
||||
const z = entry.coverage.bbox
|
||||
? Math.min(entry.source.maxZoom ?? 18, Math.max(minZoom, 13))
|
||||
: minZoom;
|
||||
const points = entry.coverage.bbox ? PROBE_POINTS : [PROBE_POINTS[0]];
|
||||
let status = null;
|
||||
let lastError = null;
|
||||
for (const point of points) {
|
||||
const { x, y } = probeTile(entry, z, point);
|
||||
const url = libraryTileUrl(entry.source.url, z, x, y);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
redirect: 'error',
|
||||
});
|
||||
await response.arrayBuffer();
|
||||
status = response.status;
|
||||
if (
|
||||
response.ok &&
|
||||
/^image\//.test(response.headers.get('content-type') || '')
|
||||
)
|
||||
return { id: entry.id, ok: true, status, ms: Date.now() - started };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (status === null && lastError) throw lastError;
|
||||
return { id: entry.id, ok: false, status, ms: Date.now() - started };
|
||||
}
|
||||
if (entry.source.format === 'geoparquet') {
|
||||
// List the partitions and read one footer by range request: proof the
|
||||
// bucket answers and the files are GeoParquet, without reading data.
|
||||
const { listPartitions, rangeBuffer, geoParquetInfo } =
|
||||
await import('../src/library/parquet.js');
|
||||
const { parquetMetadataAsync } = await import('hyparquet');
|
||||
const fetchImpl = (target, options = {}) =>
|
||||
fetch(target, { ...options, signal });
|
||||
const objects = entry.source.partitions
|
||||
? await listPartitions(entry, { fetchImpl, signal })
|
||||
: [{ key: entry.source.url, size: null }];
|
||||
const first = objects[0];
|
||||
const url = entry.source.partitions
|
||||
? new URL(first.key, entry.source.partitions.keyBase).href
|
||||
: entry.source.url;
|
||||
const size =
|
||||
first.size ??
|
||||
Number(
|
||||
(await fetch(url, { method: 'HEAD', signal })).headers.get(
|
||||
'content-length',
|
||||
),
|
||||
);
|
||||
const metadata = await parquetMetadataAsync(
|
||||
rangeBuffer(url, size, { fetchImpl, signal }),
|
||||
{ initialFetchSize: 16 * 1024 },
|
||||
);
|
||||
return {
|
||||
id: entry.id,
|
||||
ok: Boolean(geoParquetInfo(metadata).column),
|
||||
status: 200,
|
||||
ms: Date.now() - started,
|
||||
features: Number(metadata.num_rows),
|
||||
partitions: objects.length,
|
||||
};
|
||||
}
|
||||
const url =
|
||||
entry.source.format === 'arcgis'
|
||||
? arcgisPageUrl(
|
||||
entry.source.url,
|
||||
entry.source.query,
|
||||
0,
|
||||
// Some huge view services refuse a page smaller than their own.
|
||||
entry.source.view ? 2000 : 100,
|
||||
// A view-bounded layer is probed over its sample area.
|
||||
entry.source.view?.sample ?? null,
|
||||
)
|
||||
: datasetRequestUrl(entry.source);
|
||||
const response = await fetch(url, { signal, redirect: 'error' });
|
||||
const text = await response.text();
|
||||
const parsed = response.ok
|
||||
? parseLibraryBody(entry.source.format, text, entry.source)
|
||||
: null;
|
||||
return {
|
||||
id: entry.id,
|
||||
ok: Boolean(parsed?.candidates.length),
|
||||
status: response.status,
|
||||
ms: Date.now() - started,
|
||||
features: parsed?.candidates.length ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: entry.id,
|
||||
ok: false,
|
||||
status: null,
|
||||
ms: Date.now() - started,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const [command = 'check', ...rest] = process.argv.slice(2);
|
||||
const flag = (name) => rest.includes(name);
|
||||
const option = (name) => {
|
||||
const index = rest.indexOf(name);
|
||||
return index >= 0 ? rest[index + 1] : null;
|
||||
};
|
||||
|
||||
if (command === 'check') check(option('--base-ref'));
|
||||
else if (command === 'next') {
|
||||
const id = rest.find((arg) => !arg.startsWith('--'));
|
||||
if (!id) throw new Error('usage: npm run library:next -- <entry-id>');
|
||||
if (LIBRARY_LEDGER.some(([existing]) => existing === id))
|
||||
throw new Error(`${id} already owns a token`);
|
||||
console.log(nextLibraryToken(LIBRARY_LEDGER));
|
||||
} else if (command === 'docs') {
|
||||
const text = readFileSync(DATA_SOURCES, 'utf8');
|
||||
writeFileSync(DATA_SOURCES, replaceSection(text, libraryDocsSection()));
|
||||
console.log('DATA_SOURCES.md Data Library section updated.');
|
||||
} else if (command === 'probe') {
|
||||
const results = [];
|
||||
for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind !== 'pack'))
|
||||
results.push(await probeEntry(entry));
|
||||
if (flag('--json')) console.log(JSON.stringify(results, null, 2));
|
||||
else
|
||||
for (const result of results)
|
||||
console.log(
|
||||
`${result.ok ? 'ok ' : 'FAIL'} ${result.id} · ${result.status ?? '—'} · ${result.ms} ms${result.features !== undefined ? ` · ${result.features} features` : ''}${result.partitions !== undefined ? ` in the first of ${result.partitions} partitions` : ''}${result.error ? ` · ${result.error}` : ''}`,
|
||||
);
|
||||
if (flag('--strict') && results.some(({ ok }) => !ok)) process.exit(1);
|
||||
} else {
|
||||
console.error(`unknown command: ${command}`);
|
||||
process.exit(2);
|
||||
}
|
||||
+245
-171
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
|
||||
@@ -29,6 +30,7 @@ const browser = await puppeteer.launch({
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
const failures = [];
|
||||
const consoleErrors = [];
|
||||
const localHttpErrors = [];
|
||||
@@ -825,6 +827,7 @@ try {
|
||||
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
|
||||
// assertion when camera motion supersedes the manager's initial update.
|
||||
const readinessPage = await browser.newPage();
|
||||
await stubPlanespotters(readinessPage);
|
||||
let deferredInstallationReadiness;
|
||||
try {
|
||||
await readinessPage.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
@@ -77,6 +78,7 @@ const browser = await puppeteer.launch({
|
||||
|
||||
async function openPage({ aircraft = false } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||
|
||||
@@ -1,636 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Data Library live gate. Run against this candidate's dev or preview server:
|
||||
*
|
||||
* npm run qa:data-library -- --url http://localhost:4173
|
||||
*
|
||||
* Every upstream a library entry names is answered from fixtures in the
|
||||
* browser (EONET, the EIA ArcGIS pages, the GIBS tiles and the proxied
|
||||
* volcano route), so the gate is deterministic and offline apart from the
|
||||
* app itself. It drives the real panel and dialog: Browse opens a modal,
|
||||
* Add registers a layer under the Library heading and switches it on, the
|
||||
* share link carries `lib=`, a reload restores the selection, Escape and
|
||||
* clean view close the dialog, and Remove destroys the layer.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { default: puppeteer } = await import('puppeteer');
|
||||
const args = process.argv.slice(2);
|
||||
const urlIndex = args.indexOf('--url');
|
||||
const base = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
||||
|
||||
const point = (lon, lat, properties) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
properties,
|
||||
});
|
||||
const collection = (features, extra = {}) =>
|
||||
JSON.stringify({ type: 'FeatureCollection', features, ...extra });
|
||||
// 1×1 transparent PNG.
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
const seen = {
|
||||
eonet: 0,
|
||||
arcgisPages: [],
|
||||
tiles: 0,
|
||||
proxied: 0,
|
||||
structureBoxes: [],
|
||||
};
|
||||
function fixtureFor(url) {
|
||||
const cors = { 'Access-Control-Allow-Origin': '*' };
|
||||
if (url.hostname === 'eonet.gsfc.nasa.gov') {
|
||||
seen.eonet += 1;
|
||||
return {
|
||||
status: 200,
|
||||
headers: cors,
|
||||
contentType: 'application/json',
|
||||
body: collection([
|
||||
point(-120, 38, {
|
||||
title: 'Fixture Fire',
|
||||
date: '2026-10-20T00:00:00Z',
|
||||
}),
|
||||
point(140, 35, {
|
||||
title: 'Fixture Storm',
|
||||
date: '2026-10-19T00:00:00Z',
|
||||
}),
|
||||
]),
|
||||
};
|
||||
}
|
||||
if (
|
||||
url.hostname === 'services2.arcgis.com' &&
|
||||
url.pathname.includes('USA_Structures')
|
||||
) {
|
||||
seen.structureBoxes.push(url.searchParams.get('geometry'));
|
||||
const square = (lon, lat) => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + 0.0004, lat],
|
||||
[lon + 0.0004, lat + 0.0003],
|
||||
[lon, lat + 0.0003],
|
||||
[lon, lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
properties: { OCC_CLS: 'Government', HEIGHT: 30 },
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
headers: cors,
|
||||
contentType: 'application/json',
|
||||
body: collection([
|
||||
square(-77.035, 38.892),
|
||||
square(-77.03, 38.893),
|
||||
square(-77.025, 38.894),
|
||||
]),
|
||||
};
|
||||
}
|
||||
if (url.hostname === 'services2.arcgis.com') {
|
||||
const offset = Number(url.searchParams.get('resultOffset'));
|
||||
seen.arcgisPages.push(offset);
|
||||
const features =
|
||||
offset === 0
|
||||
? [
|
||||
point(-118.9, 47.9, { Plant_Name: 'Fixture Dam', Total_MW: 6809 }),
|
||||
point(-112.9, 33.4, {
|
||||
Plant_Name: 'Fixture Nuclear',
|
||||
Total_MW: 3937,
|
||||
}),
|
||||
]
|
||||
: [point(-97.6, 28.8, { Plant_Name: 'Fixture Coast', Total_MW: 2560 })];
|
||||
return {
|
||||
status: 200,
|
||||
headers: cors,
|
||||
contentType: 'application/json',
|
||||
body: collection(features, {
|
||||
properties: { exceededTransferLimit: offset === 0 },
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.hostname === 'gibs.earthdata.nasa.gov') {
|
||||
seen.tiles += 1;
|
||||
return { status: 200, headers: cors, contentType: 'image/png', body: PNG };
|
||||
}
|
||||
if (url.pathname === '/api/library/data/gvp-holocene-volcanoes') {
|
||||
seen.proxied += 1;
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: collection([
|
||||
point(-121.76, 46.85, { name: 'Fixture Volcano', __gevDetails: [] }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
|
||||
defaultViewport: { width: 1440, height: 900 },
|
||||
protocolTimeout: 300_000,
|
||||
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const errors = [];
|
||||
let count = 0;
|
||||
const step = async (label, fn) => {
|
||||
await fn();
|
||||
console.log(`ok ${++count} ${label}`);
|
||||
};
|
||||
|
||||
async function open(page, hash = '') {
|
||||
await page.goto(`${base}/${hash}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevDataLibrary?.library.ready &&
|
||||
window.__godsEyeView?.dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
}
|
||||
|
||||
const libraryRows = (page) =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll('#data-toggles [data-library-entry]')].map(
|
||||
(row) => ({
|
||||
id: row.dataset.libraryEntry,
|
||||
on: row.querySelector('.data-toggle-btn')?.classList.contains('active'),
|
||||
count: row.querySelector('.data-count')?.textContent,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && /Library|library/.test(message.text()))
|
||||
errors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => errors.push(String(error)));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const fixture = fixtureFor(new URL(request.url()));
|
||||
if (fixture) request.respond(fixture);
|
||||
else request.continue();
|
||||
});
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
try {
|
||||
// Start empty once; the reload step needs what this run stored.
|
||||
if (!sessionStorage.getItem('qa-data-library')) {
|
||||
localStorage.removeItem('gev:library:v1');
|
||||
sessionStorage.setItem('qa-data-library', '1');
|
||||
}
|
||||
// Skip the first-run chooser (src/firstRunExperience.js).
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
} catch {
|
||||
/* storage may be blocked; the gate still runs */
|
||||
}
|
||||
});
|
||||
await open(page);
|
||||
|
||||
await step(
|
||||
'the panel offers Browse and lists no library layer by default',
|
||||
async () => {
|
||||
assert.deepEqual(await libraryRows(page), []);
|
||||
const hidden = await page.$eval(
|
||||
'#data-library-browse',
|
||||
(node) => node.hidden,
|
||||
);
|
||||
assert.equal(hidden, false);
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'Browse opens a labelled modal with addable entries first and packs last',
|
||||
async () => {
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('data-panel')?.classList.remove('collapsed');
|
||||
document.getElementById('data-library-browse').click();
|
||||
});
|
||||
const dialog = await page.$eval('#data-library', (node) => ({
|
||||
open: node.open,
|
||||
modal: node.matches(':modal'),
|
||||
title: document.getElementById(node.getAttribute('aria-labelledby'))
|
||||
?.textContent,
|
||||
focus: document.activeElement?.type,
|
||||
order: [...node.querySelectorAll('.data-library-item')].map(
|
||||
(item) => item.dataset.libraryId,
|
||||
),
|
||||
}));
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(dialog.modal, true);
|
||||
assert.equal(dialog.title, 'DATA LIBRARY');
|
||||
assert.equal(dialog.focus, 'search');
|
||||
assert.ok(dialog.order.at(-1).startsWith('cctv-'), dialog.order.join());
|
||||
assert.ok(!dialog.order[0].startsWith('cctv-'));
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'a pack explains the operator switch instead of offering Add',
|
||||
async () => {
|
||||
const pack = await page.$eval(
|
||||
'#data-library .data-library-item[data-library-id="cctv-nyc-dot"]',
|
||||
(item) => ({
|
||||
action: Boolean(item.querySelector('.data-library-action')),
|
||||
reason: item.querySelector('.data-library-reason')?.textContent,
|
||||
}),
|
||||
);
|
||||
assert.equal(pack.action, false);
|
||||
assert.match(pack.reason, /CCTV_NYC_ENABLED=1/);
|
||||
},
|
||||
);
|
||||
|
||||
for (const [id, expected] of [
|
||||
['nasa-eonet-events', '2'],
|
||||
['eia-power-plants', '3'],
|
||||
['gvp-holocene-volcanoes', '1'],
|
||||
['nasa-black-marble', 'DRAPED'],
|
||||
])
|
||||
await step(
|
||||
`Add ${id} registers it under Library and switches it on`,
|
||||
async () => {
|
||||
await page.click(
|
||||
`#data-library button[data-library-id="${id}"][data-library-action="add"]`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(layerId, text) => {
|
||||
const row = document.querySelector(
|
||||
`#data-toggles [data-layer-id="${layerId}"]`,
|
||||
);
|
||||
return (
|
||||
row
|
||||
?.querySelector('.data-toggle-btn')
|
||||
?.classList.contains('active') &&
|
||||
row.querySelector('.data-count')?.textContent === text
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
`lib-${id}`,
|
||||
expected,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'the fixtures were reached the way each manifest says',
|
||||
async () => {
|
||||
assert.ok(seen.eonet >= 1);
|
||||
assert.deepEqual(seen.arcgisPages.slice(0, 2), [0, 2]);
|
||||
assert.ok(seen.proxied >= 1);
|
||||
const draped = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('lib-nasa-black-marble')
|
||||
.module.getDiagnostics().draped,
|
||||
);
|
||||
assert.equal(draped, true);
|
||||
// Imagery requests tiles on the following frames, not on enable.
|
||||
for (let waited = 0; seen.tiles === 0 && waited < 30_000; waited += 250)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
assert.ok(seen.tiles >= 1, 'the drape requested its tiles');
|
||||
const headings = await page.$$eval(
|
||||
'#data-toggles .data-layer-group-heading',
|
||||
(nodes) => nodes.map((node) => node.textContent),
|
||||
);
|
||||
assert.equal(headings.at(-1), 'Library');
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'the stats strip sits above the intro and counts what is on the map',
|
||||
async () => {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector(
|
||||
'[data-stat="features"] .data-library-stat-value',
|
||||
)?.textContent === '6',
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const strip = await page.evaluate(() => {
|
||||
const stats = document.querySelector(
|
||||
'#data-library .data-library-stats',
|
||||
);
|
||||
const read = (id) =>
|
||||
[...document.querySelectorAll(`[data-stat="${id}"] dd`)].map(
|
||||
(node) => node.textContent,
|
||||
);
|
||||
return {
|
||||
beforeIntro:
|
||||
stats?.nextElementSibling?.classList.contains('data-library-intro'),
|
||||
label: stats?.getAttribute('aria-label'),
|
||||
entries: read('entries'),
|
||||
added: read('added'),
|
||||
features: read('features'),
|
||||
};
|
||||
});
|
||||
assert.equal(strip.beforeIntro, true);
|
||||
assert.equal(strip.label, 'Data Library at a glance');
|
||||
// Counted from the shipped catalog, so a new entry does not break this.
|
||||
const kinds = await page.evaluate(() => {
|
||||
const counts = { dataset: 0, tiles: 0, pack: 0 };
|
||||
for (const { entry } of window.__gevDataLibrary.library.list())
|
||||
counts[entry.kind] += 1;
|
||||
return counts;
|
||||
});
|
||||
assert.deepEqual(strip.entries, [
|
||||
String(kinds.dataset + kinds.tiles + kinds.pack),
|
||||
`${kinds.dataset} datasets · ${kinds.tiles} tile layers · ${kinds.pack} packs`,
|
||||
]);
|
||||
assert.deepEqual(strip.added, ['4', '4 on the map']);
|
||||
// Fixtures: 2 EONET events, 3 power plants, 1 volcano.
|
||||
assert.deepEqual(strip.features, ['6', 'from 3 datasets']);
|
||||
},
|
||||
);
|
||||
|
||||
await step('the share link carries lib= with the ledger tokens', async () => {
|
||||
await page.waitForFunction(
|
||||
() => /(?:^|&)lib=00\.02\.01\.03(?:&|$)/.test(location.hash.slice(1)),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
});
|
||||
|
||||
await step(
|
||||
'Escape closes the dialog and returns focus to Browse',
|
||||
async () => {
|
||||
await page.focus('#data-library input[type="search"]');
|
||||
await page.keyboard.press('Escape');
|
||||
const state = await page.evaluate(() => ({
|
||||
open: document.getElementById('data-library').open,
|
||||
focus: document.activeElement?.id,
|
||||
}));
|
||||
assert.deepEqual(state, { open: false, focus: 'data-library-browse' });
|
||||
},
|
||||
);
|
||||
|
||||
await step('clean view closes an open dialog', async () => {
|
||||
const open = await page.evaluate(async () => {
|
||||
window.__gevDataLibrary.browser.open();
|
||||
document.body.classList.add('ui-clean-view');
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const result = document.getElementById('data-library').open;
|
||||
document.body.classList.remove('ui-clean-view');
|
||||
return result;
|
||||
});
|
||||
assert.equal(open, false);
|
||||
});
|
||||
|
||||
await step(
|
||||
'a reload restores the added entries and what was on',
|
||||
async () => {
|
||||
await page.evaluate(() => {
|
||||
document
|
||||
.querySelector(
|
||||
'#data-toggles [data-layer-id="lib-gvp-holocene-volcanoes"] .data-toggle-btn',
|
||||
)
|
||||
.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem('gev:library:v1')).on.length === 3,
|
||||
);
|
||||
await open(page, '');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll('#data-toggles [data-library-entry]')
|
||||
.length === 4,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
const rows = await libraryRows(page);
|
||||
assert.deepEqual(rows.map(({ id, on }) => [id, on]).sort(), [
|
||||
['eia-power-plants', true],
|
||||
['gvp-holocene-volcanoes', false],
|
||||
['nasa-black-marble', true],
|
||||
['nasa-eonet-events', true],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
const cameraCenter = () =>
|
||||
page.evaluate(() => {
|
||||
const { camera } = window.__godsEyeView.viewer;
|
||||
const carto = camera.positionCartographic;
|
||||
return {
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
height: carto.height,
|
||||
};
|
||||
});
|
||||
const waitForFlight = () =>
|
||||
page.waitForFunction(
|
||||
() => !window.__godsEyeView.viewer.camera._currentFlight,
|
||||
{ timeout: 30_000, polling: 200 },
|
||||
);
|
||||
|
||||
await step(
|
||||
'Zoom to layer on the row works while off: it switches on and flies',
|
||||
async () => {
|
||||
const chip =
|
||||
'#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="zoom"]';
|
||||
await page.evaluate(() =>
|
||||
document.getElementById('data-panel')?.classList.remove('collapsed'),
|
||||
);
|
||||
// The icons work while the layer is off; Zoom switches it back on.
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled(
|
||||
'lib-eia-power-plants',
|
||||
false,
|
||||
{ origin: 'user' },
|
||||
),
|
||||
);
|
||||
await page.waitForSelector(chip, { visible: true, timeout: 30_000 });
|
||||
await page.evaluate(
|
||||
(selector) => document.querySelector(selector).click(),
|
||||
chip,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.isEnabled('lib-eia-power-plants'),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await waitForFlight();
|
||||
const center = await cameraCenter();
|
||||
// Fixture plants span -118.9…-97.6 °E and 28.8…47.9 °N.
|
||||
assert.ok(center.lon > -125 && center.lon < -90, JSON.stringify(center));
|
||||
assert.ok(center.lat > 22 && center.lat < 55, JSON.stringify(center));
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'ZOOM in the dialog closes it and flies to that entry',
|
||||
async () => {
|
||||
await page.evaluate(() => window.__gevDataLibrary.browser.open());
|
||||
const zoom =
|
||||
'#data-library button[data-library-id="gvp-holocene-volcanoes"][data-library-action="zoom"]';
|
||||
assert.ok(await page.$(zoom), 'Zoom is offered for an entry that is off');
|
||||
await page.click(
|
||||
'#data-library button[data-library-id="nasa-eonet-events"][data-library-action="zoom"]',
|
||||
);
|
||||
const open = await page.$eval('#data-library', (node) => node.open);
|
||||
assert.equal(open, false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await waitForFlight();
|
||||
const center = await cameraCenter();
|
||||
// Fixture events at (-120, 38) and (140, 35): the frame sits between them.
|
||||
assert.ok(center.lat > 20 && center.lat < 55, JSON.stringify(center));
|
||||
},
|
||||
);
|
||||
|
||||
await step('Remove destroys the layer and forgets it', async () => {
|
||||
await page.evaluate(() => window.__gevDataLibrary.browser.open());
|
||||
await page.click(
|
||||
'#data-library button[data-library-id="nasa-black-marble"][data-library-action="remove"]',
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document.querySelector(
|
||||
'#data-toggles [data-layer-id="lib-nasa-black-marble"]',
|
||||
) &&
|
||||
!window.__godsEyeView.dataManager.layers.has('lib-nasa-black-marble'),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const stored = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('gev:library:v1')),
|
||||
);
|
||||
assert.equal(stored.added.includes('nasa-black-marble'), false);
|
||||
});
|
||||
|
||||
await step(
|
||||
'the row delete icon removes that entry, even while it is off',
|
||||
async () => {
|
||||
await page.evaluate(() => {
|
||||
window.__gevDataLibrary.browser.close();
|
||||
document.getElementById('data-panel')?.classList.remove('collapsed');
|
||||
});
|
||||
const chip =
|
||||
'#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="remove"]';
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled(
|
||||
'lib-eia-power-plants',
|
||||
false,
|
||||
{ origin: 'user' },
|
||||
),
|
||||
);
|
||||
await page.waitForSelector(chip, { visible: true, timeout: 30_000 });
|
||||
const label = await page.$eval(chip, (node) => ({
|
||||
name: node.getAttribute('aria-label'),
|
||||
glyph: node.textContent,
|
||||
pressed: node.getAttribute('aria-pressed'),
|
||||
}));
|
||||
assert.deepEqual(label, {
|
||||
name: 'Remove from Data Layers',
|
||||
glyph: 'delete',
|
||||
pressed: null,
|
||||
});
|
||||
await page.evaluate(
|
||||
(selector) => document.querySelector(selector).click(),
|
||||
chip,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!window.__godsEyeView.dataManager.layers.has(
|
||||
'lib-eia-power-plants',
|
||||
) &&
|
||||
!document.querySelector(
|
||||
'#data-toggles [data-layer-id="lib-eia-power-plants"]',
|
||||
),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await step(
|
||||
'a view-bounded layer asks to zoom in, then loads only what is in view',
|
||||
async () => {
|
||||
await page.evaluate(() =>
|
||||
window.__gevDataLibrary.library.add('fema-usa-structures'),
|
||||
);
|
||||
const row = '#data-toggles [data-layer-id="lib-fema-usa-structures"]';
|
||||
await page.waitForFunction(
|
||||
(selector) =>
|
||||
/zoom in to load this layer/.test(
|
||||
document.querySelector(`${selector} .data-toggle-meta`)
|
||||
?.textContent || '',
|
||||
),
|
||||
{ timeout: 30_000 },
|
||||
row,
|
||||
);
|
||||
assert.equal(seen.structureBoxes.length, 0, 'nothing loads from orbit');
|
||||
await page.evaluate(() =>
|
||||
window.__gevDataLibrary.library.zoom('fema-usa-structures'),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('lib-fema-usa-structures')
|
||||
.module.getDiagnostics().count === 3,
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
);
|
||||
const altitude = (await cameraCenter()).height;
|
||||
assert.ok(altitude <= 4000, `landed at ${Math.round(altitude)} m`);
|
||||
const [west, south, east, north] = seen.structureBoxes
|
||||
.at(-1)
|
||||
.split(',')
|
||||
.map(Number);
|
||||
assert.ok(east - west < 0.2 && north - south < 0.2, 'a view-sized box');
|
||||
assert.ok(
|
||||
west < -77.03 && east > -77.03 && south < 38.893 && north > 38.893,
|
||||
);
|
||||
// Climbing back out clears the buildings and shows the guidance again.
|
||||
await page.evaluate(() => {
|
||||
const { viewer, requestRender } = window.__godsEyeView;
|
||||
viewer.camera.zoomOut(60_000);
|
||||
requestRender?.('qa-data-library');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('lib-fema-usa-structures')
|
||||
.module.getDiagnostics().tooHigh,
|
||||
{ timeout: 30_000, polling: 250 },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await step('Remove all asks twice, then clears the library', async () => {
|
||||
await page.evaluate(() => window.__gevDataLibrary.browser.open());
|
||||
await page.click('#data-library .data-library-reset');
|
||||
const armed = await page.$eval(
|
||||
'#data-library .data-library-reset',
|
||||
(node) => ({
|
||||
text: node.textContent,
|
||||
armed: node.classList.contains('is-armed'),
|
||||
}),
|
||||
);
|
||||
assert.equal(armed.armed, true);
|
||||
assert.match(armed.text, /CONFIRM/);
|
||||
assert.ok(
|
||||
(await page.$$('#data-toggles [data-library-entry]')).length > 0,
|
||||
'one press removes nothing',
|
||||
);
|
||||
await page.click('#data-library .data-library-reset');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document.querySelector('#data-toggles [data-library-entry]') &&
|
||||
document.querySelector('#data-library .data-library-reset').hidden,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const stored = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('gev:library:v1')),
|
||||
);
|
||||
assert.deepEqual(stored, { added: [], on: [] });
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, [], 'no library errors on the page');
|
||||
console.log(`# ${count} passed`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -223,6 +224,7 @@ async function main() {
|
||||
const errors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
const { lon, lat } = CENTER;
|
||||
|
||||
@@ -21,6 +21,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback = null) => {
|
||||
@@ -651,6 +652,7 @@ async function main() {
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport(VIEWPORT);
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
||||
* seen from over the Indian Ocean no pin clearly behind the planet is
|
||||
* shown; back over North America the near-side pins show again.
|
||||
* (viii) AGE — seven days is the default; shorter/longer windows and All
|
||||
* markers update the globe, and the age choice survives a reload.
|
||||
*
|
||||
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
|
||||
*
|
||||
@@ -224,6 +226,77 @@ async function main() {
|
||||
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
|
||||
|
||||
const ageChips = await page.evaluate(
|
||||
(id) =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
`[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`,
|
||||
),
|
||||
].map((option) => ({
|
||||
id: option.dataset.chipId,
|
||||
active: option.getAttribute('aria-checked') === 'true',
|
||||
})),
|
||||
LAYER_ID,
|
||||
);
|
||||
record(
|
||||
'AGE: seven days is the default with an All markers option',
|
||||
live.ageDays === 7 &&
|
||||
ageChips.length === 6 &&
|
||||
ageChips
|
||||
.filter((chip) => chip.active)
|
||||
.map((chip) => chip.id)
|
||||
.join() === 'age-7' &&
|
||||
ageChips.some((chip) => chip.id === 'age-all'),
|
||||
);
|
||||
const selectAge = async (days) => {
|
||||
await showRow(page);
|
||||
const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`;
|
||||
await page.waitForSelector(selector, { timeout: 15000 });
|
||||
// Dispatch through the rendered button, matching the product-chip gate.
|
||||
await page.$eval(selector, (button) => button.click());
|
||||
await page.waitForFunction(
|
||||
(id, age) => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
.module.getStats();
|
||||
return s.ageDays === age && !s.loading;
|
||||
},
|
||||
{ timeout: TIMEOUT_MS },
|
||||
LAYER_ID,
|
||||
days,
|
||||
);
|
||||
return stats(page);
|
||||
};
|
||||
const all = await selectAge(null);
|
||||
record(
|
||||
'AGE: All markers restores old events without duplicate data sources',
|
||||
all.count > live.count &&
|
||||
(await sources(page)).length === live.products.length,
|
||||
`${live.count} → ${all.count}`,
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
await enableAndSettle(page);
|
||||
const persistedAge = await stats(page);
|
||||
record(
|
||||
'AGE: All markers survives a reload',
|
||||
persistedAge.ageDays === null && persistedAge.count === all.count,
|
||||
);
|
||||
const thirty = await selectAge(30);
|
||||
const one = await selectAge(1);
|
||||
const seven = await selectAge(7);
|
||||
record(
|
||||
'AGE: counts follow the window order and seven days restores the default',
|
||||
one.count <= live.count &&
|
||||
thirty.count >= live.count &&
|
||||
thirty.count <= all.count &&
|
||||
seven.count === live.count &&
|
||||
seven.ageDays === 7,
|
||||
`1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`,
|
||||
);
|
||||
|
||||
// ---- (ii) PICK ----------------------------------------------------------
|
||||
const target = await page.evaluate((id) => {
|
||||
const gev = window.__godsEyeView;
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as track-regression.mjs)
|
||||
@@ -466,6 +467,7 @@ async function main() {
|
||||
const failedResponses = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
|
||||
+12
-12
@@ -239,11 +239,11 @@ function satisfiesEngines(version, range) {
|
||||
* re-resolve the interpreter from PATH and hand the gate a different Node than
|
||||
* the one the check believes it selected.
|
||||
* @param {string} version e.g. "v24.19.0"
|
||||
* @returns {boolean} true only for a parseable Node 24.
|
||||
* @returns {boolean} true for a parseable supported Node 24 or 26.
|
||||
*/
|
||||
function isCalibratedAllocationRuntime(version) {
|
||||
const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10));
|
||||
return maj === 24;
|
||||
return maj === 24 || maj === 26;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -546,7 +546,7 @@ const check = (spec) => { CHECKS.push(spec); };
|
||||
|
||||
// ─── A · REPO GATES ───────────────────────────────────────────────────────
|
||||
check({
|
||||
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets are pinned to Node 24)',
|
||||
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)',
|
||||
run: async () => {
|
||||
const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8'));
|
||||
const range = pkg.engines?.node || '(unset)';
|
||||
@@ -557,7 +557,7 @@ check({
|
||||
if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`);
|
||||
// An off-range runtime is an environment problem, not a product defect —
|
||||
// but it silently disables the allocation gate, so say so loudly. A3 then
|
||||
// runs that gate under a discovered Node 24.
|
||||
// runs that gate under a discovered calibrated Node 24 fallback.
|
||||
return env.node24
|
||||
? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV')
|
||||
: fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`);
|
||||
@@ -579,13 +579,13 @@ check({
|
||||
});
|
||||
|
||||
check({
|
||||
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE (they silently skip off Node 24)',
|
||||
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors',
|
||||
run: async () => {
|
||||
// npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still
|
||||
// exits 0 when the runtime is not Node 24. A green suite is therefore NOT
|
||||
// proof the gate ran. Force it, under Node 24 when one is discoverable.
|
||||
// exits 0 when the runtime is outside the calibrated supported majors. A green
|
||||
// suite is therefore NOT proof the gate ran. Force it under a calibrated runtime.
|
||||
//
|
||||
// ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm
|
||||
// ALWAYS invoke the calibrated Node binary directly — never `npm test`. npm
|
||||
// re-resolves the interpreter from PATH and can land back on the system
|
||||
// Node even when THIS process is already 24: running the whole matrix under
|
||||
// `mise exec [email protected] --`, the npm shell-out still re-execed system
|
||||
@@ -597,10 +597,10 @@ check({
|
||||
const v = await sh(bin, ['--version'], { timeoutMs: 60000 });
|
||||
const version = (v.out || '').trim();
|
||||
if (!isCalibratedAllocationRuntime(version)) {
|
||||
// run-unit-tests.mjs refuses to measure calibrated budgets off Node 24,
|
||||
// run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors,
|
||||
// so a wrong binary means the gate never ran. That is this check failing
|
||||
// to establish its own claim — not evidence about the product.
|
||||
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 — the gate would refuse or silently skip, so this check verified nothing`);
|
||||
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 or 26 — the gate would refuse or silently skip, so this check verified nothing`);
|
||||
}
|
||||
const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], {
|
||||
timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' },
|
||||
@@ -611,9 +611,9 @@ check({
|
||||
};
|
||||
|
||||
const [maj] = process.versions.node.split('.').map(Number);
|
||||
if (maj === 24) return runGate(process.execPath, 'this runtime');
|
||||
if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime');
|
||||
if (env.node24) return runGate(env.node24.bin, env.node24.label);
|
||||
return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN');
|
||||
return skip(`no calibrated Node 24/26 runtime found (running ${process.versions.node}); the gate SKIPS silently — install a supported Node runtime and re-run`, 'OWNER-RUN');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Keep browser QA off third-party aircraft photo services.
|
||||
*
|
||||
* Tracking a civil flight asks Planespotters for the aircraft's photo. QA
|
||||
* runs track synthetic hexes, so a real request only adds a third-party
|
||||
* dependency and, when the service refuses a headless browser, a console
|
||||
* error that fails the "no console errors" checks. Every photo lookup is
|
||||
* answered with no photos instead, without leaving the browser.
|
||||
*/
|
||||
|
||||
/** Origin of the aircraft photo lookup. */
|
||||
export const PLANESPOTTERS_ORIGIN = 'https://api.planespotters.net';
|
||||
|
||||
/** The answer every stubbed lookup gets: no photos for this aircraft. */
|
||||
export const PLANESPOTTERS_EMPTY_BODY = JSON.stringify({ photos: [] });
|
||||
|
||||
/** Whether a URL is a photo lookup the stub answers. */
|
||||
export function isPlanespottersRequest(url) {
|
||||
try {
|
||||
return new URL(String(url)).origin === PLANESPOTTERS_ORIGIN;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer an intercepted photo lookup from a page that already uses request
|
||||
* interception. Returns false for any other request, which the caller
|
||||
* handles as before.
|
||||
* @param {import('puppeteer').HTTPRequest} request
|
||||
* @returns {boolean} Whether the request was answered.
|
||||
*/
|
||||
export function respondToPlanespotters(request) {
|
||||
if (!isPlanespottersRequest(request.url())) return false;
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: PLANESPOTTERS_EMPTY_BODY,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub photo lookups in a page that does not intercept requests: wraps
|
||||
* `fetch` before any app code runs, so a lookup never reaches the network.
|
||||
* Composes with other `fetch` shims in either order.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function stubPlanespotters(page) {
|
||||
await page.evaluateOnNewDocument(
|
||||
(origin, body) => {
|
||||
const original = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const href =
|
||||
typeof input === 'string' ? input : input?.url || String(input);
|
||||
let target = '';
|
||||
try {
|
||||
target = new URL(href, location.href).origin;
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
if (target === origin) {
|
||||
return Promise.resolve(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
},
|
||||
PLANESPOTTERS_ORIGIN,
|
||||
PLANESPOTTERS_EMPTY_BODY,
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
|
||||
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
|
||||
@@ -186,6 +187,7 @@ async function main() {
|
||||
const httpErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await stubPlanespotters(page);
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
|
||||
@@ -296,6 +296,22 @@ async function expandPanel(page) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A keyless bundle opens on the first keyless source with live controls. */
|
||||
async function assertKeylessDefault(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await uiUntil(page, litAlone, 'kartaview');
|
||||
const shown = await page.evaluate(() => ({
|
||||
status: document.getElementById('sl-status').textContent,
|
||||
disabled: document.getElementById('sl-controls').disabled,
|
||||
}));
|
||||
assert.notEqual(shown.status, 'KEY REQUIRED');
|
||||
assert.equal(shown.disabled, false, 'controls stay live');
|
||||
}
|
||||
|
||||
async function assertKeyRequired(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
@@ -528,11 +544,25 @@ async function main() {
|
||||
);
|
||||
|
||||
await expandPanel(page);
|
||||
if (!configured) {
|
||||
if (!configured && litAlone(await getUI(page), 'mapillary')) {
|
||||
// A keyed bundle behind a server without the token keeps Mapillary.
|
||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||||
assertKeyRequired(page),
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else if (!configured) {
|
||||
await step('keyless server: opens on KartaView, not KEY REQUIRED', () =>
|
||||
assertKeylessDefault(page),
|
||||
);
|
||||
await step(
|
||||
'keyless server: the MAPILLARY chip explains its key instead of switching',
|
||||
async () => {
|
||||
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
assert.ok(litAlone(await getUI(page), 'kartaview'));
|
||||
},
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else {
|
||||
await park(page);
|
||||
await step(
|
||||
@@ -751,6 +781,8 @@ async function main() {
|
||||
await park(page);
|
||||
|
||||
await fixtureStep(
|
||||
// The fixture bundle carries a token, so this covers a keyless
|
||||
// server behind a keyed bundle; the keyless default is checked above.
|
||||
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||
async () => {
|
||||
const keyless = await open(
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Trail freeze gate: freezing a trail chunk never shows a blank frame.
|
||||
*
|
||||
* A tracked aircraft's trail is cut into chunks; every chunk but the newest
|
||||
* is frozen into static geometry, which Cesium builds asynchronously. Until
|
||||
* that geometry is in the scene, the chunk keeps being drawn dynamically.
|
||||
*
|
||||
* This gate drives the real app and a real Cesium scene: it draws a trail of
|
||||
* CHUNK_POINTS points (all dynamic), then adds one point so the first chunk
|
||||
* freezes, and on every rendered frame after that it checks two things:
|
||||
* 1. every frozen chunk is either still drawn by a dynamic polyline or its
|
||||
* static geometry is in the scene (DataSourceDisplay reports DONE);
|
||||
* 2. the number of trail-coloured pixels never drops below 90% of the
|
||||
* count measured just before the freeze.
|
||||
* It also checks that clearing the trail releases every chunk entity.
|
||||
*
|
||||
* The camera is held still while the gate runs.
|
||||
*
|
||||
* Needs a running dev server:
|
||||
* node scripts/qa-trail-freeze.mjs [--url http://localhost:4173]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||
|
||||
const urlFlag = process.argv.indexOf('--url');
|
||||
const APP_URL =
|
||||
urlFlag > 0 ? process.argv[urlFlag + 1] : 'http://localhost:4173';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
executablePath: chromeExecutablePath(),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=960,640',
|
||||
],
|
||||
});
|
||||
|
||||
let failed = false;
|
||||
const report = (name, ok, detail) => {
|
||||
if (!ok) failed = true;
|
||||
console.log(
|
||||
` [${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 960, height: 640 });
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.viewer &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
// The exact Cesium module the renderer uses, so classes are shared.
|
||||
const source = await (await fetch('/src/data/trailRenderer.js')).text();
|
||||
const cesiumUrl = source.match(/from\s+["']([^"']*cesium[^"']*)["']/)[1];
|
||||
const Cesium = await import(cesiumUrl);
|
||||
const { createTrail, CHUNK_POINTS } =
|
||||
await import('/src/data/trailRenderer.js');
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const scene = viewer.scene;
|
||||
const nextFrame = () =>
|
||||
new Promise((resolve) => {
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
|
||||
// A line across the middle of the current view, in mid-air in front of
|
||||
// the camera, so terrain and buildings never hide it.
|
||||
viewer.camera.cancelFlight?.();
|
||||
const camera = viewer.camera;
|
||||
const ahead = Cesium.Cartesian3.add(
|
||||
camera.positionWC,
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.directionWC,
|
||||
3_000,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const points = Array.from({ length: CHUNK_POINTS + 1 }, (_, i) =>
|
||||
Cesium.Cartesian3.add(
|
||||
ahead,
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.rightWC,
|
||||
-1_200 + (2_400 * i) / CHUNK_POINTS,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
);
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const add = viewer.entities.add.bind(viewer.entities);
|
||||
const remove = viewer.entities.remove.bind(viewer.entities);
|
||||
viewer.entities.add = (definition) => {
|
||||
const entity = add(definition);
|
||||
if (String(entity.id).startsWith('gev-trail:')) added.push(entity);
|
||||
return entity;
|
||||
};
|
||||
viewer.entities.remove = (entity) => {
|
||||
if (String(entity?.id).startsWith('gev-trail:')) removed.push(entity);
|
||||
return remove(entity);
|
||||
};
|
||||
|
||||
const gl = scene.context._gl;
|
||||
const width = gl.drawingBufferWidth;
|
||||
const height = gl.drawingBufferHeight;
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
// Magenta trail: count pixels that are clearly magenta.
|
||||
const countTrailPixels = () => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let count = 0;
|
||||
for (let p = 0; p < pixels.length; p += 4) {
|
||||
const r = pixels[p];
|
||||
const g = pixels[p + 1];
|
||||
const b = pixels[p + 2];
|
||||
if (r > 150 && b > 150 && g < 110) count++;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const trail = createTrail(viewer, { color: '#ff00ff', width: 8 });
|
||||
trail.setPositions(points.slice(0, CHUNK_POINTS));
|
||||
for (let i = 0; i < 20; i++) await nextFrame();
|
||||
const before = trail._chunksForTest();
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let lit = 0;
|
||||
for (let p = 0; p < pixels.length; p += 4)
|
||||
if (pixels[p] || pixels[p + 1] || pixels[p + 2]) lit++;
|
||||
const cam = viewer.camera.positionCartographic;
|
||||
const diagnostics = {
|
||||
lit,
|
||||
size: [width, height],
|
||||
camera: [
|
||||
Cesium.Math.toDegrees(cam.longitude),
|
||||
Cesium.Math.toDegrees(cam.latitude),
|
||||
cam.height,
|
||||
],
|
||||
preserveDrawingBuffer: gl.getContextAttributes().preserveDrawingBuffer,
|
||||
};
|
||||
let baseline = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await nextFrame();
|
||||
baseline = Math.max(baseline, countTrailPixels());
|
||||
}
|
||||
|
||||
trail.setPositions(points);
|
||||
const frames = [];
|
||||
const sphere = new Cesium.BoundingSphere();
|
||||
let resolvedAt = null;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await nextFrame();
|
||||
const state = trail._chunksForTest();
|
||||
const covered = state.frozen.every(
|
||||
(chunk) =>
|
||||
chunk.standIn ||
|
||||
viewer.dataSourceDisplay.getBoundingSphere(
|
||||
chunk.entity,
|
||||
false,
|
||||
sphere,
|
||||
) === Cesium.BoundingSphereState.DONE,
|
||||
);
|
||||
frames.push({ covered, pixels: countTrailPixels() });
|
||||
if (resolvedAt == null && state.frozen.every((c) => !c.standIn))
|
||||
resolvedAt = i;
|
||||
if (resolvedAt != null && i > resolvedAt + 30) break;
|
||||
}
|
||||
|
||||
trail.clear();
|
||||
for (let i = 0; i < 3; i++) await nextFrame();
|
||||
const frozenAfterClear = trail._chunksForTest().frozen.length;
|
||||
trail.destroy();
|
||||
const leaked = added.filter((entity) => !removed.includes(entity)).length;
|
||||
viewer.entities.add = add;
|
||||
viewer.entities.remove = remove;
|
||||
return {
|
||||
chunkPoints: CHUNK_POINTS,
|
||||
diagnostics,
|
||||
frozenBefore: before.frozen.length,
|
||||
baseline,
|
||||
frames,
|
||||
resolvedAt,
|
||||
frozenAfterClear,
|
||||
entities: added.length,
|
||||
leaked,
|
||||
};
|
||||
});
|
||||
|
||||
if (process.env.GEV_TEST_VERBOSE)
|
||||
console.log(JSON.stringify(result.diagnostics));
|
||||
const minPixels = Math.min(...result.frames.map((frame) => frame.pixels));
|
||||
report(
|
||||
'the trail draws before the freeze',
|
||||
result.baseline > 200 && result.frozenBefore === 0,
|
||||
`baseline=${result.baseline} trail pixels, frozen chunks=${result.frozenBefore}`,
|
||||
);
|
||||
report(
|
||||
'the frozen chunk reaches the scene as static geometry',
|
||||
result.resolvedAt != null,
|
||||
`stand-in dropped after ${result.resolvedAt} frames`,
|
||||
);
|
||||
report(
|
||||
'every frame after the freeze draws the frozen chunk (dynamic copy or static geometry)',
|
||||
result.frames.every((frame) => frame.covered),
|
||||
`${result.frames.length} frames checked`,
|
||||
);
|
||||
report(
|
||||
'no blank frame: trail pixels never drop below 90% of the pre-freeze count',
|
||||
minPixels >= 0.9 * result.baseline,
|
||||
`min=${minPixels} baseline=${result.baseline}`,
|
||||
);
|
||||
report(
|
||||
'clearing and destroying release every trail entity',
|
||||
result.frozenAfterClear === 0 && result.leaked === 0,
|
||||
`entities created=${result.entities} leaked=${result.leaked}`,
|
||||
);
|
||||
} catch (error) {
|
||||
report('gate ran', false, error.message);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(failed ? 'RESULT: FAIL' : 'RESULT: PASS');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -801,7 +801,7 @@ try {
|
||||
);
|
||||
check(
|
||||
`${preset.name}: the icon raster cache stays bounded`,
|
||||
styling.iconCacheSize <= 6 * 2 * 3,
|
||||
styling.iconCacheSize <= 6 * 2 * 3 * 2,
|
||||
`${styling.iconCacheSize} rasters`,
|
||||
);
|
||||
}
|
||||
|
||||
+11
-11
@@ -8,15 +8,16 @@ export const ALLOCATION_TEST_FILES = Object.freeze([
|
||||
'src/overlays/worldOverlayAllocation.test.mjs',
|
||||
]);
|
||||
|
||||
/** Whether this runtime matches the one the allocation budgets were calibrated on. */
|
||||
/** Whether this runtime matches a supported major the allocation budgets are calibrated on. */
|
||||
export function isCalibratedAllocationRuntime(version = process.versions.node) {
|
||||
return Number.parseInt(String(version).split('.')[0], 10) === 24;
|
||||
const major = Number.parseInt(String(version).split('.')[0], 10);
|
||||
return major === 24 || major === 26;
|
||||
}
|
||||
|
||||
/** Require the runtime on which allocation budgets were calibrated. */
|
||||
export function assertNode24AllocationRuntime(version = process.versions.node) {
|
||||
/** Require a runtime on which allocation budgets are calibrated. */
|
||||
export function assertCalibratedAllocationRuntime(version = process.versions.node) {
|
||||
if (!isCalibratedAllocationRuntime(version)) {
|
||||
throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`);
|
||||
throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
@@ -92,19 +93,18 @@ export function runUnitTests() {
|
||||
const parallelStatus = runTests(['--test', ...plan.parallel]);
|
||||
if (parallelStatus !== 0) return parallelStatus;
|
||||
|
||||
// The GC-bracketed budgets are calibrated on Node 24 and are meaningless on
|
||||
// other allocators. A contributor's suite must stay green on any supported
|
||||
// engine (package.json permits >=24), so uncalibrated runtimes skip the
|
||||
// The GC-bracketed budgets are calibrated on every supported Node major.
|
||||
// Unsupported runtimes may still run ordinary tests, so uncalibrated runtimes skip the
|
||||
// probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI /
|
||||
// release batteries) to make an uncalibrated runtime a hard failure.
|
||||
if (!isCalibratedAllocationRuntime()) {
|
||||
if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') {
|
||||
assertNode24AllocationRuntime();
|
||||
assertCalibratedAllocationRuntime();
|
||||
}
|
||||
console.warn(
|
||||
`[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: `
|
||||
+ `budgets are calibrated for Node 24, running ${process.versions.node}. `
|
||||
+ 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
|
||||
+ `budgets are calibrated for Node 24 and 26, running ${process.versions.node}. `
|
||||
+ 'Run under Node 24 or 26 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
+165
-21
@@ -42,8 +42,9 @@
|
||||
* and a real mouse click on the tracked plane is a
|
||||
* NO-OP (no deselect, no camera spike).
|
||||
* M3. AGE-OUT RELEASE — when the tracked plane's fixes stop arriving
|
||||
* (3 missed polls via the shim), tracking clears and
|
||||
* the camera is RELEASED IN PLACE: viewer.trackedEntity
|
||||
* it stays tracked (STALE, then SIGNAL LOST) until
|
||||
* 10 min after its newest fix; then tracking clears
|
||||
* and the camera is RELEASED IN PLACE: viewer.trackedEntity
|
||||
* undefined, NO jump (owner decision 2026-07-02 —
|
||||
* the old ~80 km overview flyTo is gone).
|
||||
*
|
||||
@@ -94,6 +95,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { respondToPlanespotters } from './qa-photo-stub.mjs';
|
||||
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
@@ -287,6 +289,8 @@ async function main() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Aircraft photo lookups never reach the third party: no photos.
|
||||
if (respondToPlanespotters(request)) return;
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
@@ -359,7 +363,12 @@ async function main() {
|
||||
'Synthetica', // 2 origin_country
|
||||
Math.floor(Date.now() / 1000), // 3 time_position
|
||||
Math.floor(Date.now() / 1000), // 4 last_contact
|
||||
f.lon, // 5 longitude
|
||||
// A plane given `moveFromMs` flies east at its reported speed
|
||||
// (the trail-head check needs a moving icon); others hold still.
|
||||
Number.isFinite(f.moveFromMs)
|
||||
? f.lon + (f.vel * (Date.now() - f.moveFromMs)) / 1000
|
||||
/ (111320 * Math.cos((f.lat * Math.PI) / 180))
|
||||
: f.lon, // 5 longitude
|
||||
f.lat, // 6 latitude
|
||||
f.alt, // 7 baro_altitude (m)
|
||||
f.onGround === true, // 8 on_ground (ground-traffic phase flips this live)
|
||||
@@ -409,6 +418,15 @@ async function main() {
|
||||
status: 'ready',
|
||||
}));
|
||||
}
|
||||
// CelesTrak SATCAT, which the satellite classes read beside the TLEs.
|
||||
// An empty CSV (header only) keeps the run hermetic: the classes fall
|
||||
// back to names and groups, as they do when SATCAT is unavailable.
|
||||
if (isAppRequest && url.pathname.startsWith('/api/celestrak-satcat/')) {
|
||||
return Promise.resolve(new Response(
|
||||
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,LAUNCH_DATE\n',
|
||||
{ status: 200, headers: { 'Content-Type': 'text/csv' } },
|
||||
));
|
||||
}
|
||||
// CelesTrak TLE groups. Served from a fixed two-satellite catalog so
|
||||
// the satellite assertions are deterministic and the run never depends
|
||||
// on an upstream that rate-limits (it was answering 403 the night this
|
||||
@@ -1486,6 +1504,110 @@ async function main() {
|
||||
`max=${maxDelta.toFixed(3)} m over ${samples.length} frames (tol ${JITTER_TOL_M} m, mode=${modelUp ? 'model-matrix' : 'display-contract'})`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TRAIL HEAD ENDS AT THE DRAWN ICON, every rendered frame.
|
||||
// The head's last vertex is where the tracked plane is drawn (the
|
||||
// display position, or the model's tail anchor while the model is the
|
||||
// visual). getTrackedInfo reports the dead-reckoned position without
|
||||
// the brief post-report easing; its gap is logged, not asserted.
|
||||
// ============================================================
|
||||
console.log('\nTrail head — ends at the drawn icon on every frame');
|
||||
// The fixture planes repeat one position, so the icon holds and draws no
|
||||
// head. Fly the tracked one east for this check; the display runs 30 s
|
||||
// behind, so give it time to reach the moving reports.
|
||||
await evalPage((icao) => {
|
||||
const f = window.__SYNTH.flights.find((p) => p.icao === icao);
|
||||
f.moveFromMs = Date.now();
|
||||
}, trackedIcao);
|
||||
await sleep(55_000);
|
||||
const headSamples = await sampleFrames(page, 40, (icao) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const ent = viewer.trackedEntity;
|
||||
const time = viewer.clock.currentTime;
|
||||
const head = viewer.entities.values.find((e) => String(e.id).startsWith('gev-trail:fl-head-'));
|
||||
const line = head?.polyline?.positions?.getValue(time);
|
||||
const drawn = ent?.position?.getValue(time);
|
||||
if (!line?.length || !drawn) return null;
|
||||
const end = line[line.length - 1];
|
||||
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||
const ti = fl.getTrackedInfo();
|
||||
let infoGap = null;
|
||||
if (ti && ti.icao24 === icao) {
|
||||
// WGS84 lat/lon → ECEF on the ellipsoid; the angle at the centre to
|
||||
// the drawn position, times the mean radius, is the ground gap.
|
||||
const a = 6378137, e2 = 6.69437999014e-3;
|
||||
const lat = (ti.latitude * Math.PI) / 180, lon = (ti.longitude * Math.PI) / 180;
|
||||
const n = a / Math.sqrt(1 - e2 * Math.sin(lat) ** 2);
|
||||
const p = {
|
||||
x: n * Math.cos(lat) * Math.cos(lon),
|
||||
y: n * Math.cos(lat) * Math.sin(lon),
|
||||
z: n * (1 - e2) * Math.sin(lat),
|
||||
};
|
||||
const dot = p.x * drawn.x + p.y * drawn.y + p.z * drawn.z;
|
||||
const cos = dot / (Math.hypot(p.x, p.y, p.z) * Math.hypot(drawn.x, drawn.y, drawn.z));
|
||||
infoGap = 6371008.8 * Math.acos(Math.min(1, cos));
|
||||
}
|
||||
// While the 3D model is the visual the head ends at the model's tail
|
||||
// anchor, which sits on the drawn (pixel-size-enlarged) model.
|
||||
const model = window.__findTrackedModel ? window.__findTrackedModel() : null;
|
||||
const sphere = model && model.ready && model.show ? model.boundingSphere : null;
|
||||
// boundingSphere leaves out the minimum-pixel-size enlargement that
|
||||
// computedScale carries; the drawn model is that much larger.
|
||||
const enlarge = sphere && Number.isFinite(model.computedScale) && model.scale > 0
|
||||
? Math.max(1, model.computedScale / model.scale)
|
||||
: 1;
|
||||
const radius = sphere ? sphere.radius * enlarge : null;
|
||||
return {
|
||||
end: dist(end, drawn),
|
||||
onModel: sphere ? dist(end, sphere.center) <= radius + 50 : false,
|
||||
modelRadius: radius,
|
||||
infoGap,
|
||||
vertices: line.length,
|
||||
};
|
||||
}, trackedIcao);
|
||||
// The same reading between frames, the way a page script polls it: the
|
||||
// head holds the last rendered frame while getTrackedInfo dead-reckons
|
||||
// the current instant. Logged only, to tell the two apart.
|
||||
const betweenFrames = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await sleep(600);
|
||||
betweenFrames.push(await evalPage((icao) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const head = viewer.entities.values.find((e) => String(e.id).startsWith('gev-trail:fl-head-'));
|
||||
const line = head?.polyline?.positions?.getValue(viewer.clock.currentTime);
|
||||
const drawn = viewer.trackedEntity?.position?.getValue(viewer.clock.currentTime);
|
||||
if (!line?.length || !drawn) return null;
|
||||
const end = line[line.length - 1];
|
||||
return Math.hypot(end.x - drawn.x, end.y - drawn.y, end.z - drawn.z);
|
||||
}, trackedIcao));
|
||||
}
|
||||
console.log(` (between frames: head end vs entity position, m: ${
|
||||
betweenFrames.map((v) => (v == null ? '-' : v.toFixed(1))).join(' ')})`);
|
||||
// Freeze the plane where it got to, so later checks see a still contact.
|
||||
await evalPage((icao) => {
|
||||
const f = window.__SYNTH.flights.find((p) => p.icao === icao);
|
||||
f.lon += (f.vel * (Date.now() - f.moveFromMs)) / 1000
|
||||
/ (111320 * Math.cos((f.lat * Math.PI) / 180));
|
||||
delete f.moveFromMs;
|
||||
}, trackedIcao);
|
||||
const headUsable = headSamples.values.filter((v) => v != null);
|
||||
if (headUsable.length < 10) {
|
||||
skip('trail head: ends at the drawn icon on every frame',
|
||||
`only ${headUsable.length} frames with a drawn head (timedOut=${headSamples.timedOut})`);
|
||||
} else {
|
||||
const worst = Math.max(...headUsable.map((v) => v.end));
|
||||
const off = headUsable.filter((v) => !(v.end <= 50 || v.onModel));
|
||||
const onModel = headUsable.filter((v) => v.end > 50 && v.onModel).length;
|
||||
const radii = headUsable.map((v) => v.modelRadius).filter((v) => v != null);
|
||||
const gaps = headUsable.map((v) => v.infoGap).filter((v) => v != null);
|
||||
record('trail head: last vertex on the drawn icon (within 50 m, or on the drawn 3D model) on every frame',
|
||||
off.length === 0,
|
||||
`${off.length} of ${headUsable.length} frames off; max end-to-centre=${worst.toFixed(1)} m, ${onModel} frames ending on the model's tail (model radius ${
|
||||
radii.length ? Math.max(...radii).toFixed(0) : 'n/a'} m); getTrackedInfo vs drawn icon max=${
|
||||
gaps.length ? Math.max(...gaps).toFixed(1) : 'n/a'} m (logged only)`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INVARIANT 2: NO PULL-OUT on a track switch
|
||||
// tracking B while tracking A must not fly to ~80 km overview.
|
||||
@@ -1732,7 +1854,8 @@ async function main() {
|
||||
|
||||
// ============================================================
|
||||
// REGRESSION M3 (rewritten 2026-07-02 — deliberate behavior change):
|
||||
// when the TRACKED plane ages out (3 missed polls), tracking must clear
|
||||
// when the TRACKED plane ages out (10 min after its newest fix, past the
|
||||
// 3 missed polls that remove an untracked plane), tracking must clear
|
||||
// and the camera must be RELEASED IN PLACE — viewer.trackedEntity
|
||||
// undefined and NO jump (the old contract flew an ~80 km overview; the
|
||||
// owner field-ruled that wrong: "it randomly zooms way up and loses my
|
||||
@@ -1753,27 +1876,48 @@ async function main() {
|
||||
const beforeTracking = !!fl.getTrackedInfo();
|
||||
// Simulate the age-out via the shim: the plane stops arriving.
|
||||
window.__SYNTH.flights = window.__SYNTH.flights.filter((f) => f.icao !== icao);
|
||||
// MISSING_POLL_LIMIT = 3 consecutive absent polls → removal + clear.
|
||||
// Capture the camera pose right BEFORE the removing poll — the follow
|
||||
// camera drifts with the coasting plane until the instant of release,
|
||||
// so this is the "where the user was looking" reference.
|
||||
// The tracked plane outlives MISSING_POLL_LIMIT (3 absent polls): it is
|
||||
// kept until 10 min after its newest fix, so the camera keeps its target.
|
||||
await fl.update(v);
|
||||
await fl.update(v);
|
||||
const c0 = v.camera.positionCartographic;
|
||||
const atAgeOut = {
|
||||
lonDeg: (c0.longitude * 180) / Math.PI,
|
||||
latDeg: (c0.latitude * 180) / Math.PI,
|
||||
height: c0.height,
|
||||
};
|
||||
await fl.update(v); // third miss → removal + clear + in-place release
|
||||
return {
|
||||
beforeTracking,
|
||||
atAgeOut,
|
||||
stillTrackedAfter: !!fl.getTrackedInfo(),
|
||||
viewerStillTracking: !!v.trackedEntity,
|
||||
};
|
||||
await fl.update(v);
|
||||
const trackedThroughMisses = !!fl.getTrackedInfo();
|
||||
// Jump the page clock past the 10 min retention instead of waiting.
|
||||
const realNow = Date.now;
|
||||
Date.now = () => realNow() + 11 * 60 * 1000;
|
||||
try {
|
||||
// Let the follow camera settle on the stopped plane, then capture the
|
||||
// pose right BEFORE the removing poll: the "where the user was
|
||||
// looking" reference.
|
||||
await new Promise((res) => {
|
||||
const stop = v.scene.postRender.addEventListener(() => {
|
||||
stop();
|
||||
res();
|
||||
});
|
||||
v.scene.requestRender();
|
||||
});
|
||||
const c0 = v.camera.positionCartographic;
|
||||
const atAgeOut = {
|
||||
lonDeg: (c0.longitude * 180) / Math.PI,
|
||||
latDeg: (c0.latitude * 180) / Math.PI,
|
||||
height: c0.height,
|
||||
};
|
||||
await fl.update(v); // past retention → removal + clear + in-place release
|
||||
return {
|
||||
beforeTracking,
|
||||
trackedThroughMisses,
|
||||
atAgeOut,
|
||||
stillTrackedAfter: !!fl.getTrackedInfo(),
|
||||
viewerStillTracking: !!v.trackedEntity,
|
||||
};
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
}, m3Icao);
|
||||
|
||||
record('M3: the tracked plane stays tracked through 3 missed polls (kept 10 min after its newest fix)',
|
||||
m3.trackedThroughMisses,
|
||||
`trackedThroughMisses=${m3.trackedThroughMisses}`);
|
||||
record('M3: tracking cleared after the tracked plane ages out (getTrackedInfo null)',
|
||||
m3.beforeTracking && !m3.stillTrackedAfter,
|
||||
`before=${m3.beforeTracking} trackedAfter=${m3.stillTrackedAfter}`);
|
||||
|
||||
@@ -83,6 +83,15 @@ export function adsbLolProxy() {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
// After the /track hand-off, so /track keeps its own method handling.
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, {
|
||||
'Content-Type': 'application/json',
|
||||
Allow: 'GET',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (_cache && now - _cacheAt < CACHE_MS) {
|
||||
serve(res, 200, _cache, 'HIT');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js';
|
||||
import { encodedBody, gzipBody } from '../common/compress.js';
|
||||
import {
|
||||
coalesceProxyRequest,
|
||||
readResponseJsonCapped,
|
||||
@@ -18,6 +19,11 @@ let _openskyTokenExpiry = 0;
|
||||
let _openskyTokenPromise = null;
|
||||
/** @type {string|null} Cached upstream response body (JSON text). */
|
||||
let _openskyCacheBody = null;
|
||||
/**
|
||||
* @type {Buffer|null} Gzip of `_openskyCacheBody`, written together with it.
|
||||
* Null when the body is not worth compressing.
|
||||
*/
|
||||
let _openskyCacheGzip = null;
|
||||
/** @type {number} HTTP status of the cached response. */
|
||||
let _openskyCacheStatus = 0;
|
||||
/** @type {number} Epoch-ms when the response was cached. */
|
||||
@@ -399,8 +405,12 @@ async function fetchAdsbLolPointFallback(req) {
|
||||
ADSBLOL_POINT_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
const normalized = normalizeAdsbLolPointResponse(payload);
|
||||
const body = JSON.stringify(normalized);
|
||||
const record = {
|
||||
body: JSON.stringify(normalized),
|
||||
body,
|
||||
// Compressed once per entry; ADSBLOL_POINT_CACHE_MAX bounds the
|
||||
// extra memory.
|
||||
gzip: await gzipBody(body),
|
||||
cachedAt: Date.now(),
|
||||
count: normalized.states.length,
|
||||
};
|
||||
@@ -429,6 +439,7 @@ async function fetchAdsbLolPointFallback(req) {
|
||||
async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
|
||||
const fallback = await fetchAdsbLolPointFallback(req);
|
||||
if (!fallback) return false;
|
||||
const encoded = encodedBody(req, fallback.body, fallback.gzip);
|
||||
res.writeHead(200, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: fallback.cacheStatus,
|
||||
@@ -439,8 +450,9 @@ async function serveAdsbLolPointFallback(req, res, requestedMode, reason) {
|
||||
'X-Flight-Source': 'adsb.lol',
|
||||
'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`,
|
||||
'X-Flight-Count': String(fallback.count),
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(fallback.body);
|
||||
res.end(encoded.body);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -484,6 +496,15 @@ export function openSkyProxy() {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
// After the /track hand-off, so /track keeps its own method handling.
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, {
|
||||
'Content-Type': 'application/json',
|
||||
Allow: 'GET',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
res.setHeader('X-Feed-Source', 'OpenSky Network');
|
||||
const requestedMode = normalizeOpenSkyAuthMode(
|
||||
process.env.OPENSKY_AUTH_MODE,
|
||||
@@ -514,9 +535,13 @@ export function openSkyProxy() {
|
||||
reason: 'cached',
|
||||
};
|
||||
const isStale = now - _openskyCacheTime >= _openskyTtlMs;
|
||||
res.writeHead(
|
||||
_openskyCacheStatus || 200,
|
||||
buildOpenSkyHeaders({
|
||||
const encoded = encodedBody(
|
||||
req,
|
||||
_openskyCacheBody,
|
||||
_openskyCacheGzip,
|
||||
);
|
||||
res.writeHead(_openskyCacheStatus || 200, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: isStale ? 'STALE' : 'HIT',
|
||||
requestedMode: cachedMeta.requestedMode || requestedMode,
|
||||
usedMode: cachedMeta.usedMode || 'unknown',
|
||||
@@ -532,8 +557,9 @@ export function openSkyProxy() {
|
||||
? (_openskyCooldownUntil - now) / 1000
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
res.end(_openskyCacheBody);
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(encoded.body);
|
||||
return;
|
||||
}
|
||||
// Cooling down with nothing cached (cold start into a rate limit):
|
||||
@@ -628,7 +654,11 @@ export function openSkyProxy() {
|
||||
// Only cache successful responses — error responses (401/403/429/5xx)
|
||||
// should not be served from cache on subsequent requests
|
||||
if (upstream.ok) {
|
||||
// Compress before writing either slot, so a request arriving
|
||||
// mid-refresh still reads a body with its own frame.
|
||||
const frame = await gzipBody(body);
|
||||
_openskyCacheBody = body;
|
||||
_openskyCacheGzip = frame;
|
||||
_openskyCacheStatus = upstream.status;
|
||||
_openskyCacheTime = now;
|
||||
_openskyCacheSourceEpochMs = sourceEpochMs;
|
||||
@@ -681,9 +711,13 @@ export function openSkyProxy() {
|
||||
// Serve the last-good body instead of the 429 when we have one —
|
||||
// the layer keeps rendering (STALE-cued) instead of dying.
|
||||
if (_openskyCacheBody && _openskyCacheStatus === 200) {
|
||||
res.writeHead(
|
||||
200,
|
||||
buildOpenSkyHeaders({
|
||||
const encoded = encodedBody(
|
||||
req,
|
||||
_openskyCacheBody,
|
||||
_openskyCacheGzip,
|
||||
);
|
||||
res.writeHead(200, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: 'STALE',
|
||||
requestedMode,
|
||||
usedMode,
|
||||
@@ -691,8 +725,9 @@ export function openSkyProxy() {
|
||||
staleSeconds: (now - _openskyCacheTime) / 1000,
|
||||
retryAfterSeconds: cooldownMs / 1000,
|
||||
}),
|
||||
);
|
||||
res.end(_openskyCacheBody);
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(encoded.body);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -734,16 +769,23 @@ export function openSkyProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(
|
||||
upstream.status,
|
||||
buildOpenSkyHeaders({
|
||||
// The frame belongs to this body only if this body was cached. An
|
||||
// uncached error must not borrow the previous snapshot's frame.
|
||||
const encoded = encodedBody(
|
||||
req,
|
||||
body,
|
||||
_openskyCacheBody === body ? _openskyCacheGzip : null,
|
||||
);
|
||||
res.writeHead(upstream.status, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: 'MISS',
|
||||
requestedMode,
|
||||
usedMode,
|
||||
reason,
|
||||
}),
|
||||
);
|
||||
res.end(body);
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(encoded.body);
|
||||
} catch (e) {
|
||||
console.error('[OpenSky Proxy]', e.message);
|
||||
if (_openskyCacheBody) {
|
||||
@@ -754,17 +796,22 @@ export function openSkyProxy() {
|
||||
usedMode: 'unknown',
|
||||
reason: 'cached_stale',
|
||||
};
|
||||
res.writeHead(
|
||||
_openskyCacheStatus || 200,
|
||||
buildOpenSkyHeaders({
|
||||
const encoded = encodedBody(
|
||||
req,
|
||||
_openskyCacheBody,
|
||||
_openskyCacheGzip,
|
||||
);
|
||||
res.writeHead(_openskyCacheStatus || 200, {
|
||||
...buildOpenSkyHeaders({
|
||||
cacheStatus: 'STALE',
|
||||
requestedMode:
|
||||
cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT,
|
||||
usedMode: cachedMeta.usedMode || 'unknown',
|
||||
reason: cachedMeta.reason || 'cached_stale',
|
||||
}),
|
||||
);
|
||||
res.end(_openskyCacheBody);
|
||||
...encoded.headers,
|
||||
});
|
||||
res.end(encoded.body);
|
||||
return;
|
||||
}
|
||||
const requestedMode = normalizeOpenSkyAuthMode(
|
||||
|
||||
+187
-5
@@ -1,4 +1,5 @@
|
||||
import { createCctvCatalog } from './cctv/catalog.js';
|
||||
import { quebec511VideoUrl, quebecBrowserVideoEnabled } from './cctv/quebec.js';
|
||||
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
|
||||
import {
|
||||
buildSyntheticCctvSvg,
|
||||
@@ -18,6 +19,8 @@ import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
||||
import { createHlsPuller } from './cctv/stream.js';
|
||||
import { isMlitRiverAdjustmentImage } from './cctv/worldCameras.js';
|
||||
import { ottawaAuthorizedFrameUrl } from './cctv/ottawa.js';
|
||||
import { googleServerApiKey } from './places/google-key.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||
/**
|
||||
@@ -43,6 +46,120 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
||||
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
|
||||
const puller = createHlsPuller();
|
||||
/**
|
||||
* Bounded transient image reuse keeps cadence-limited providers (World
|
||||
* stills, Ottawa's once-a-minute API) inside their refresh floor. The byte
|
||||
* cap is the real bound; the entry cap only limits map overhead.
|
||||
*/
|
||||
const WORLD_FRAME_CACHE_MAX_ENTRIES = 1024;
|
||||
/** Retry a failed fetch sooner than a full refresh window, never under a minute. */
|
||||
const THROTTLED_FRAME_FAILURE_BACKOFF_MS = 60 * 1000;
|
||||
const WORLD_FRAME_CACHE_MAX_ENTRY_BYTES = 2 * 1024 * 1024;
|
||||
const WORLD_FRAME_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
||||
const worldFrameCache = new Map();
|
||||
const worldFrameInflight = new Map();
|
||||
const worldFrameNextAllowedAt = new Map();
|
||||
let worldFrameCacheBytes = 0;
|
||||
|
||||
function getWorldFrameCache(cameraId) {
|
||||
const entry = worldFrameCache.get(cameraId);
|
||||
if (!entry) return null;
|
||||
worldFrameCache.delete(cameraId);
|
||||
worldFrameCache.set(cameraId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function storeWorldFrame(cameraId, image) {
|
||||
if (
|
||||
!Buffer.isBuffer(image?.body) ||
|
||||
image.body.length > WORLD_FRAME_CACHE_MAX_ENTRY_BYTES
|
||||
)
|
||||
return;
|
||||
const previous = worldFrameCache.get(cameraId);
|
||||
if (previous) worldFrameCacheBytes -= previous.body.length;
|
||||
const entry = {
|
||||
body: image.body,
|
||||
contentType: image.contentType,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
worldFrameCache.delete(cameraId);
|
||||
worldFrameCache.set(cameraId, entry);
|
||||
worldFrameCacheBytes += entry.body.length;
|
||||
while (
|
||||
worldFrameCache.size > WORLD_FRAME_CACHE_MAX_ENTRIES ||
|
||||
worldFrameCacheBytes > WORLD_FRAME_CACHE_MAX_BYTES
|
||||
) {
|
||||
const oldestId = worldFrameCache.keys().next().value;
|
||||
const oldest = worldFrameCache.get(oldestId);
|
||||
worldFrameCache.delete(oldestId);
|
||||
worldFrameCacheBytes -= oldest.body.length;
|
||||
}
|
||||
}
|
||||
|
||||
function setNextAllowedAt(cameraId, at) {
|
||||
worldFrameNextAllowedAt.delete(cameraId);
|
||||
worldFrameNextAllowedAt.set(cameraId, at);
|
||||
while (worldFrameNextAllowedAt.size > 4096) {
|
||||
worldFrameNextAllowedAt.delete(
|
||||
worldFrameNextAllowedAt.keys().next().value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a frame from a provider with a per-camera refresh floor. Inside the
|
||||
* floor the last frame is reused; with no frame to reuse the caller gets
|
||||
* `{ throttled: true }` and serves a placeholder, so a cadence-limited camera
|
||||
* never falls through to the key-bearing Street View fallback. A failed
|
||||
* fetch backs off for a minute (capped at the floor) instead of a full
|
||||
* window.
|
||||
*/
|
||||
async function fetchThrottledFrame(source, cameraId, frameUrl) {
|
||||
const cached = getWorldFrameCache(cameraId);
|
||||
const rawRefreshMs = Number(source?.frameRefreshMs);
|
||||
const refreshMs =
|
||||
Number.isFinite(rawRefreshMs) && rawRefreshMs > 0
|
||||
? rawRefreshMs
|
||||
: 5 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.fetchedAt < refreshMs) {
|
||||
return { ok: true, ...cached, cached: true };
|
||||
}
|
||||
|
||||
const inflight = worldFrameInflight.get(cameraId);
|
||||
if (inflight) return inflight;
|
||||
const stale = cached
|
||||
? {
|
||||
ok: true,
|
||||
body: cached.body,
|
||||
contentType: cached.contentType,
|
||||
stale: true,
|
||||
}
|
||||
: null;
|
||||
const nextAllowedAt = worldFrameNextAllowedAt.get(cameraId) || 0;
|
||||
if (now < nextAllowedAt) return stale || { ok: false, throttled: true };
|
||||
if (!frameUrl) return stale;
|
||||
setNextAllowedAt(cameraId, now + refreshMs);
|
||||
let request;
|
||||
request = fetchCctvImageFromUpstream(frameUrl)
|
||||
.then((image) => {
|
||||
if (image?.ok) {
|
||||
storeWorldFrame(cameraId, image);
|
||||
return image;
|
||||
}
|
||||
setNextAllowedAt(
|
||||
cameraId,
|
||||
Date.now() + Math.min(refreshMs, THROTTLED_FRAME_FAILURE_BACKOFF_MS),
|
||||
);
|
||||
return stale || image;
|
||||
})
|
||||
.finally(() => {
|
||||
if (worldFrameInflight.get(cameraId) === request)
|
||||
worldFrameInflight.delete(cameraId);
|
||||
});
|
||||
worldFrameInflight.set(cameraId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
||||
const setHealth = (cameraId, patch) => {
|
||||
@@ -153,6 +270,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
);
|
||||
|
||||
if (url.pathname === '/sources') {
|
||||
const quebecVideo = quebecBrowserVideoEnabled();
|
||||
const body = {
|
||||
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
|
||||
sources: sources.map((source) => ({
|
||||
@@ -164,6 +282,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
provider: source.provider,
|
||||
lat: source.lat,
|
||||
lon: source.lon,
|
||||
frameRefreshMs: source.frameRefreshMs,
|
||||
headingDeg: source.headingDeg,
|
||||
headingConfidence: source.headingConfidence || '',
|
||||
pitchDeg: source.pitchDeg,
|
||||
@@ -174,6 +293,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
feedType: normalizeFeedType(source.feedType),
|
||||
sourceKind:
|
||||
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
||||
sourceUrl: source.sourceUrl || '',
|
||||
browserMediaUrl:
|
||||
quebecVideo && source.sourceKind === 'quebec-511-browser'
|
||||
? quebec511VideoUrl(source.id)
|
||||
: '',
|
||||
accuracyM: source.accuracyM,
|
||||
positionSource: source.positionSource || '',
|
||||
poseSource: source.poseSource,
|
||||
model: source.model || '',
|
||||
manufacturer: source.manufacturer || '',
|
||||
@@ -526,15 +652,71 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
: '');
|
||||
|
||||
const upstreamImage =
|
||||
source?.sourceKind === 'txdot-its'
|
||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||
: await fetchCctvImageFromUpstream(upstreamCandidate);
|
||||
source?.pack === 'world-cameras'
|
||||
? await fetchThrottledFrame(source, cameraId, upstreamCandidate)
|
||||
: source?.sourceKind === 'ottawa-traffic'
|
||||
? await fetchThrottledFrame(
|
||||
source,
|
||||
cameraId,
|
||||
ottawaAuthorizedFrameUrl(upstreamCandidate),
|
||||
)
|
||||
: source?.sourceKind === 'txdot-its'
|
||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||
: await fetchCctvImageFromUpstream(upstreamCandidate, {
|
||||
allowMjpeg: source?.sourceKind === 'taiwan-highway-motc',
|
||||
});
|
||||
if (
|
||||
source?.sourceKind === 'mlit-river' &&
|
||||
isMlitRiverAdjustmentImage(upstreamImage)
|
||||
) {
|
||||
const svg = buildSyntheticCctvSvg({
|
||||
cameraId,
|
||||
label,
|
||||
city,
|
||||
status: 'CAMERA UNDER ADJUSTMENT',
|
||||
});
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
sourceKind: 'snapshot',
|
||||
label: source.provider || 'MLIT river cameras',
|
||||
message: 'Provider camera under adjustment',
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-CCTV-Source': 'provider-maintenance',
|
||||
});
|
||||
res.end(svg);
|
||||
return;
|
||||
}
|
||||
if (upstreamImage?.throttled) {
|
||||
// Inside the provider's refresh floor with no frame to reuse: a
|
||||
// placeholder, not Street View, and no health change.
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-CCTV-Source': 'synthetic',
|
||||
});
|
||||
res.end(
|
||||
buildSyntheticCctvSvg({
|
||||
cameraId,
|
||||
label,
|
||||
city,
|
||||
status: 'WAITING FOR NEXT FRAME',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (upstreamImage?.ok) {
|
||||
setHealth(cameraId, {
|
||||
status: 'ok',
|
||||
status: upstreamImage.stale ? 'degraded' : 'ok',
|
||||
sourceKind: 'snapshot',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: 'Upstream snapshot active',
|
||||
message: upstreamImage.stale
|
||||
? 'Serving last snapshot; refresh failed'
|
||||
: upstreamImage.cached
|
||||
? 'Cached snapshot active'
|
||||
: 'Upstream snapshot active',
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': upstreamImage.contentType,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from './constants.js';
|
||||
|
||||
@@ -23,16 +24,27 @@ export function resolveCatalogCap(raw) {
|
||||
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
|
||||
* each pack per turn, in each pack's own order — so a lowered
|
||||
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
|
||||
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
|
||||
* pack happened to be appended last. A pack with a `reservedCount` keeps up to
|
||||
* that many rows only from the capacity ABOVE the shared floor, so a
|
||||
* reservation adds room and never shrinks the other packs' round-robin
|
||||
* share; at or below the floor reserved packs are ordinary lanes. Duplicate
|
||||
* ids resolve last-pack-wins so
|
||||
* file/env overrides keep replacing live records; the record is then counted
|
||||
* against the pack that won it.
|
||||
*
|
||||
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
|
||||
* packs in merge order (earlier packs lose duplicate ids to later ones).
|
||||
* @param {number} maxCount - Catalog cap.
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.sharedFloor=CCTV_SHARED_SOURCE_SLOTS] - Slots kept
|
||||
* for the round-robin before reservations apply.
|
||||
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
|
||||
*/
|
||||
export function allocateSourceCap(packs, maxCount) {
|
||||
export function allocateSourceCap(
|
||||
packs,
|
||||
maxCount,
|
||||
{ sharedFloor = CCTV_SHARED_SOURCE_SLOTS } = {},
|
||||
) {
|
||||
const owner = new Map();
|
||||
for (const pack of packs) {
|
||||
for (const source of pack.sources) {
|
||||
@@ -40,30 +52,53 @@ export function allocateSourceCap(packs, maxCount) {
|
||||
owner.set(source.id, { pack: pack.name, source });
|
||||
}
|
||||
}
|
||||
const lanes = packs.map((pack) => ({
|
||||
name: pack.name,
|
||||
const lanes = packs.map((pack) => {
|
||||
// Only the record that WON its id (the last occurrence anywhere, so also
|
||||
// the last within this pack) takes a lane slot; earlier duplicates never
|
||||
// count against the cap.
|
||||
queue: pack.sources.filter(
|
||||
const queue = pack.sources.filter(
|
||||
(source) => source?.id && owner.get(source.id)?.source === source,
|
||||
),
|
||||
next: 0,
|
||||
kept: 0,
|
||||
}));
|
||||
);
|
||||
return {
|
||||
name: pack.name,
|
||||
queue,
|
||||
reserved: Math.max(
|
||||
0,
|
||||
Math.min(queue.length, Math.floor(Number(pack.reservedCount) || 0)),
|
||||
),
|
||||
next: 0,
|
||||
kept: 0,
|
||||
};
|
||||
});
|
||||
const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
|
||||
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
|
||||
|
||||
const sources = [];
|
||||
const reservedSources = [];
|
||||
if (limit >= total) {
|
||||
for (const lane of lanes) {
|
||||
sources.push(...lane.queue);
|
||||
lane.kept = lane.queue.length;
|
||||
}
|
||||
} else {
|
||||
while (sources.length < limit) {
|
||||
// Reserved packs draw only on the capacity above the shared floor, then
|
||||
// every lane (reserved ones included) shares the rest round-robin.
|
||||
const reserveBudget = Math.max(0, limit - Math.max(0, sharedFloor));
|
||||
while (reservedSources.length < reserveBudget) {
|
||||
let tookReserved = false;
|
||||
for (const lane of lanes) {
|
||||
if (sources.length >= limit) break;
|
||||
if (reservedSources.length >= reserveBudget) break;
|
||||
if (lane.kept >= lane.reserved) continue;
|
||||
reservedSources.push(lane.queue[lane.next]);
|
||||
lane.next += 1;
|
||||
lane.kept += 1;
|
||||
tookReserved = true;
|
||||
}
|
||||
if (!tookReserved) break;
|
||||
}
|
||||
while (sources.length + reservedSources.length < limit) {
|
||||
for (const lane of lanes) {
|
||||
if (sources.length + reservedSources.length >= limit) break;
|
||||
if (lane.next >= lane.queue.length) continue;
|
||||
sources.push(lane.queue[lane.next]);
|
||||
lane.next += 1;
|
||||
@@ -72,7 +107,9 @@ export function allocateSourceCap(packs, maxCount) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
// Keep the shared round-robin sequence first so the default active camera
|
||||
// remains in its established region; guaranteed regional rows follow it.
|
||||
sources: [...sources, ...reservedSources],
|
||||
packs: lanes.map((lane) => ({
|
||||
name: lane.name,
|
||||
offered: lane.queue.length,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
||||
import {
|
||||
DEFAULT_CCTV_SOURCE_FILE,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from './constants.js';
|
||||
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
||||
import { loadLithuaniaSources } from './lithuania.js';
|
||||
import { loadOttawaSourcesFromOpenData } from './ottawa.js';
|
||||
import { loadQuebecSources, QUEBEC_CAMERA_SOURCE_COUNT } from './quebec.js';
|
||||
import { loadTaiwanSources } from './taiwan.js';
|
||||
import { loadWorldCameraSources } from './worldCameras.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
@@ -20,6 +27,7 @@ import {
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
loadIcelandSourcesFromOpenData,
|
||||
loadHkSourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadKingCountySourcesFromOpenData,
|
||||
@@ -75,6 +83,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
|
||||
load: loadDriveBcSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'ottawa',
|
||||
enabled: () => envEnabled('CCTV_OTTAWA_ENABLED'),
|
||||
load: loadOttawaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'txdot',
|
||||
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
|
||||
@@ -110,6 +123,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
|
||||
load: loadCataloniaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'iceland',
|
||||
enabled: () => envEnabled('CCTV_ICELAND_ENABLED'),
|
||||
load: loadIcelandSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'hk',
|
||||
enabled: () => envEnabled('CCTV_HK_ENABLED'),
|
||||
@@ -172,6 +190,18 @@ const LIVE_PACKS = [
|
||||
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
|
||||
load: loadHoustonSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'quebec',
|
||||
enabled: () => envEnabled('CCTV_QUEBEC_ENABLED'),
|
||||
load: loadQuebecSources,
|
||||
reservedCount: QUEBEC_CAMERA_SOURCE_COUNT,
|
||||
},
|
||||
{
|
||||
name: 'world-cameras',
|
||||
enabled: () => envEnabled('CCTV_WORLD_CAMERAS_ENABLED'),
|
||||
load: loadWorldCameraSources,
|
||||
reservedCount: WORLD_CCTV_SOURCE_COUNT,
|
||||
},
|
||||
{
|
||||
name: 'a22',
|
||||
enabled: () => envEnabled('CCTV_A22_ENABLED'),
|
||||
@@ -316,7 +346,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
: [];
|
||||
// Live packs first so file/env overrides win on duplicate IDs; each pack
|
||||
// keeps its own priority order and the catalog cap is shared fairly.
|
||||
const normalizePack = (name, items) => ({
|
||||
const normalizePack = (name, items, reservedCount = 0) => ({
|
||||
name,
|
||||
sources: items
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
@@ -326,6 +356,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
? items.available
|
||||
: items.length,
|
||||
region: items.region ?? null,
|
||||
reservedCount,
|
||||
});
|
||||
const packs = [
|
||||
...LIVE_PACKS.map((pack, index) =>
|
||||
@@ -334,6 +365,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
liveResults[index]?.status === 'fulfilled'
|
||||
? liveResults[index].value
|
||||
: [],
|
||||
pack.reservedCount,
|
||||
),
|
||||
),
|
||||
normalizePack('file', fromFile),
|
||||
|
||||
@@ -5,15 +5,18 @@ export const DEFAULT_AUSTIN_ROWS_URL =
|
||||
/** Default cap on Austin cameras after distance-based prioritization. */
|
||||
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
|
||||
/**
|
||||
* Catalog-wide safety ceiling on served cameras. Each pack already caps
|
||||
* itself (nearest-to-anchor first); this bound only matters when the packs
|
||||
* together exceed it, and it is then filled round-robin across packs (see
|
||||
* cap.js) so no region is silently dropped. The default per-pack caps
|
||||
* together can exceed it, so a default install may be thinned evenly here.
|
||||
* Slots every pack shares round-robin before any reservation applies. A
|
||||
* reserved pack (Québec, World) only takes capacity above this floor, so a
|
||||
* cap at or below it thins every region evenly (see cap.js).
|
||||
*/
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
|
||||
export const CCTV_SHARED_SOURCE_SLOTS = 4000;
|
||||
/**
|
||||
* Catalog-wide default: the 4,000 shared slots plus room reserved for the
|
||||
* 1,120 Québec and 2,230 World image locations.
|
||||
*/
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 7350;
|
||||
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
|
||||
export const CCTV_MAX_SOURCES_CEILING = 5000;
|
||||
export const CCTV_MAX_SOURCES_CEILING = 8000;
|
||||
/** Reference point for Austin camera prioritization (Congress & 6th). */
|
||||
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
|
||||
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
|
||||
@@ -197,6 +200,39 @@ export const DRIVEBC_ANCHORS = [
|
||||
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
|
||||
{ lat: 48.4284, lon: -123.3656 }, // Victoria
|
||||
];
|
||||
/** City of Ottawa traffic cameras: documented JSON list (keyless). */
|
||||
export const OTTAWA_CAMERAS_URL =
|
||||
'https://traffic.ottawa.ca/map/service/camera';
|
||||
/**
|
||||
* Keyless canonical frame URL for one camera number. The city's Traffic Camera
|
||||
* Images API needs a certificate (OTTAWA_CAMERA_CERTIFICATE), which the frame
|
||||
* proxy adds at request time so it never appears in a catalog response.
|
||||
*/
|
||||
export const OTTAWA_IMAGE_URL = (cameraNumber) =>
|
||||
`https://traffic.ottawa.ca/opendata/camera?c=${encodeURIComponent(cameraNumber)}`;
|
||||
/** Pinned frame directory, for redirect containment in the media proxy. */
|
||||
export const OTTAWA_IMAGE_ORIGIN = 'https://traffic.ottawa.ca/opendata/';
|
||||
/** The API lets one certificate and user id fetch a camera once a minute. */
|
||||
export const OTTAWA_FRAME_REFRESH_MS = 60 * 1000;
|
||||
/** Alphanumeric user id this server sends with its certificate. */
|
||||
export const OTTAWA_API_USER_ID = 'gev';
|
||||
export const DEFAULT_OTTAWA_MAX_SOURCES = 250;
|
||||
/** Prioritization anchor: downtown Ottawa (Parliament Hill). */
|
||||
export const OTTAWA_ANCHORS = [{ lat: 45.4215, lon: -75.6972 }];
|
||||
/**
|
||||
* Taiwan Highway Bureau MJPEG servers. Each camera is one root path segment,
|
||||
* so the pin is the host root: a redirect must stay on the same server.
|
||||
*/
|
||||
export const TAIWAN_HIGHWAY_IMAGE_ORIGINS = Object.freeze(
|
||||
[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `https://cctv-ss0${n}.thb.gov.tw/`),
|
||||
);
|
||||
/** Direct still-image directories from the World cams add-on's eligible packs. */
|
||||
export const TRIPCHECK_IMAGE_ORIGIN =
|
||||
'https://www.tripcheck.com/roadcams/cams/';
|
||||
export const MLIT_RIVER_IMAGE_ORIGIN = 'https://cam.river.go.jp/cam/now/';
|
||||
export const DEFAULT_WORLD_CCTV_SOURCE_FILE =
|
||||
'config/cctv_sources.world-image-cameras.json';
|
||||
export const WORLD_CCTV_SOURCE_COUNT = 2230;
|
||||
/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */
|
||||
export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
|
||||
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
|
||||
@@ -309,6 +345,37 @@ export const CATALONIA_ANCHORS = [
|
||||
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
|
||||
];
|
||||
|
||||
/**
|
||||
* Icelandic Road and Coastal Administration (Vegagerðin, IRCA) road cameras:
|
||||
* one keyless JSON list, one row per camera view (about 500 views at roughly
|
||||
* 170 stations, verified 2026-11-06). Frames are stills on the IRCA host, each
|
||||
* rewritten by the publisher every few minutes. Camera images are published
|
||||
* under CC BY 4.0 (IRCA terms of use, "Open data" section).
|
||||
*/
|
||||
export const ICELAND_CAMERAS_URL =
|
||||
'https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1';
|
||||
/** The only directory Iceland camera frames may come from. */
|
||||
export const ICELAND_IMAGE_ORIGIN =
|
||||
'https://www.vegagerdin.is/vgdata/vefmyndavelar/';
|
||||
/** Byte ceiling for the Iceland catalog read (~150 KB observed 2026-11-06). */
|
||||
export const ICELAND_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Ground-elevation prior in metres: the feed carries none, and the country
|
||||
* runs from sea level to highland passes. The client's ground snap corrects
|
||||
* this on 3D-tile stacks. */
|
||||
export const ICELAND_GROUND_ELEVATION_M = 60;
|
||||
/** The feed lists about 500 views; the default keeps half so the shared
|
||||
* catalog cap still leaves room for denser regions. */
|
||||
export const DEFAULT_ICELAND_MAX_SOURCES = 250;
|
||||
/** Prioritization anchors: Reykjavík and the regional centres, so a cap keeps
|
||||
* every region's nearest cameras instead of only the capital's. */
|
||||
export const ICELAND_ANCHORS = [
|
||||
{ lat: 64.1466, lon: -21.9426 }, // Reykjavík
|
||||
{ lat: 65.6885, lon: -18.1262 }, // Akureyri
|
||||
{ lat: 65.2639, lon: -14.3948 }, // Egilsstaðir
|
||||
{ lat: 66.0749, lon: -23.1351 }, // Ísafjörður
|
||||
{ lat: 63.9331, lon: -20.9971 }, // Selfoss
|
||||
];
|
||||
|
||||
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
|
||||
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
|
||||
export const DEFAULT_HK_CAMERAS_URL =
|
||||
|
||||
@@ -10,10 +10,15 @@ import {
|
||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
TFL_IMAGE_ORIGIN,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
@@ -523,13 +528,18 @@ for (const prefix of [
|
||||
TFL_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
...WARENDORF_IMAGE_ORIGINS,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
A22_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
...TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
]) {
|
||||
const host = new URL(prefix).hostname;
|
||||
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
||||
@@ -621,6 +631,216 @@ export function cctvUpstreamUserAgent(url) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Largest single JPEG the MJPEG first-frame reader will buffer. */
|
||||
export const CCTV_MJPEG_FRAME_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Read one bounded JPEG part from an open MJPEG response, then stop the stream.
|
||||
*
|
||||
* The first part may follow a CRLF preamble, and its delimiter may be written
|
||||
* as `--<boundary>` or, when the declared boundary already starts with `--`,
|
||||
* as the boundary itself. A part with Content-Length is read to that length;
|
||||
* a part without one ends at the next delimiter, so a JPEG end marker inside
|
||||
* the image (an EXIF thumbnail) never truncates it. Bytes are copied once
|
||||
* into a preallocated buffer and every search resumes where it stopped, so a
|
||||
* trickling upstream costs linear time.
|
||||
*/
|
||||
async function readFirstMjpegFrame(upstream, maxBytes) {
|
||||
const mediaType = cctvLiveMediaType(
|
||||
'mjpeg',
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
const boundary = parseMediaType(mediaType)?.params.get('boundary') || '';
|
||||
const frameMax = Math.min(maxBytes, CCTV_MJPEG_FRAME_MAX_BYTES);
|
||||
if (
|
||||
!boundary ||
|
||||
!upstream.body ||
|
||||
!Number.isSafeInteger(frameMax) ||
|
||||
frameMax < 4
|
||||
) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const delimiters = [Buffer.from('--' + boundary, 'latin1')];
|
||||
if (boundary.startsWith('--') && boundary.length > 2)
|
||||
delimiters.push(Buffer.from(boundary, 'latin1'));
|
||||
const lineEnd = Buffer.from('\r\n', 'ascii');
|
||||
const headerTerminator = Buffer.from('\r\n\r\n', 'ascii');
|
||||
const jpegStart = Buffer.from([0xff, 0xd8]);
|
||||
const jpegEnd = Buffer.from([0xff, 0xd9]);
|
||||
const maxPreambleBytes = 64;
|
||||
const maxHeaderBytes = 8 * 1024;
|
||||
const maxBufferedBytes =
|
||||
maxPreambleBytes +
|
||||
delimiters[0].length +
|
||||
lineEnd.length +
|
||||
maxHeaderBytes +
|
||||
headerTerminator.length +
|
||||
frameMax +
|
||||
lineEnd.length +
|
||||
delimiters[0].length;
|
||||
const buffer = Buffer.allocUnsafe(maxBufferedBytes);
|
||||
let length = 0;
|
||||
let reader;
|
||||
let headerStart = -1;
|
||||
let delimiter = null;
|
||||
let headerScanFrom = 0;
|
||||
let bodyStart = -1;
|
||||
let declaredLength = null;
|
||||
let boundaryScanFrom = -1;
|
||||
|
||||
/** Match the opening delimiter after an optional CRLF preamble. */
|
||||
function findOpening() {
|
||||
let at = 0;
|
||||
while (at < length && (buffer[at] === 0x0d || buffer[at] === 0x0a)) at++;
|
||||
if (at > maxPreambleBytes) return { failed: true };
|
||||
for (const candidate of delimiters) {
|
||||
const line = Buffer.concat([candidate, lineEnd]);
|
||||
const need = at + line.length;
|
||||
if (length < need) {
|
||||
const seen = buffer.subarray(at, length);
|
||||
if (line.subarray(0, seen.length).equals(seen))
|
||||
return { pending: true };
|
||||
continue;
|
||||
}
|
||||
if (buffer.subarray(at, need).equals(line))
|
||||
return { start: need, delimiter: candidate };
|
||||
}
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
/** Parse the part headers; false rejects the stream. */
|
||||
function parseHeaders(headerEnd) {
|
||||
const headerText = buffer
|
||||
.subarray(headerStart, headerEnd)
|
||||
.toString('latin1');
|
||||
const headers = new Map();
|
||||
for (const line of headerText.split('\r\n')) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) return false;
|
||||
const name = line.slice(0, colon).trim().toLowerCase();
|
||||
if (!name || headers.has(name)) return false;
|
||||
headers.set(name, line.slice(colon + 1).trim());
|
||||
}
|
||||
if (cctvRasterImageType(headers.get('content-type')) !== 'image/jpeg')
|
||||
return false;
|
||||
const declared = headers.get('content-length');
|
||||
if (declared !== undefined) {
|
||||
if (!/^\d+$/.test(declared)) return false;
|
||||
declaredLength = Number(declared);
|
||||
if (
|
||||
!Number.isSafeInteger(declaredLength) ||
|
||||
declaredLength < jpegStart.length + jpegEnd.length ||
|
||||
declaredLength > frameMax
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A complete JPEG body, or null. */
|
||||
function asJpeg(frame) {
|
||||
return frame.length >= jpegStart.length + jpegEnd.length &&
|
||||
frame.subarray(0, jpegStart.length).equals(jpegStart) &&
|
||||
frame.subarray(-jpegEnd.length).equals(jpegEnd)
|
||||
? Buffer.from(frame)
|
||||
: null;
|
||||
}
|
||||
|
||||
try {
|
||||
reader = upstream.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return null;
|
||||
|
||||
const chunk = Buffer.from(value);
|
||||
const room = maxBufferedBytes - length;
|
||||
const included = Math.min(room, chunk.length);
|
||||
chunk.copy(buffer, length, 0, included);
|
||||
length += included;
|
||||
const overflow = included < chunk.length;
|
||||
|
||||
if (headerStart < 0) {
|
||||
const opening = findOpening();
|
||||
if (opening.failed) return null;
|
||||
if (opening.pending) {
|
||||
if (overflow) return null;
|
||||
continue;
|
||||
}
|
||||
headerStart = opening.start;
|
||||
delimiter = opening.delimiter;
|
||||
headerScanFrom = headerStart;
|
||||
}
|
||||
|
||||
if (bodyStart < 0) {
|
||||
const headerEnd = buffer
|
||||
.subarray(0, length)
|
||||
.indexOf(headerTerminator, headerScanFrom);
|
||||
if (headerEnd < 0) {
|
||||
if (length - headerStart > maxHeaderBytes || overflow) return null;
|
||||
headerScanFrom = Math.max(
|
||||
headerStart,
|
||||
length - headerTerminator.length + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (headerEnd - headerStart > maxHeaderBytes) return null;
|
||||
if (!parseHeaders(headerEnd)) return null;
|
||||
bodyStart = headerEnd + headerTerminator.length;
|
||||
boundaryScanFrom = bodyStart;
|
||||
}
|
||||
|
||||
const available = length - bodyStart;
|
||||
if (available >= jpegStart.length) {
|
||||
if (
|
||||
!buffer
|
||||
.subarray(bodyStart, bodyStart + jpegStart.length)
|
||||
.equals(jpegStart)
|
||||
)
|
||||
return null;
|
||||
}
|
||||
const nextDelimiter = Buffer.concat([lineEnd, delimiter]);
|
||||
const nextPart = buffer
|
||||
.subarray(0, length)
|
||||
.indexOf(nextDelimiter, boundaryScanFrom);
|
||||
if (declaredLength !== null) {
|
||||
if (nextPart >= bodyStart && nextPart - bodyStart < declaredLength)
|
||||
return null;
|
||||
if (available >= declaredLength)
|
||||
return asJpeg(buffer.subarray(bodyStart, bodyStart + declaredLength));
|
||||
} else if (nextPart >= bodyStart) {
|
||||
const frame = buffer.subarray(bodyStart, nextPart);
|
||||
return frame.length > frameMax ? null : asJpeg(frame);
|
||||
} else if (available > frameMax) {
|
||||
return null;
|
||||
}
|
||||
if (nextPart < 0)
|
||||
boundaryScanFrom = Math.max(
|
||||
bodyStart,
|
||||
length - nextDelimiter.length + 1,
|
||||
);
|
||||
if (overflow) return null;
|
||||
}
|
||||
} finally {
|
||||
if (reader) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* already closed or aborted */
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Fetch one upstream CCTV image within the frame-refresh budget.
|
||||
*
|
||||
@@ -633,6 +853,10 @@ export function cctvUpstreamUserAgent(url) {
|
||||
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
|
||||
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
|
||||
* @param {number} [options.maxBytes=CCTV_FRAME_MAX_BODY_BYTES] - Snapshot byte cap.
|
||||
* @param {boolean} [options.allowMjpeg=false] - Accept a multipart MJPEG
|
||||
* answer and keep its first JPEG. Only sources registered as MJPEG stills
|
||||
* (the Taiwan Highway Bureau feeds) set it; any other host answering
|
||||
* multipart falls through to the next fallback.
|
||||
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
|
||||
*/
|
||||
export async function fetchCctvImageFromUpstream(
|
||||
@@ -641,6 +865,7 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
|
||||
allowMjpeg = false,
|
||||
} = {},
|
||||
) {
|
||||
if (!url || !/^https?:\/\//i.test(url)) return null;
|
||||
@@ -660,10 +885,7 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) return null;
|
||||
const contentType = cctvRasterImageType(
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
if (!upstream.ok || !contentType) {
|
||||
if (!upstream.ok) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
@@ -671,9 +893,24 @@ export async function fetchCctvImageFromUpstream(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
const upstreamType = upstream.headers.get('content-type');
|
||||
const contentType = cctvRasterImageType(upstreamType);
|
||||
if (contentType) {
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType };
|
||||
}
|
||||
if (!allowMjpeg || !cctvLiveMediaType('mjpeg', upstreamType)) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readFirstMjpegFrame(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType };
|
||||
return { ok: true, body, contentType: 'image/jpeg' };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -460,6 +460,17 @@ export function isLikelyCataloniaCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Iceland bounding box (mainland and nearby islands, with a margin). */
|
||||
export function isLikelyIcelandCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 63.0 &&
|
||||
lat <= 66.7 &&
|
||||
lon >= -25.0 &&
|
||||
lon <= -13.0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode numeric HTML character references, decimal ("í") and hex
|
||||
* ("í") alike, the entity form the Catalonia feed uses for accented
|
||||
@@ -639,6 +650,10 @@ export function normalizeSourceItem(item) {
|
||||
feedType: normalizeFeedType(item.feedType || item.type || ''),
|
||||
url: typeof item.url === 'string' ? item.url : '',
|
||||
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
|
||||
frameRefreshMs: toFiniteNumber(item.frameRefreshMs),
|
||||
sourceUrl: typeof item.sourceUrl === 'string' ? item.sourceUrl : '',
|
||||
accuracyM: toFiniteNumber(item.accuracyM),
|
||||
positionSource: String(item.positionSource || ''),
|
||||
license: String(item.license || item.licenseNote || ''),
|
||||
// Per-camera attribution for feeds a partner supplies inside a pack
|
||||
// (DriveBC: TransLink, city cameras). Shown beside the provider.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
OTTAWA_ANCHORS,
|
||||
OTTAWA_API_USER_ID,
|
||||
OTTAWA_CAMERAS_URL,
|
||||
OTTAWA_FRAME_REFRESH_MS,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
OTTAWA_IMAGE_URL,
|
||||
} from './constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
pinnedCameraUrl,
|
||||
prioritizeSources,
|
||||
toFiniteNumber,
|
||||
} from './normalize.js';
|
||||
|
||||
let ottawaCertificateMissingLogged = false;
|
||||
|
||||
/** Test hook: let the next missing-certificate skip log again. */
|
||||
export function _resetOttawaCertificateWarningForTest() {
|
||||
ottawaCertificateMissingLogged = false;
|
||||
}
|
||||
|
||||
/** The operator's Traffic Camera Images API certificate, trimmed. */
|
||||
function ottawaCertificate(env = process.env) {
|
||||
return String(env.OTTAWA_CAMERA_CERTIFICATE ?? '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the operator's certificate to a registered keyless Ottawa frame URL.
|
||||
* Only a URL inside the pinned `/opendata/` directory with a numeric camera
|
||||
* number is signed, so the certificate never goes to any other path or host.
|
||||
*
|
||||
* @param {string} url - Canonical frame URL from the catalog.
|
||||
* @param {Record<string,string|undefined>} [env]
|
||||
* @returns {string} The signed URL, or '' when there is no certificate or the
|
||||
* URL is not a registered Ottawa frame.
|
||||
*/
|
||||
export function ottawaAuthorizedFrameUrl(url, env = process.env) {
|
||||
const certificate = ottawaCertificate(env);
|
||||
const pinned = pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN);
|
||||
if (!certificate || !pinned) return '';
|
||||
const parsed = new URL(pinned);
|
||||
const cameraNumber = parsed.searchParams.get('c') || '';
|
||||
if (parsed.pathname !== '/opendata/camera' || !/^\d+$/.test(cameraNumber))
|
||||
return '';
|
||||
const signed = new URL(OTTAWA_IMAGE_URL(cameraNumber));
|
||||
signed.searchParams.set('certificate', certificate);
|
||||
signed.searchParams.set('id', OTTAWA_API_USER_ID);
|
||||
return signed.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* City of Ottawa envelope: Kanata to Orléans, Barrhaven to the river, with
|
||||
* slack for the MTO cameras on the 416/417 that the city feed also carries.
|
||||
*/
|
||||
const OTTAWA_BOUNDS = Object.freeze({
|
||||
minLat: 45.1,
|
||||
maxLat: 45.65,
|
||||
minLon: -76.2,
|
||||
maxLon: -75.1,
|
||||
});
|
||||
|
||||
/** Whether a point is inside the city feed's service area. */
|
||||
function isOttawaCoordinate(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= OTTAWA_BOUNDS.minLat &&
|
||||
lat <= OTTAWA_BOUNDS.maxLat &&
|
||||
lon >= OTTAWA_BOUNDS.minLon &&
|
||||
lon <= OTTAWA_BOUNDS.maxLon
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the City of Ottawa's traffic cameras.
|
||||
*
|
||||
* Camera images come from the city's Traffic Camera Images API, which needs a
|
||||
* certificate from the city's registration form (OTTAWA_CAMERA_CERTIFICATE)
|
||||
* and allows one fetch per camera per minute for a certificate and user id.
|
||||
* Without a certificate the pack is skipped, with no request and one log line.
|
||||
* The camera list is the documented `map/service/camera` JSON: a camera
|
||||
* number, English and French names, coordinates and the owner (CITY or MTO).
|
||||
* The feed carries no bearing, no elevation and no status field, so headings
|
||||
* use the same deterministic low-confidence fallback as the other
|
||||
* heading-less packs and the client's ground snap refines the elevation
|
||||
* prior.
|
||||
*
|
||||
* @param {object} [_options]
|
||||
* @param {Record<string,string|undefined>} [env]
|
||||
* @returns {Promise<Array<object>>} Cameras nearest downtown first, or [] on
|
||||
* a missing certificate or any upstream or payload failure.
|
||||
*/
|
||||
export async function loadOttawaSourcesFromOpenData(
|
||||
_options = {},
|
||||
env = process.env,
|
||||
) {
|
||||
if (!ottawaCertificate(env)) {
|
||||
if (!ottawaCertificateMissingLogged) {
|
||||
ottawaCertificateMissingLogged = true;
|
||||
console.warn(
|
||||
'[CCTV] Ottawa cameras skipped: the city camera image API needs a certificate (set OTTAWA_CAMERA_CERTIFICATE)',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(OTTAWA_CAMERAS_URL, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Ottawa camera download failed:', resp.status);
|
||||
return [];
|
||||
}
|
||||
const payload = await resp.json();
|
||||
const rows = Array.isArray(payload?.cameras) ? payload.cameras : null;
|
||||
if (!rows) return [];
|
||||
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
const number = toFiniteNumber(row?.camera_number);
|
||||
if (!Number.isSafeInteger(number) || number <= 0 || seen.has(number))
|
||||
continue;
|
||||
const lat = toFiniteNumber(row?.latitude);
|
||||
const lon = toFiniteNumber(row?.longitude);
|
||||
if (!isOttawaCoordinate(lat, lon)) continue;
|
||||
seen.add(number);
|
||||
|
||||
const cameraId = `ottawa-${number}`;
|
||||
const imageUrl = OTTAWA_IMAGE_URL(number);
|
||||
// The docs name the field camera_owner; the live feed sends cameraOwner.
|
||||
const owner = String(row?.camera_owner || row?.cameraOwner || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: String(row?.name || '').trim() || `Ottawa camera ${number}`,
|
||||
city: 'Ottawa',
|
||||
cityId: 'ottawa',
|
||||
provider: 'City of Ottawa',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 0,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
frameRefreshMs: OTTAWA_FRAME_REFRESH_MS,
|
||||
sourceKind: 'ottawa-traffic',
|
||||
license: 'City of Ottawa Traffic Camera Images API (registered access)',
|
||||
credit:
|
||||
owner === 'MTO'
|
||||
? 'Ministry of Transportation of Ontario (MTO) camera'
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_OTTAWA_MAX_SOURCES || DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(500, Math.floor(maxRaw)))
|
||||
: DEFAULT_OTTAWA_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, OTTAWA_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Ottawa camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Ottawa camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cameraDisplayCode, fallbackHeadingFromId } from './normalize.js';
|
||||
|
||||
const QUEBEC_CATALOG_PATH = 'public/quebec-cameras.json';
|
||||
export const QUEBEC_CAMERA_SOURCE_COUNT = 1120;
|
||||
|
||||
/**
|
||||
* Whether the operator opted in to the browser-direct Québec 511 video. Off by
|
||||
* default: the camera locations are CC BY 4.0, but whether Québec 511's
|
||||
* service terms allow another site to embed its clips is unconfirmed, so an
|
||||
* unset flag lists the cameras with a link to the 511 viewer page only.
|
||||
*/
|
||||
export function quebecBrowserVideoEnabled(env = process.env) {
|
||||
return String(env.CCTV_QUEBEC_VIDEO_ENABLED || '').trim() === '1';
|
||||
}
|
||||
|
||||
/** Build the direct browser-only MP4 URL for one namespaced MTQ camera id. */
|
||||
export function quebec511VideoUrl(cameraId) {
|
||||
const match = /^qc-511-(\d+)$/.exec(String(cameraId || ''));
|
||||
return match
|
||||
? `https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=${match[1]}&format=mp4`
|
||||
: '';
|
||||
}
|
||||
|
||||
function safeViewerUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
return url.protocol === 'https:' && !url.username && !url.password
|
||||
? url.href
|
||||
: '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function sourceId(camera) {
|
||||
const id = String(camera?.id || '').trim();
|
||||
if (camera?.kind === 'traffic' && /^\d+$/.test(id)) return `qc-511-${id}`;
|
||||
if (camera?.kind === 'montreal' && /^mtl-\d+(?:-\d+)*$/.test(id))
|
||||
return `qc-mtl-${id.slice(4)}`;
|
||||
if (camera?.kind === 'webcam' && /^web-\d+$/.test(id))
|
||||
return `qc-web-${id.slice(4)}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the checked-in, no-key Québec location catalog and adapt it to the
|
||||
* regular CCTV source schema. MTQ video stays browser-only; other rows link to
|
||||
* their operator page because this dataset has no direct image URL for them.
|
||||
*/
|
||||
export function loadQuebecSources({ sourceRoot = process.cwd() } = {}) {
|
||||
const filename = path.resolve(sourceRoot, QUEBEC_CATALOG_PATH);
|
||||
let cameras;
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
cameras = Array.isArray(payload?.cameras) ? payload.cameras : [];
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] Québec camera catalog unavailable:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const sources = [];
|
||||
const seen = new Set();
|
||||
for (const camera of cameras) {
|
||||
const id = sourceId(camera);
|
||||
const lat = Number(camera.lat);
|
||||
const lon = Number(camera.lon);
|
||||
if (
|
||||
!id ||
|
||||
seen.has(id) ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
lat < 44.9 ||
|
||||
lat > 62.6 ||
|
||||
lon < -79.9 ||
|
||||
lon > -57.1
|
||||
)
|
||||
continue;
|
||||
seen.add(id);
|
||||
|
||||
const name = String(camera.nameEn || camera.name || id).trim();
|
||||
const isTraffic = camera.kind === 'traffic';
|
||||
const isMontreal = camera.kind === 'montreal';
|
||||
const sourceKind = isTraffic
|
||||
? 'quebec-511-browser'
|
||||
: isMontreal
|
||||
? 'quebec-montreal-osm'
|
||||
: 'quebec-webcam-link';
|
||||
const provider = isTraffic
|
||||
? 'Québec 511 / MTQ'
|
||||
: isMontreal
|
||||
? 'Ville de Montréal / OpenStreetMap'
|
||||
: String(camera.operator || 'Québec webcam');
|
||||
|
||||
sources.push({
|
||||
id,
|
||||
name: name.slice(0, 180),
|
||||
city: String(camera.region || 'Québec'),
|
||||
cityId: 'quebec',
|
||||
provider,
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(id),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 74,
|
||||
rangeM: 400,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 0,
|
||||
feedType: 'image',
|
||||
// No frame URL is registered: the server never fetches 511 media. With
|
||||
// CCTV_QUEBEC_VIDEO_ENABLED=1 the browser plays the 511 MP4 in the DOM
|
||||
// overlay after a play click; otherwise the camera links to 511.
|
||||
url: '',
|
||||
snapshotUrl: '',
|
||||
sourceKind,
|
||||
sourceUrl: safeViewerUrl(camera.viewerUrl),
|
||||
accuracyM: Number.isFinite(Number(camera.accuracyM))
|
||||
? Number(camera.accuracyM)
|
||||
: null,
|
||||
positionSource: String(camera.positionSource || ''),
|
||||
license: isTraffic
|
||||
? 'Locations: Données Québec / MTQ, CC BY 4.0. Video: hosted by Québec 511 under its terms of use'
|
||||
: isMontreal
|
||||
? 'OpenStreetMap contributors, ODbL 1.0'
|
||||
: 'Operator terms apply; location provenance shown per camera',
|
||||
credit: isTraffic
|
||||
? 'Locations: MTQ · CC BY 4.0 · Video: Québec 511'
|
||||
: isMontreal
|
||||
? 'OpenStreetMap contributors · ODbL'
|
||||
: provider,
|
||||
code: cameraDisplayCode(name.toUpperCase()),
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
Loaded 100 of 670 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user