Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f268efb71 | ||
|
|
2da8ced642 | ||
|
|
a397c94892 | ||
|
|
5a1598fd9e | ||
|
|
bf0e1d7c79 | ||
|
|
415edbcfdf | ||
|
|
4bfee31fdb | ||
|
|
7df8b369e4 | ||
|
|
40394ff76e | ||
|
|
713285b554 | ||
|
|
13b474d302 | ||
|
|
23feb16b0f | ||
|
|
3d75b9e7ee | ||
|
|
da9beb9324 | ||
|
|
803cddd5cb | ||
|
|
5f99cd25ec | ||
|
|
064d51f7be | ||
|
|
1ec21c59ce | ||
|
|
ce5f69b586 | ||
|
|
3f1a0c0603 | ||
|
|
0868bb97f6 | ||
|
|
d5545243c5 | ||
|
|
22c22d1876 | ||
|
|
f882626612 | ||
|
|
72983215fc | ||
|
|
5e9c14db83 | ||
|
|
64fd9ba1c2 | ||
|
|
f1c617ad27 | ||
|
|
f6006358ed | ||
|
|
7164373fb3 | ||
|
|
24ff5c7ea7 | ||
|
|
acc87a9e22 | ||
|
|
7ca3c1e08f | ||
|
|
505d2cb586 | ||
|
|
28e0ba7104 | ||
|
|
140c18c55a | ||
|
|
876cd5e4a5 | ||
|
|
b48fcc42ae | ||
|
|
b920d5f404 | ||
|
|
57983fb4c2 | ||
|
|
b43970eecc | ||
|
|
b0e0945360 | ||
|
|
a7160c62be | ||
|
|
167aa947ce | ||
|
|
de2138e84c | ||
|
|
f0e0ea42ce | ||
|
|
89ef90af04 | ||
|
|
5597c81e7c | ||
|
|
97630a598a | ||
|
|
3744a97f00 | ||
|
|
59646e52f9 | ||
|
|
38e037d3a7 | ||
|
|
ca71b27791 | ||
|
|
b7fe29b5cc | ||
|
|
12b8fd0c1d | ||
|
|
474d44c4db | ||
|
|
05f668dfa7 | ||
|
|
93c1f6cd45 | ||
|
|
3e39bb2cf4 | ||
|
|
194a547955 | ||
|
|
00e8e3b997 | ||
|
|
2f171c7d79 | ||
|
|
a1508a386e | ||
|
|
1c727842df | ||
|
|
fb777c1c93 | ||
|
|
bd2342dc81 | ||
|
|
5c513684f5 | ||
|
|
ba5a1e25ee | ||
|
|
4e3c1628b2 | ||
|
|
7fe95b76e9 | ||
|
|
4e2e0d6900 | ||
|
|
2120130997 | ||
|
|
10916227cb | ||
|
|
c808bfe609 | ||
|
|
b66d02df7c | ||
|
|
0561277dcb | ||
|
|
e9a234094b | ||
|
|
a536e7879c | ||
|
|
29fb756a5c | ||
|
|
35c612c5f6 | ||
|
|
a6f229d870 | ||
|
|
1eded0468f | ||
|
|
bd087d6096 | ||
|
|
86eca5abf3 | ||
|
|
0f51eb21ba | ||
|
|
9f6e0ef60f | ||
|
|
5fd5d688dc | ||
|
|
d80683c44b | ||
|
|
56c5d03b65 | ||
|
|
b8bfeb7aa2 | ||
|
|
3b3895f82e | ||
|
|
7214f71878 | ||
|
|
d09d1ddad1 | ||
|
|
8e635c9a3a | ||
|
|
1959b33c48 | ||
|
|
daaaa348b7 | ||
|
|
0f237a32d4 | ||
|
|
4ecbe54997 | ||
|
|
4999f6dc60 | ||
|
|
3d8e1ef00f | ||
|
|
326c41df9c | ||
|
|
beca41c676 | ||
|
|
a29d5a6874 | ||
|
|
839442b20b | ||
|
|
c174742e2c | ||
|
|
4c383214f7 | ||
|
|
5d480a5fc2 | ||
|
|
7712ecf6ed | ||
|
|
256d7c273e | ||
|
|
d921a1b801 | ||
|
|
cd7e29c635 | ||
|
|
ada7d8852f | ||
|
|
7844e765b4 | ||
|
|
5b12f51e98 | ||
|
|
f747c4828e | ||
|
|
a310cbc2f0 | ||
|
|
d983ef23d8 | ||
|
|
e324e2231c | ||
|
|
73336b297e | ||
|
|
6390a3346f | ||
|
|
1763143337 | ||
|
|
a331616a9d | ||
|
|
ca9dbea060 | ||
|
|
9b400622af | ||
|
|
3792d7377e | ||
|
|
1b66d284c4 | ||
|
|
f26586bb3e | ||
|
|
6b3f7a3ec1 | ||
|
|
83b9940733 | ||
|
|
91461381ee | ||
|
|
cd0956eef7 | ||
|
|
fa085cbd64 | ||
|
|
48c8a64c97 | ||
|
|
59db4ac227 | ||
|
|
15208b5c40 | ||
|
|
80a0954030 | ||
|
|
8d3c929740 | ||
|
|
7688e53424 | ||
|
|
558b0275a4 | ||
|
|
50559723a5 | ||
|
|
e20e858ba8 | ||
|
|
9b1967a59e | ||
|
|
d39256e700 | ||
|
|
97816a35de | ||
|
|
d06e485c12 | ||
|
|
7bf268ec42 | ||
|
|
661e1d7fa3 | ||
|
|
aec4f67ca9 | ||
|
|
5a87fb3b15 | ||
|
|
548e7f23d1 | ||
|
|
daff74fe04 | ||
|
|
a1bd7ff37c | ||
|
|
672191497a | ||
|
|
1c4c903ac6 | ||
|
|
c09865e582 | ||
|
|
83fc895442 | ||
|
|
e4f526415d | ||
|
|
f4a4df5044 | ||
|
|
3e23b4cd39 | ||
|
|
cefbad3c2a | ||
|
|
c1dd4171db | ||
|
|
c7ff33ac2e | ||
|
|
77768e9fff | ||
|
|
5d0c367315 | ||
|
|
db73c6908e | ||
|
|
2a6ba1180d | ||
|
|
c6706a5a52 | ||
|
|
c869b248b7 | ||
|
|
2b5a5128fe | ||
|
|
d604b97a4d | ||
|
|
8f6671b6c2 | ||
|
|
5acf254f8b | ||
|
|
60444cca53 | ||
|
|
1d418fd5af | ||
|
|
52863a9f42 | ||
|
|
8abc99aa0a | ||
|
|
4fde920665 | ||
|
|
2bec3d521b | ||
|
|
202a310c7a | ||
|
|
acd75efc7c | ||
|
|
e2dacd92ea | ||
|
|
38b95fb3b1 | ||
|
|
9688f15af7 | ||
|
|
edf0392248 | ||
|
|
c90e212049 | ||
|
|
e312a007ab | ||
|
|
2aa0ae851f | ||
|
|
702ecc38e2 | ||
|
|
f11a532139 | ||
|
|
6f054408da | ||
|
|
166190b710 | ||
|
|
392141bfc5 | ||
|
|
e2f9952867 | ||
|
|
361f1c0fb8 | ||
|
|
c29ccd55d6 | ||
|
|
19372875e2 | ||
|
|
d9e2c8a7e7 | ||
|
|
a15747ca61 | ||
|
|
1371a86713 | ||
|
|
603f428397 | ||
|
|
2110d06e36 | ||
|
|
e38672b45d | ||
|
|
d19e9f8b93 | ||
|
|
ff799d1520 | ||
|
|
e214129e45 | ||
|
|
34d43c7e3f | ||
|
|
8e5e3cdd3b | ||
|
|
d313b6e62c | ||
|
|
48b889590d | ||
|
|
d59b4d05bb | ||
|
|
777aa62586 | ||
|
|
99264e80d6 | ||
|
|
83e6acc34d | ||
|
|
4682757e71 | ||
|
|
bd57cf5f43 | ||
|
|
db9b61dc55 | ||
|
|
c18af6ad1c | ||
|
|
35bfe203a6 | ||
|
|
36b675b928 | ||
|
|
09e2fd96a4 | ||
|
|
1db3290e0b | ||
|
|
affa2164e0 | ||
|
|
3808f1fa7e | ||
|
|
200844998b | ||
|
|
fd2a21c0c6 | ||
|
|
c78c872f92 | ||
|
|
7411ebaf1c | ||
|
|
209dda5507 | ||
|
|
94049c5667 | ||
|
|
07a239fd82 | ||
|
|
8706cafdd1 | ||
|
|
939264d30e | ||
|
|
346245e8e7 | ||
|
|
ad58a0bdc4 | ||
|
|
b5a78826f8 | ||
|
|
37a96072fa | ||
|
|
0f41fb04cd | ||
|
|
7dec7883aa | ||
|
|
fbbc4cda5c | ||
|
|
1e1ddfca0f | ||
|
|
34d53edc91 | ||
|
|
c43f58dc3f | ||
|
|
bea76df454 | ||
|
|
2b7f832f45 | ||
|
|
a194da21dd | ||
|
|
abea3f2feb | ||
|
|
914df05346 | ||
|
|
cbd3b13afb | ||
|
|
8acb526d1f | ||
|
|
3c53d63def | ||
|
|
6a6703e0c6 | ||
|
|
58b7d2d95e | ||
|
|
05b13e9a78 | ||
|
|
c55d4d7c25 | ||
|
|
6439575d15 | ||
|
|
3c163d09a1 | ||
|
|
5c044a3d8d | ||
|
|
4fc813e175 | ||
|
|
b22e6d519a | ||
|
|
b79ab84a1a | ||
|
|
a4ce7381d8 | ||
|
|
f0e28a83cb | ||
|
|
be7187cea7 |
No files matched your search
@@ -83,6 +83,12 @@ OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
||||
# keep them while debugging; delete the log when you are done.
|
||||
GEV_VOICE_LOG_CONTENT=
|
||||
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
||||
# Optional: Gemini Live voice, selected in the GEV MIC provider chooser.
|
||||
# Server-only key. Do not prefix with VITE_. Provider Settings can save it.
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_LIVE_MODEL=gemini-3.8-live
|
||||
# Token starts per IP per minute; 0 disables the throttle, not provider quotas.
|
||||
# GEV_RATELIMIT_GEMINI_PER_MIN=30
|
||||
# Per-IP rate limit for the OpenAI cost endpoints
|
||||
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
||||
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
|
||||
@@ -118,8 +124,8 @@ OPENSKY_AUTH_MODE=oauth
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
|
||||
# Optional: higher Launch Library 2 request allowance. Public access works
|
||||
# without a token.
|
||||
# Launch Library 2: leave blank for standard public access (no key needed).
|
||||
# Optional paid Patreon tokens raise request limits; there is no free API key.
|
||||
LL2_API_TOKEN=
|
||||
|
||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
||||
@@ -201,6 +207,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams
|
||||
# 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
|
||||
# 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
|
||||
@@ -221,6 +228,9 @@ 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_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
|
||||
# CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras
|
||||
# CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog
|
||||
# CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras
|
||||
@@ -228,6 +238,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
|
||||
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
|
||||
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
|
||||
# CCTV_VEGVESEN_HEIGHTS=1 # set 0 to skip Kartverket ground heights for Statens vegvesen cameras (flat prior instead)
|
||||
# CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL
|
||||
# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement ([email protected]) for the camera feed
|
||||
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
|
||||
@@ -237,6 +248,11 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
|
||||
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
|
||||
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
|
||||
# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md)
|
||||
# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first)
|
||||
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
||||
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
||||
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
@@ -67,8 +67,6 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
GEV_REQUIRE_ALLOCATION_GATE: '0'
|
||||
# Fail on a missed header press unless the panel itself moved.
|
||||
QA_FAIL_ON_RETRY: '1'
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
@@ -104,10 +102,6 @@ jobs:
|
||||
- name: Street Level gate with Mapillary fixtures
|
||||
run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173
|
||||
|
||||
- name: Portable panel gate
|
||||
if: success() || failure()
|
||||
run: npm run qa:panel-resize -- --url http://localhost:4173
|
||||
|
||||
- name: Preview server log
|
||||
if: failure()
|
||||
run: cat preview.log
|
||||
|
||||
@@ -16,6 +16,10 @@ output/
|
||||
.gev-cache/
|
||||
# Browser QA gate evidence (screenshots, console and network dumps).
|
||||
qa-artifacts/
|
||||
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
|
||||
.playwright-mcp/
|
||||
# Local Claude Code settings and preview configs.
|
||||
.claude/
|
||||
.DS_Store
|
||||
.gstack/
|
||||
3d-models/
|
||||
|
||||
@@ -317,9 +317,8 @@
|
||||
[NotProniss](https://github.com/NotProniss) (#1084).
|
||||
|
||||
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
|
||||
(orange; off by default, one provider at a time): from orbit dots show
|
||||
where the federated commons has pictures, and below about 60 km its
|
||||
sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
|
||||
(orange; off by default, one provider at a time): below about 60 km the
|
||||
federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. 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 and ‹ › /
|
||||
← → following the picture's own links. The 360° and since filters apply,
|
||||
@@ -377,10 +376,10 @@
|
||||
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
|
||||
open Google's own panorama in
|
||||
the panel, with its arrows, the address and month in the caption, a link
|
||||
to Google Maps and FOLLOW. Share links carry it in the provider switches
|
||||
to Google Maps. Share links carry it in the provider switches
|
||||
(`r.google-1`). One provider is lit at a time (lighting one switches the
|
||||
other off), Mapillary by default since it is free. Street View's panoramas
|
||||
are all 360°, so the IMAGERY and SINCE filters and the legend apply to
|
||||
are all 360°, so the IMAGERY and SINCE filters apply to
|
||||
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
|
||||
is a Google-blue view cone.
|
||||
|
||||
@@ -419,21 +418,16 @@
|
||||
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
|
||||
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
|
||||
|
||||
- Street Level: a street-level imagery layer modelled on the iD editor's photo
|
||||
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
|
||||
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
|
||||
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
|
||||
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit
|
||||
down to a single street; on Google 3D they sit on the bare earth, refined
|
||||
against the rendered mesh near the camera, so trees and buildings hide them.
|
||||
Share links carry the provider switches, the filters and the panel's collapsed
|
||||
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
|
||||
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
|
||||
rejects the token or rate-limits.
|
||||
|
||||
- The CCTV and Street Level panels are portable: drag the header to float the
|
||||
panel, resize it from any edge or corner, and double-click the header to dock
|
||||
it again. The position persists across reloads.
|
||||
- Street Level: street-level imagery from providers, Mapillary first (free
|
||||
client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider,
|
||||
360°/flat and captured-since filters and an embedded viewer with EXPAND and
|
||||
FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and
|
||||
Google 3D tiles; opening a photo frames it with one camera flight. Share
|
||||
links carry the provider switches, the filters and the panel's collapsed
|
||||
state. A server proxy adds the token to Mapillary coverage tiles, trims and
|
||||
caches them for 24 h, refuses cross-site requests and redirects, and limits
|
||||
load per IP and upstream. The panel reads KEY REQUIRED without a token and
|
||||
KEY REJECTED when Mapillary refuses it.
|
||||
|
||||
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
|
||||
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
|
||||
|
||||
@@ -54,9 +54,8 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
|
||||
Runnable gate entrypoints document what they assert and how to invoke them.
|
||||
Most need a dev server; some also need a specific provider key or port. Follow
|
||||
the entrypoint's header rather than assuming every matching file accepts the
|
||||
same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
|
||||
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
|
||||
`qa:transit`.
|
||||
same arguments. Four have an `npm run` alias: `qa:map-source-tray`,
|
||||
`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`.
|
||||
|
||||
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
|
||||
for the feature: it names the gate for many of them, and it's the authoritative
|
||||
@@ -66,11 +65,10 @@ gate for the feature you changed.
|
||||
|
||||
> **CI covers only two of these for you.** The workflow runs the setup policy
|
||||
> checks, the formatting and package-boundary checks, the unit suite and the
|
||||
> production build, plus a Windows onboarding job, and against the built preview
|
||||
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
|
||||
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
|
||||
> server and a browser. Include the applicable local run in your PR's
|
||||
> validation evidence.
|
||||
> production build, plus a Windows onboarding job. It runs neither
|
||||
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
|
||||
> gate (`street-level-browser` job) — they need a live dev server and a
|
||||
> browser. Include the applicable local run in your PR's validation evidence.
|
||||
|
||||
## Share-link layer tokens
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ How to read this:
|
||||
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
|
||||
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
|
||||
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
|
||||
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
||||
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
||||
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
|
||||
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
|
||||
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
|
||||
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
|
||||
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
|
||||
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
|
||||
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
|
||||
@@ -48,6 +48,7 @@ How to read this:
|
||||
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
|
||||
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
|
||||
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
|
||||
| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) |
|
||||
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
|
||||
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
|
||||
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
|
||||
@@ -62,7 +63,9 @@ How to read this:
|
||||
| **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 |
|
||||
| **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" |
|
||||
| **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) |
|
||||
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
||||
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
@@ -110,6 +113,7 @@ 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).
|
||||
|
||||
- **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)).
|
||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||
@@ -152,7 +156,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **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.
|
||||
- **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 keyless `511on.ca/api/v2/get/cameras` endpoint; 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.
|
||||
- **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.
|
||||
@@ -162,10 +166,13 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
|
||||
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public/<cameraId>/manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover.
|
||||
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
||||
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
|
||||
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
||||
- **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.
|
||||
|
||||
@@ -309,6 +309,10 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
|
||||
|
||||
## 🎙️ Talk to It
|
||||
|
||||
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
||||
|
||||
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
|
||||
|
||||
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||
|
||||
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
|
||||
@@ -377,15 +381,15 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🌍 **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) · 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), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **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 and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **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) | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
||||
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
||||
@@ -421,9 +425,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
**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.
|
||||
|
||||
**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. 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 and legend 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 under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and 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**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). 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. 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.
|
||||
|
||||
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. 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`.
|
||||
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`.
|
||||
|
||||
## 🎖️ Field Missions
|
||||
|
||||
@@ -527,9 +531,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di
|
||||
| | Key | Why | Get it |
|
||||
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
|
||||
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
|
||||
| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) |
|
||||
|
||||
Add these if you need higher polling allowances.
|
||||
Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional
|
||||
paid Patreon upgrades for higher limits; there is no free API-key signup.
|
||||
|
||||
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
|
||||
where each configured provider was found without printing credential values.
|
||||
|
||||
@@ -29,7 +29,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
|
||||
|
||||
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
|
||||
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
|
||||
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
|
||||
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
|
||||
|
||||
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
|
||||
|
||||
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -26,8 +26,6 @@ export function panelBuildConfig(config) {
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
...config.build?.rollupOptions,
|
||||
// The panel is the app alone: one page, so one inlined script.
|
||||
input: undefined,
|
||||
output: { inlineDynamicImports: true },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { applicationHtmlPlugin } from './application-html.js';
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
|
||||
/** HTML documents a production build emits: the app and its mobile shell. */
|
||||
export const BROWSER_PAGES = Object.freeze({
|
||||
main: fileURLToPath(new URL('../index.html', import.meta.url)),
|
||||
mobile: fileURLToPath(new URL('../mobile/index.html', import.meta.url)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for every document the dev/preview server serves.
|
||||
* No inline script is permitted, and the only foreign script origins are the
|
||||
@@ -120,9 +113,6 @@ export function createBrowserViteConfig({
|
||||
mapillaryToken ?? '',
|
||||
),
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rollupOptions: { input: { ...BROWSER_PAGES } },
|
||||
},
|
||||
build: { chunkSizeWarningLimit: 1500 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an
|
||||
MCP client can ask which airlines, countries, aircraft types or ship types
|
||||
dominate an area, or the average altitude or speed, over every match instead
|
||||
of estimating from the returned rows. Airlines come from the ICAO designator
|
||||
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
|
||||
that report no operator; operator and type are counted only where the feed
|
||||
reports them. Ship types count by family, so AIS codes 70 to 79 are all
|
||||
cargo; type 0 ("not available") and the reserved codes 100 to 255 count as
|
||||
unknown. Altitude and speed stats cover airborne aircraft only, so parked
|
||||
aircraft do not pull the means down. Every vessel answer, `find_vessel`
|
||||
included, now reads AIS ship-type codes as their family and a destination
|
||||
with no letters as unknown (quanticsoul4772, #1056).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Share Launch Library request cache keys across installations refreshing in the
|
||||
same UTC minute, identify the app to the provider, and explain that standard
|
||||
space-missions access needs no key. Optional higher-rate LL2 tokens are paid
|
||||
upgrades; existing local cache and server-only token handling are unchanged
|
||||
(rudycelekli, #1197).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep Space Missions marked STALE when Launch Library 2 falls back to cached
|
||||
launch data, and return to NOMINAL after a fresh response without discarding
|
||||
retained missions (rudycelekli, #1199).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or
|
||||
WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen
|
||||
video camera could only be found by opening it. Stills stay cyan and the
|
||||
active camera stays amber, and focus de-emphasis fades the violet the same
|
||||
way (Danielslay86, #1200).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Draw CCTV stills at their source aspect on the monitor plane instead of
|
||||
stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so
|
||||
every still was widened; they are now centered and pillarboxed on the dark
|
||||
the placeholder already ends on (Danielslay86, #1201).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Report actual outcomes when voice enables or disables CCTV through the shared
|
||||
layer lifecycle, including failed and cancelled changes (manjunath22466,
|
||||
#1205).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI
|
||||
command, while preserving an earlier accepted deferred input
|
||||
(Manjunath, #1220).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a
|
||||
free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is
|
||||
otherwise skipped with one log line saying where to get a key
|
||||
(quanticsoul4772, #1228).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects,
|
||||
cancel both response streams when either response fails or overflows the 4 MB
|
||||
byte cap, and deduplicate camera IDs before per-pack prioritization
|
||||
(yaka0007, #1233).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Serve the camera catalog while it refreshes. An expired catalog made every
|
||||
camera request wait for the slowest pack to reload; it is now answered at
|
||||
once while one background refresh runs, so only the first load waits. Estonia's
|
||||
slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out
|
||||
on about a third of loads (quanticsoul4772, #1234).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Require a geocoded containment anchor for ambiguous analyst region names,
|
||||
preserve boundary provenance and cancellation safety, and retain exact-name
|
||||
precedence for natural outlines (manjunath22466, #1235).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV:
|
||||
~1,800 cameras read at runtime from the official MOTC 2.0 open-data table,
|
||||
with on-demand snapshots and attribution. No API key is required; set
|
||||
`CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Gate CCTV HLS media requests before session work, bind leases to the direct
|
||||
peer, and bound per-peer leases across shared sessions (asturksever, #1267).
|
||||
- Keep headful CCTV QA rendering active when its window is occluded (asturksever, #1267).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Report cached CelesTrak satellite records as stale in the Data Layers row,
|
||||
preserving separate core and dense catalog provenance through refresh,
|
||||
removal and lifecycle cleanup (rudycelekli, #1283).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep aircraft route labels on the scheduled airport leg instead of accepting
|
||||
its distant great-circle extensions, preserving generous endpoint tolerance
|
||||
across the signed-angle branch cut (rudycelekli, #1284).
|
||||
@@ -0,0 +1,4 @@
|
||||
- The Street Level SINCE note sits on the value's line, in its type and a
|
||||
size smaller: with KartaView lit it reads ANY DATE (by upload date). It
|
||||
had lost its size variable in the Street Level slim-down and rendered
|
||||
larger than the value, on a line of its own (asturksever, #1286).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Give ordinary sliders a shared native control and consistent track, thumb,
|
||||
value, focus and disabled styling while preserving feature behavior and
|
||||
specialized controls (mazy1998, #1287).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Unify segmented selectors for model coverage, detection allocation, drawing
|
||||
shape, Street View and imagery comparison while preserving their existing
|
||||
behavior. Keep compact labels such as FILL and FLAT on one line, and let
|
||||
collapsed Weather release space to the open rail panel. Reserve the full mode
|
||||
button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack:
|
||||
the statewide ArcGIS catalog, read a page at a time under a byte cap with
|
||||
redirects refused, and frames pinned to FL511's two image hosts over HTTPS.
|
||||
FL511's terms limit its content to individual, personal use, and any other
|
||||
use (a public deployment included) needs FDOT's express written consent, so
|
||||
the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the
|
||||
catalog's direction codes, and camera ids come from the image channel, since
|
||||
the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES`
|
||||
sets the cap (Danielslay86, #605).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Bound what a caller can grow through `/api/terrain/heights`. Every distinct
|
||||
5-decimal point was a permanent in-memory entry, a permanent line in an
|
||||
uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with
|
||||
nothing limiting the rate. The point cache now holds at most 50,000 entries
|
||||
and evicts oldest-first, a cache file above
|
||||
24 MB is not read back, and the route is limited to 90 requests/minute per
|
||||
client (270 global). Evicting a point costs one refetch, never a wrong
|
||||
answer, since terrain does not move. Ordinary use is unaffected: a full
|
||||
`test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686).
|
||||
@@ -0,0 +1,7 @@
|
||||
- Close the compact Radio disclosure when an adaptive tactical right-rail
|
||||
restore directly expands Context, before Space Missions can be covered.
|
||||
A repeated Context-open request also clears stale disclosure state.
|
||||
Real PanelChrome constructor wiring and the rail reflow path are covered
|
||||
by focused regressions. Credits: kvnloo (#816), raysung-2026's original
|
||||
#804 report, asturksever's review, and quanticsoul4772's independent
|
||||
Windows diagnosis.
|
||||
@@ -0,0 +1,4 @@
|
||||
- Add optional Gemini Live voice to the standalone app, with a provider chooser,
|
||||
server-held API key, microphone streaming, shared map actions and captions.
|
||||
Switching providers ends the old conversation and preserves the input mode
|
||||
(Manjunath, #872).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Extract a reusable model-free World Packet from the existing voice
|
||||
`get_current_view_state` owner without restoring disabled layers to the
|
||||
model context. Keep the enabled-only payload, disabled-layer count, source
|
||||
provenance, action result and view-state contract; read the layer roster once
|
||||
per packet; voice retains the speech/card envelope at its own boundary.
|
||||
Credits: original GEV voice/layer contributors and #880 implementation.
|
||||
@@ -0,0 +1,17 @@
|
||||
- Give each Statens vegvesen camera its own ground height from Kartverket's
|
||||
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
|
||||
mountain-pass cameras no longer sit hundreds of metres below the terrain
|
||||
where neither the point-height prior nor the ground snap resolves. About 850
|
||||
of ~855 cameras get a height; the rest keep the old prior. Requests carry 20
|
||||
points, the most the API answers from its 1 m terrain model, eight at a
|
||||
time; a cold lookup takes about 6–13 s. Cameras over water use the water
|
||||
surface rather than the sea-floor depth the API returns there; for bridge
|
||||
cameras that is still below the deck. Heights are cached for good and misses
|
||||
for 6 h, so a refresh only asks about new or moved cameras. After a
|
||||
failing request (a 408, 429 or 5xx, a network error, a request over 10 s or
|
||||
an unreadable body) the lookup stops and backs off (Retry-After, else
|
||||
30 min, longer than the refresh). The lane waits at most 12 s from its
|
||||
start, and only on a cold cache; a slower first lookup leaves the cameras it
|
||||
had not reached on the flat prior until the next 15-minute refresh, and
|
||||
keeps filling the cache meanwhile.
|
||||
`CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Street Level is rebuilt around one small provider registry: Mapillary,
|
||||
KartaView, Panoramax and Google Street View share one coverage drawer and
|
||||
one photo viewer, one source lit at a time. Floating and docked panels,
|
||||
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
|
||||
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
|
||||
deleted (gekh, #974).
|
||||
@@ -0,0 +1,8 @@
|
||||
- Cross-site pages can no longer fill the terrain-height cache or spend the
|
||||
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
|
||||
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
|
||||
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
|
||||
the cost-bearing routes it still answers same-origin requests that arrive
|
||||
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
|
||||
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
|
||||
#975).
|
||||
@@ -0,0 +1,7 @@
|
||||
- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV
|
||||
source pack: the public DATA.GOV.HK English locations XML, frames pinned to
|
||||
`tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By
|
||||
default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads,
|
||||
prioritized across Hong Kong Island, Kowloon and the New Territories. Headings
|
||||
use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets
|
||||
the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990).
|
||||
@@ -356,8 +356,8 @@ drag or final change. Feature adapters own data mapping and service subscription
|
||||
generic modules own DOM/listeners/timers and release them in `destroy()`.
|
||||
|
||||
Keep persistence with panel chrome: collapse uses
|
||||
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
|
||||
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
|
||||
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
|
||||
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
|
||||
Do not rename existing
|
||||
ids or reset persistence versions when adding a body. The WEATHER adapter is
|
||||
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
Analyst region queries resolve unique Natural Earth names offline without
|
||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||
Duplicate names require a geocoded containment anchor; when several entries for
|
||||
the same name contain that anchor, the lookup stays unresolved rather than
|
||||
selecting the largest entry. Natural outlines try exact names before aliases and
|
||||
broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
||||
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||
search keeps a successful geocode, with no search or view-based region recovery.
|
||||
|
||||
CCTV live-media requests are same-site gated before catalog or media work, including
|
||||
HLS renewal, segment reads and DELETE cleanup. HLS leases belong to their socket
|
||||
peer (IPv4-mapped IPv6 is normalized), with eight leases per peer across the two
|
||||
global sessions. Peers behind the same NAT share that budget; this is resource
|
||||
admission, not authentication. Native same-origin video and range requests remain
|
||||
supported.
|
||||
|
||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
|
||||
@@ -59,24 +75,6 @@ provider registration rejects compositions that cannot fit rather than truncatin
|
||||
identities. A malformed or duplicate entry invalidates the complete custom-switch
|
||||
field. Existing whole-link size limits still apply.
|
||||
|
||||
## Mobile app prototype — October 18, 2026
|
||||
|
||||
`/mobile/` is a phone and foldable shell over the same engine, specified in
|
||||
[`mobile/DESIGN.md`](../mobile/DESIGN.md). It has five tabs: a situation feed,
|
||||
the globe, capture, reconstruction threads and the user's own captures.
|
||||
`mobile/globe/` is a phone-tuned Cesium engine. It blends Esri day imagery
|
||||
with NASA Black Marble night lights across the terminator, uses Google 3D
|
||||
tiles below 25 km when keyed, and builds its layers on the existing `/api`
|
||||
routes and source parsers. Capture signs each bundle on the device with a
|
||||
non-extractable ECDSA P-256 WebCrypto key held in IndexedDB. A bundle carries
|
||||
a SHA-256 media hash, a per-device hash chain, and the IMU and GPS trace.
|
||||
These signatures are browser equivalents of hardware attestation; they are
|
||||
not hardware attestation. Hypotheses and authors in the reconstruction
|
||||
threads are labelled example content. `npm run build` now emits
|
||||
`dist/mobile/index.html` beside the app, while `npm run build:panel` still
|
||||
builds the app page alone. `mobile/vision/index.html` is a self-contained
|
||||
concept mockup with illustrative data and no production code.
|
||||
|
||||
## God's Eye View in conversations — October 2, 2026
|
||||
|
||||
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
||||
@@ -130,7 +128,14 @@ perimeters, terrain height, military installations and map features, plus a
|
||||
combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that
|
||||
opens the app over an area. Tools reuse the layers' portable source factories, take a
|
||||
shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||
lists at 25 rows by default. Voice offers the same queries next to its app
|
||||
lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also
|
||||
count every match by a field (`group_by`: airline, read from the ICAO
|
||||
designator in the callsign, or operator, country or type for aircraft; type or
|
||||
destination for ships; the ten largest groups, then other and unknown counts;
|
||||
the summary says when a feed reports none of the field) and summarize airborne
|
||||
altitude and speed (`stats`: count, min, median, mean, max).
|
||||
Both cover all matches, not just the rows returned; voice leaves these two
|
||||
tools out, so they reach MCP clients only. Voice offers the same queries next to its app
|
||||
actions: the session lists them, and the browser runs them through the same
|
||||
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
||||
Aircraft altitude-band queries require a reported barometric or geometric altitude
|
||||
@@ -507,6 +512,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
||||
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||
share one deterministic count/scope headline; lower bounds use "At least" and
|
||||
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||
The model-free `gods-eye-view/agent/world-packet` export exposes
|
||||
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
||||
non-voice consumers. It reads the same enabled-only state and feed
|
||||
provenance before voice adds speech/card fields, with no new network requests
|
||||
or mutations.
|
||||
The voice card nested in the mic control (`voiceCard.js`) shows captions, plan
|
||||
steps and the result. `resultDisplay.js` is the one adapter from a result to
|
||||
the card: builder results show as built, and analyst results show a title
|
||||
@@ -624,10 +634,20 @@ Reference feed construction is exported through `sources/reference`; the cable s
|
||||
|
||||
Source factories have dedicated `layers/<family>/source` exports. ALPR and earthquake record normalization and CCTV source policy no longer pull rendering into source consumers. Catalog construction and voice feed reads use their focused owners; compatibility entries remain available. Settings filesystem hardening lives under `server/standalone/`. Import-direction checks complement export ownership checks; source behavior, settings policy and rendering are unchanged.
|
||||
|
||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner.
|
||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
|
||||
|
||||
The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
|
||||
|
||||
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
|
||||
layer visibility lifecycle. Results distinguish successful changes from
|
||||
manager-reported failures or cancellation and report enabled state and lifecycle
|
||||
uncertainty. A caller-supplied abort signal reaches the manager; a late abort
|
||||
preserves an already committed change. Missing-layer errors and other CCTV
|
||||
actions retain their existing behavior. The exported `controlCctv` helper refuses direct
|
||||
enable/disable requests and directs callers to `set_layer_visibility`.
|
||||
|
||||
`src/voice/layerManifest.js` declares each catalog layer's voice capabilities: spoken aliases, `toggle`, in-view `context`, `track`, and analyst `query` fields with type and unit, or `noQuery` with the reason. The `set_layer_visibility`, `show_data_layers_menu`, `get_entity_context` and `analyst_query` layer enums, the runner's alias table and the analyst field hints are generated from it. `src/voice/layerManifest.test.mjs` fails when a catalog layer is neither listed nor named in `VOICE_OFF_LAYERS` (today only the two scene-driven Bhote Koshi layers). Global Context is listed with `toggle: false`; `set_context_mode` reaches it. `set_panel_open` also opens `weather-panel` and `recent-imagery-panel`.
|
||||
|
||||
Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged.
|
||||
@@ -655,7 +675,7 @@ The UI facade composes separate owners for navigation authority, share restorati
|
||||
visual settings and panel chrome. Navigation owns request generations and follow
|
||||
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
|
||||
visual settings own style/detection overrides and sensor cleanup; panel chrome
|
||||
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
|
||||
owns disclosure and Cockpit panel snapshots. Each takes explicit
|
||||
operations and reads instead of the complete shell. Existing controls, share
|
||||
formats and one-application-per-page behavior are preserved.
|
||||
|
||||
@@ -821,7 +841,11 @@ Viewport limits, saturation retry, placement, query caps and controls are retain
|
||||
|
||||
Satellite and mission layers expose separate factories through `./layers/satellites`
|
||||
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
|
||||
and teardown. Source adapters retain the existing CelesTrak and Launch Library
|
||||
and teardown. Satellite core and dense catalogs retain upstream stale-cache
|
||||
provenance only for admitted records; duplicate or invalid entries cannot taint
|
||||
a fresh winning record. Dense removal clears only its own provenance, and a
|
||||
fresh core rebuild or lifecycle reset replaces the corresponding ownership.
|
||||
Source adapters retain the existing CelesTrak and Launch Library
|
||||
endpoints; standalone entries provide scene services and the satellite dependency.
|
||||
The mission factory separates paths, replay, camera, panel, overlays and placement.
|
||||
Launch Library pad coordinates retain unknown values: missing fields use the
|
||||
@@ -830,7 +854,11 @@ position stay off the map. Genuine zero coordinates remain valid. Undisclosed
|
||||
stage coordinates, downrange distance and flight numbers remain unknown; the
|
||||
mission panel omits the flight-number label when no number was reported.
|
||||
Pending mission requests abort on disable/destruction and malformed launch
|
||||
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
|
||||
snapshots preserve the last accepted display. A Launch Library stale-cache fallback
|
||||
retains its missions but reports STALE in Data Layers; a fresh snapshot clears that
|
||||
provenance. Hiding retained missions does not relabel them as fresh, and teardown
|
||||
clears the instance state. Injected getLaunches-only sources remain supported.
|
||||
Catalog groups, propagation cadence,
|
||||
tracking intent, replay timing, controls and source attribution remain unchanged.
|
||||
|
||||
The fire layer now exposes `./layers/firms`: an instance factory with separate
|
||||
@@ -977,10 +1005,9 @@ start with current state, isolate listener failures and stop on disposal.
|
||||
|
||||
The standalone entry composes the UI with the application's existing layer,
|
||||
terrain, navigation and rendering operations. The shell receives those instances
|
||||
and assembles the controls. Panel layout scheduling, position preferences and
|
||||
active drags, loading notices, recording presentation and DOM lookup have focused
|
||||
and assembles the controls. Panel layout scheduling, loading notices, recording presentation and DOM lookup have focused
|
||||
owners. Disposal revokes queued presentation and listeners before asynchronous
|
||||
Context restoration, cancels an unfinished drag without saving it, restores the
|
||||
Context restoration, restores the
|
||||
recording HUD and releases status decoration without replacing accessible text.
|
||||
The ordinary control snapshot includes the current 3D model toggle and mode.
|
||||
|
||||
@@ -1093,6 +1120,26 @@ The images are periodically updated road-condition photographs, not continuous
|
||||
video; camera poses use the existing low-confidence heading prior.
|
||||
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
|
||||
|
||||
## Taiwan CCTV source pack
|
||||
|
||||
The CCTV catalog includes Taiwan national freeway cameras from the Freeway
|
||||
Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table
|
||||
(~1,800 cameras); the request refuses redirects, has a 15-second deadline and a
|
||||
6 MiB response cap, and uses the shared 15-minute cache and fair catalog
|
||||
allocation. A failed download serves the last good table (up to a day old),
|
||||
or an empty pack, and is not retried for an hour: the catalog host does not
|
||||
answer from some networks outside Taiwan, and without the backoff every refresh
|
||||
there would wait out the connect timeout. The table
|
||||
publishes MJPEG stream URLs; each is mapped to the same camera server's
|
||||
single-JPEG path for the same camera number, and only those exact HTTPS pairs
|
||||
on the four `freeway.gov.tw` camera servers are registered with the frame
|
||||
proxy. Camera keys come from the server and camera number, because published
|
||||
CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use
|
||||
the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack;
|
||||
`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000), and
|
||||
`CCTV_TAIWAN_URL` overrides the table URL.
|
||||
Gate: `src/data/cctvTaiwan.test.mjs`.
|
||||
|
||||
## DriveBC CCTV source pack
|
||||
|
||||
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
||||
@@ -1184,6 +1231,32 @@ The application supplies actions and retains effect settings, restore claims and
|
||||
rendering. Native keyboard editing, model modes and current defaults are preserved.
|
||||
Destroying the UI removes these listeners before asynchronous teardown.
|
||||
|
||||
Ordinary sliders share the native range component in `ui/slider.js` and the
|
||||
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader
|
||||
parameters, volume, replay speed, weather settings/history, imagery opacity and
|
||||
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
|
||||
Generated controls use `createSlider`; static templates apply the same class.
|
||||
Feature owners retain bounds, steps, formatted values, input/change timing and
|
||||
listener cleanup. The radio tuning dial, imagery comparison divider and event
|
||||
comparison/progression controls keep their specialized presentation and behavior.
|
||||
|
||||
## Segmented selectors
|
||||
|
||||
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
||||
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
|
||||
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
|
||||
their feature-owned values, actions and persistence. Painting a restored value
|
||||
does not emit a change or move focus. Existing keyboard policies remain explicit:
|
||||
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
|
||||
its controlled selection, and Display keeps its existing native button behavior.
|
||||
Pure value navigation is separate from DOM rendering for reuse by a future view.
|
||||
Segments keep their word minimum width: compact inline groups such as FIT/FILL
|
||||
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
|
||||
the same exclusive-rail hiding rule as the other readouts.
|
||||
The imagery mode row reserves 26px for the 24px buttons and group borders so
|
||||
the complete button and inset keyboard-focus outline remain visible.
|
||||
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`.
|
||||
|
||||
## Application shortcuts and shader parameter controls
|
||||
|
||||
`ui/input` supplies the bubbling application shortcut listener and generated
|
||||
@@ -1210,6 +1283,10 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
|
||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||
writes. This extraction does not change panel positions or layout defaults.
|
||||
Context expansion through the panel owner or adaptive right-rail restoration
|
||||
closes an open compact Radio disclosure before Context/Space Missions becomes
|
||||
visible. The mini remains available while Context is collapsed. This fixes the
|
||||
Radio-overlap path reported in #804 without changing z-index or layout policy.
|
||||
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||
collapse can still be undone.
|
||||
@@ -1459,6 +1536,18 @@ an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||
|
||||
## Terrain height cache bound
|
||||
|
||||
The `/api/terrain/heights` route refuses cross-site browser requests before
|
||||
rate limiting, cache access or upstream work: a `Sec-Fetch-Site` other than
|
||||
`same-origin`/`none` (an `<img>` on another page), or a foreign or opaque
|
||||
`Origin`, gets `403`. The per-client limiter keys on the socket address, which
|
||||
for such a request is the visitor's own browser, so without the gate any page
|
||||
could spend the visitor's 90/min quota and leave the app's own lookups on
|
||||
`429`. Unlike the cost-bearing routes, the terrain gate does not refuse
|
||||
reverse-proxy forwarding headers: terrain is keyless and needed for rendering,
|
||||
and a `403` there would send every chunk to the geoid fallback for an instance
|
||||
served behind the documented reverse proxy. Same-origin app requests,
|
||||
non-browser local callers and proxied same-origin requests keep working.
|
||||
|
||||
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
|
||||
latitudes outside [-90, 90] for every client before upstream resolution. One
|
||||
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
|
||||
@@ -1496,6 +1585,14 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
|
||||
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
||||
access. Callers retain validation, transport and response policy.
|
||||
|
||||
LL2 recent-launch requests use UTC minute-aligned bounds over the same 30-day
|
||||
window, so installations refreshing in the same minute share an upstream cache
|
||||
key. The upper bound trails the current time by less than one minute. Requests
|
||||
identify the app and repository through User-Agent; the existing 15-minute local
|
||||
cache, stale fallback and optional server-only Authorization remain unchanged.
|
||||
Provider Settings identifies LL2 tokens as optional paid higher-rate upgrades:
|
||||
standard public access needs no key, and its field can remain blank.
|
||||
|
||||
The shared TLE text parser skips incomplete named sets and resumes at the next
|
||||
complete set. A missing orbital line does not discard the later satellites in
|
||||
the catalog. A set whose two lines carry different catalog numbers, or a
|
||||
@@ -2682,6 +2779,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> tunings. Ambient overlap includes each sprite's own rendered extent. The
|
||||
> gated AIS, CCTV, and satellite passes retain an active-emphasis count so a
|
||||
> settled dim contact always completes its release after tracking ends.
|
||||
> - **Terrain-height bounds:** because every distinct 5-decimal point is its own
|
||||
> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own
|
||||
> upstream call, four limits bound what a caller can grow. Coordinates outside
|
||||
> WGS-84 range are rejected with `400` at parse (finite is not the same as on
|
||||
> Earth); the point cache holds at most 50,000 entries, evicting oldest-first
|
||||
> — terrain does not move, so an evicted point costs one refetch and never a
|
||||
> wrong answer; a cache file above 24 MB is not read back into memory (the
|
||||
> ceiling `rocketLaunchesProxy` already applies); and the route carries an
|
||||
> always-on limiter of 90 requests/minute/client, 270 global. That cap is an
|
||||
> order of magnitude above measured demand — a full `npm run test:track` run
|
||||
> drove 10 requests, peaking at 8/min.
|
||||
> - **Terrain-height resilience:** `/api/terrain/heights` caches canonical
|
||||
> 5-decimal points individually, reconstructs reordered/overlapping batches
|
||||
> in exact request order, and refreshes only missing or stale points. Upstream
|
||||
@@ -2951,6 +3059,13 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> position getter reads only `_trackedDisplayCached`, the same authoritative
|
||||
> Cartesian already consumed by the tracked visual and camera, and never
|
||||
> advances dead reckoning from the host frame.
|
||||
> - **Scheduled route plausibility:** Aircraft route labels and selected-route
|
||||
> context reject the distant backwards, forwards and antipodal extensions of
|
||||
> an airport pair's great circle. The existing 130 km endpoint admission and
|
||||
> 200 km cross-track tolerance remain; a projected fix outside the finite
|
||||
> origin-to-destination minor arc also has a generous 200 km extension at
|
||||
> either end. Missing coordinates and coincident or antipodal endpoint pairs
|
||||
> retain their loose, unjudgeable behavior; vertical-trend checks are unchanged.
|
||||
> - **Aircraft label convention (both flight layers, 2026-08-18):** every civil
|
||||
> and military label surface resolves **callsign → registration → icao24**
|
||||
> (`_contactLabel()` in `flights.js`; the same chain inline in
|
||||
@@ -3413,7 +3528,7 @@ its criteria cannot be silently ignored.
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
@@ -3422,7 +3537,7 @@ its criteria cannot be silently ignored.
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
@@ -3454,8 +3569,8 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
||||
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
|
||||
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
|
||||
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
||||
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||
collapsed. It opens itself only when the user, voice or a tool switches the
|
||||
layer on or a photo opens, never on a restore, and those opens are not stored.
|
||||
Like the iD editor's photo overlay, it has one chip per registered provider,
|
||||
@@ -3467,31 +3582,30 @@ sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers'
|
||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
||||
Street View, KartaView and Panoramax are registered. Without
|
||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
||||
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
|
||||
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||
requests for a tile.
|
||||
|
||||
Mapillary coverage ranks unique tile addresses before applying the 16-tile
|
||||
overview budget. Overlapping dateline halves in a polar view therefore do not
|
||||
waste overview slots on the same tile; loading already skips duplicate keys.
|
||||
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
|
||||
the panel says to zoom in. Tiles come from the visible ground within range of
|
||||
the camera, nearest first, at most 9 (the halves of a view across
|
||||
the date line count a shared tile once). Lines are draped
|
||||
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
|
||||
and Google 3D tiles alike; cones and the photo marker are ground-clamped
|
||||
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
|
||||
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
|
||||
pauses them for its Retry-After.
|
||||
|
||||
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
|
||||
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
|
||||
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
|
||||
cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
|
||||
the mesh hides what is behind buildings and trees. A tile is drawn draped until
|
||||
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
|
||||
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
|
||||
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
|
||||
steep streets and roads under trees. It uses the mesh floor's rules (the
|
||||
visible tileset has finished streaming, a real bare-earth prior, the shared
|
||||
mesh window) and probes a cell again once the camera is half as far from it,
|
||||
down to 40 m, so coarse early samples are replaced as finer tiles load. A
|
||||
failed probe keeps the last good sample and distance and is retried after the
|
||||
miss cooldown. Roads on elevated decks are still drawn at
|
||||
ground level. Framing a photo ignores mesh samples far below the bare earth
|
||||
(unloaded tiles).
|
||||
The server proxy (`server/providers/mapillary.js`) adds the token to
|
||||
coverage-tile requests (the browser holds it too, for MapillaryJS and the Graph
|
||||
API), serves only z11–14 coverage, refuses upstream redirects, strips the
|
||||
unused `image` layer, shares one fetch per tile, runs at most six at once
|
||||
(cancelling those every client has left) and keeps tiles in memory for 24 h
|
||||
(a body too big to be a trimmed tile is served but not kept). While Mapillary
|
||||
rate-limits, the proxy answers 429 itself until the Retry-After (seconds or an
|
||||
HTTP date, at most 10 minutes) is over, so the rest of a view does not go on
|
||||
asking.
|
||||
|
||||
The header pill is the layer switch. Lighting a chip swaps the source;
|
||||
clicking the lit chip turns the layer off. A chip without a usable key
|
||||
@@ -3502,19 +3616,18 @@ their keys but a switched-off one would draw with a key already set (no
|
||||
Mapillary token, Street View on Google 3D with a Google key), the layer
|
||||
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
|
||||
line and the layer row's tooltip say to light it. The
|
||||
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
|
||||
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
|
||||
stops when the stack changes. Camera moves go through the application's
|
||||
navigation (`attachNavigation`), which releases aircraft and satellite tracking
|
||||
and is refused in the cockpit: turning FOLLOW on claims the camera at once, and
|
||||
FOLLOW stops when another feature takes it; a photo takes a deferred ticket
|
||||
when it starts opening and frames only if nothing newer took the camera while
|
||||
it loaded. SINCE is a stepped slider whose readout names the
|
||||
cut-off date. The panel is portable (`street-level-panel` spec, minimum
|
||||
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
|
||||
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
|
||||
docks it. At phone width (≤720 px) the viewer height is derived from the rail
|
||||
band so the whole photo fits.
|
||||
viewer sits under the header with EXPAND, FIT/FILL and close above the
|
||||
image. SINCE is a stepped slider whose readout names the cut-off date.
|
||||
|
||||
MapillaryJS loads on the first photo. Opening one frames it with a single
|
||||
camera flight through the application's deferred navigation (`begin` /
|
||||
`reassert`): it releases tracking, is refused in the cockpit, and is skipped
|
||||
if newer navigation took the camera while the photo loaded; closing the photo
|
||||
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
|
||||
iPhone, which has no element fullscreen, the viewer opens as a top-layer
|
||||
popover instead (hidden where neither API exists). Clean View, recording and
|
||||
the cockpit take an expanded photo out of full screen. At phone width the
|
||||
viewer height comes from the rail band so the whole photo fits.
|
||||
|
||||
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
||||
core routes clicks by pick prefix, swaps viewer adapters in the one host,
|
||||
@@ -3523,9 +3636,10 @@ add a provider: implement the definition, register it in
|
||||
`src/app/layers/streetLevel.js` and add its modules to
|
||||
`scripts/package-boundaries.json`; its share switch travels in `r` by id.
|
||||
A provider with line coverage builds it on `lineCoverage.js` (camera-driven
|
||||
XYZ tiles, draped or cast to bare earth, selection recolouring) and its cones
|
||||
XYZ tiles near the ground, draped, the selected sequence drawn again over
|
||||
them) and its cones
|
||||
on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick
|
||||
prefixes; Mapillary's `coverage.js` and `sequences.js` are such wrappers. With providers on different keys the
|
||||
prefixes; Mapillary, KartaView and Panoramax all do. With providers on different keys the
|
||||
layer's `requiresKeyId` is null and each chip reports its own key.
|
||||
|
||||
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
|
||||
@@ -3534,7 +3648,7 @@ on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
|
||||
Its chip starts off (`defaultOn: false`; a link without its switch keeps it off) because every
|
||||
panorama load is billed to the key. Google's terms forbid Street View beside
|
||||
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
|
||||
off Google 3D it does not appear at all (no chip, legend swatch, globe
|
||||
off Google 3D it does not appear at all (no chip, globe
|
||||
credit, layer source or button, and Google's script is not loaded), lookups
|
||||
and opens skip it, and leaving Google 3D closes its panorama; the share
|
||||
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
|
||||
@@ -3556,9 +3670,8 @@ marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place
|
||||
of GEV's amber pin. Its
|
||||
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
|
||||
(a provider's `filters` default to both `pano` and `since`): lookups ignore
|
||||
the IMAGERY and SINCE filters, the panel hides each filter row no lit provider
|
||||
honours (`filterControls`), and the legend lists only providers that draw
|
||||
lines, with "Selected" only beside one of them. The viewer is Google's `StreetViewPanorama` in its own
|
||||
the IMAGERY and SINCE filters, and the panel hides each filter row no lit provider
|
||||
honours (`filterControls`). The viewer is Google's `StreetViewPanorama` in its own
|
||||
element in the viewer host, without Google's fullscreen, close and address
|
||||
controls (the address goes in the caption); a pose goes out at once and
|
||||
again when the panorama's month, photographer and address arrive (one
|
||||
@@ -3585,8 +3698,7 @@ are no KartaView photos in it. A tile's lines are OSM way segments with
|
||||
photos and carry no sequence, date or projection, so `decode.js` chains
|
||||
segments that meet end to start (about four times fewer lines; linear time,
|
||||
at most 20,000 segments a tile and 5,000 points a line), they are
|
||||
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted
|
||||
(`highlightsSequence: false`, so the legend has no "Selected"), and the
|
||||
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted, and the
|
||||
provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N
|
||||
streets in view · click a line for its nearest photo"). KartaView filters
|
||||
coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`;
|
||||
@@ -3595,8 +3707,7 @@ asked for), so a filtered tile is drawn from the cached JSON segments that
|
||||
lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the
|
||||
points sampled every 2 px have a drawn pixel within one; on Jakarta's 360°
|
||||
filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between).
|
||||
A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show
|
||||
until the new ones are in), keeps the JSON segments, and caches masks per
|
||||
A filter change reloads the lines (`lineCoverage.reload`), keeps the JSON segments, and caches masks per
|
||||
filter and tile. A dense filtered PNG can take KartaView over a minute the
|
||||
first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is
|
||||
asked again 4 s later, four tries in all; then the panel says it did not
|
||||
@@ -3607,8 +3718,8 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
|
||||
`fieldOfView`) and upload date and checks each answer the same way (when
|
||||
photos are near but none passes, the panel says so), and the cones pass on
|
||||
each photo's projection and upload date. The provider declares
|
||||
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
|
||||
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
`sinceBasis: 'upload'`, so while it is lit the panel shows "(by
|
||||
upload date)" beside the SINCE value, as ANY DATE (by upload date) (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
|
||||
KartaView is lit (`createTileCache`, dropped when it is switched off); one
|
||||
that does not answer in 20 s is asked once more, and one that is not a line
|
||||
@@ -3623,7 +3734,7 @@ remembered; a storage timeout or 5xx is asked again. The open photo's
|
||||
sequence draws as cones from the 250-photo page holding it
|
||||
(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos
|
||||
(`countActivePhotos`, asked once per sequence). The camera direction (the
|
||||
marker, cones, FOLLOW, and a panorama's centre) is the direction of travel
|
||||
marker, cones and a panorama's centre) is the direction of travel
|
||||
to the next photo a few metres on (`assignCourses`): KartaView is dashcam
|
||||
imagery, while its `heading` is the device compass, which reads 0 for whole
|
||||
drives without one and can sit a steady 110° off inside a car. Until the
|
||||
@@ -3680,23 +3791,18 @@ to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
|
||||
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
|
||||
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
|
||||
view draw their longest lines within 90,000 points (`maxPoints`,
|
||||
`keepOrder`), shared out once loading settles so a sparse tile keeps all
|
||||
it has and a dense one (a city centre) takes the rest; the selected
|
||||
sequence is always drawn. Every point becomes ~8 ground-line vertices:
|
||||
`keepOrder`), split evenly between the tiles in view (10,000 points
|
||||
each). Every point becomes ~8 ground-line vertices:
|
||||
unsimplified, the lines hung the tab. Decoded tiles are kept while the
|
||||
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView),
|
||||
and above
|
||||
60 km the `grid` layer's 0.1°
|
||||
density cells, binned to at most 96×96 points a tile (a z0 tile holds
|
||||
~70,000 cells) and not counted as sequences: the panel says to zoom in.
|
||||
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView).
|
||||
Above 60 km nothing is drawn and the panel says to zoom in.
|
||||
A tile decodes in slices of about 8 ms, the page getting a turn between
|
||||
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
|
||||
tiles back for its Retry-After, which the browser cannot read here (the API
|
||||
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
|
||||
answers; an answer may take as long as it keeps arriving (silent for 15 s
|
||||
is given up), so a slow link still gets its megabyte tiles. Both
|
||||
filters apply: lines carry their day and camera type, and a cell stays while
|
||||
it holds pictures of the kind asked for (cells are undated). A click on a
|
||||
filters apply: lines carry their day and camera type. A click on a
|
||||
line asks the core (`actions.openNearest`) for that sequence's picture
|
||||
nearest the click, within the camera height / 100 (50–400 m: from high up
|
||||
a click lands well off a line), without framing, then any sequence's within
|
||||
@@ -3745,8 +3851,9 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||
from fixtures (`providers/mapillary/coverageFixture.mjs`,
|
||||
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
|
||||
needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize`
|
||||
against a production build served by `vite preview` with a dummy token.
|
||||
needs no network. It opens and closes a photo from Mapillary, KartaView and
|
||||
Panoramax; stepping, EXPAND, FIT/FILL, the filters and Street View stay with
|
||||
the unit tests. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
|
||||
|
||||
Directions is a keyless front end to the routing the voice agent already
|
||||
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
|
||||
@@ -4384,7 +4491,7 @@ silently demoting every later lookup for the session.
|
||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
||||
- **CCTV v2 foundation:** a pitched
|
||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||
far cap, retargeting the existing video/canvas texture pipeline. Manual calibration only —
|
||||
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
|
||||
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
|
||||
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
|
||||
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
|
||||
@@ -4402,12 +4509,14 @@ silently demoting every later lookup for the session.
|
||||
ground clamp now lifts the cap _center_ only so the wireframe stays a true pyramid welded to
|
||||
the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active
|
||||
camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a
|
||||
periodic white flash). Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
|
||||
periodic white flash). Camera icons are cyan for stills, violet for cameras with a video feed, and amber for the active camera. Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
|
||||
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
|
||||
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
|
||||
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
|
||||
sources. Ontario 511 (2026-09-12) adds keyless highway cameras including Kitchener-area routes
|
||||
(cap 1,000, all enabled rows from the current ~944-camera catalog). All sources are RAW PRIOR
|
||||
sources. Ontario 511 (2026-09-12) adds highway cameras including Kitchener-area routes
|
||||
(cap 1,000, all enabled rows from the current ~944-camera catalog). The 511on.ca API now
|
||||
requires a developer key, so the pack needs `ONTARIO_511_API_KEY` and is skipped without one
|
||||
(#1228). All sources are RAW PRIOR
|
||||
poses, and the layer is stills-first.
|
||||
sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
|
||||
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
|
||||
@@ -4415,7 +4524,18 @@ silently demoting every later lookup for the session.
|
||||
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
||||
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
|
||||
across packs, so a lower global cap thins every region instead of starving the last pack.
|
||||
~1,100 cameras total, all RAW PRIOR poses, stills-first.
|
||||
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
|
||||
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
|
||||
pack, so only the very first load waits for the slowest pack (a live first load took about
|
||||
22 s, and is bounded by Tarktee's 30 s). A background refresh that fails or comes back
|
||||
empty keeps the old catalog and is retried after another 15 minutes, not on the next
|
||||
request. Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s
|
||||
(`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s.
|
||||
Both Tarktee XML catalogs refuse redirects and have a 4 MiB per-document
|
||||
limit, enforced against declared length and streamed bytes. A rejected fetch,
|
||||
unsuccessful response, or body-read failure releases both available response
|
||||
bodies; read failures also cancel an active sibling reader. Cameras sharing
|
||||
an image number are deduplicated before the Tarktee pack cap is applied.
|
||||
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
|
||||
validated 2026-07-21): the COVERAGE toggle is a
|
||||
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
|
||||
@@ -4732,7 +4852,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame.
|
||||
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
|
||||
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
|
||||
- **A typed command supersedes the turn it interrupts** (2026-08-21). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items.
|
||||
- **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt.
|
||||
- **Subject reconciliation across satellite catalog rebuilds** (2026-08-21). A dense↔core toggle or TLE refresh clears and repopulates the catalog, so the published subject is re-resolved against the new satrec; a subject that did NOT survive releases the slot. The per-frame refresh cannot do this itself — `_getTrackedFramePosition` returns early once the satellite has no catalog entry — so the reconcile runs at rebuild completion. An empty catalog is a rebuild in flight, not a disappearance.
|
||||
- **One aircraft-proximity snapshot** (2026-08-22; hardened 2026-09-30). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) computes the Contacts panel cohort once. The voice analyst reads the exact retained snapshot and evaluation timestamp instead of re-scanning live layers, so the panel readout and spoken count for one centre cannot cross snapshots. **Invariant: do not re-derive a proximity count anywhere else.** Each layer retains 20,000 rows and samples one row beyond the cap; a saturated cohort reports `complete:false`, `truncated:true`, and is spoken as a lower bound. They diverged before because the panel read live billboard positions while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions, arbitrary points, and a radius different from the panel radius deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft, complete, evaluatedAt}`.
|
||||
- **Centre precedence for nearby asks**: explicit place in the question > Contacts subject (a selected non-contact entity never silently becomes the centre) > an entity the user names > the current view, said aloud. Contacts active with no subject uses the view rather than reading an empty panel.
|
||||
@@ -5039,7 +5159,7 @@ pins and share links already supply complete boxes.
|
||||
Pixel-mode pinch deltas receive bounded amplification before reaching the
|
||||
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
|
||||
unchanged. The application scene owns and removes the relay listener.
|
||||
- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300.
|
||||
- Z ladder: panels around 100, voice pill 150, toast 200, clean-view exit 300.
|
||||
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
|
||||
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
|
||||
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
|
||||
@@ -5250,8 +5370,8 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
samples and constant elevation during E/N drag; one shared-floor resolution on
|
||||
release; late one-shot shared-cell work is permitted during viewshed idle. The
|
||||
A+B harness intentionally excludes citywide LOD assertions.
|
||||
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. Docked panels store nothing; a portable panel (CCTV, Street Level) stores `{ left, top, width, height, floating: true }` once a header drag lifts it out of the rail, and a header double-click removes it. Double presses are detected in the header's `pointerdown` handler, because its `preventDefault()` suppresses native `dblclick`. Collapsed state persists for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
|
||||
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
|
||||
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
|
||||
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
|
||||
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
|
||||
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
|
||||
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
|
||||
|
||||
@@ -57,9 +57,8 @@ Related keys (current versions):
|
||||
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
|
||||
`'1'` closed, absent means the panel's own default. A view opened from a share
|
||||
link ignores the stored value entirely.
|
||||
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
|
||||
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
|
||||
the panel header, docks the panel again.
|
||||
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the current layout
|
||||
writes none.
|
||||
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
|
||||
|
||||
---
|
||||
|
||||
@@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
|
||||
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
|
||||
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match |
|
||||
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
|
||||
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
|
||||
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
|
||||
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Running the mobile demo
|
||||
|
||||
## Start
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:4173/mobile/` in Chrome. Open DevTools, turn on the device toolbar (Cmd+Shift+M), and pick a phone (iPhone 14 Pro Max is close to the 17 Pro Max at 430×932) or an iPad Pro (1024×1366). The two-pane tablet layout starts at 600 px wide.
|
||||
|
||||
No keys are needed. Load the page once before presenting: the first load compiles and caches, and later loads paint Today in under a second from the last good snapshot.
|
||||
|
||||
## Presenter keys
|
||||
|
||||
Press `.` to open the director. Press a key to jump to a beat. Each beat sets up its own state, so they can be run in any order.
|
||||
|
||||
| Key | Beat | What to say |
|
||||
| --- | --- | --- |
|
||||
| 1 | Today | The daily brief: since you last looked, today's Moment, your sky, your watch. |
|
||||
| 2 | Your sky now | Everything above you this minute, from live ADS-B and orbital elements. |
|
||||
| 3 | The Empire State Moment | A 4D reconstruction: helicopter tracks, sealed captures with view cones, chat and the record on one clock. Illustrative. |
|
||||
| 4 | The Live lane, with hearts | Twitch-style chat over the city; reactions rise from where people are. Simulated chat. |
|
||||
| 5 | Need eyes | A room asks for coverage; the first sealed answer earns the byline and Signal. |
|
||||
| 6 | Capture | Look, Witness, Live and Upload, signed on the device. |
|
||||
| 7 | Dossier of a live aircraft | Gotham-style: identity, live state, track, links, receipts. Real data. |
|
||||
| 8 | Link graph | Everything that thing touches, synchronized with the globe. |
|
||||
| 9 | Time machine at 60× | Replays a real bundled recording of London, NYC and the Gulf. |
|
||||
| 0 | Clip this Moment | Select a range and clip it into a shareable Moment. |
|
||||
| - | An alert, calmly | Severity-gated alert rules on live data. |
|
||||
| = | Search to dossier | Search "7700" (or "ISS" if nothing is squawking) and open the result. |
|
||||
|
||||
`Esc` closes the top sheet. The director has a **Reset demo** action.
|
||||
|
||||
## What's real
|
||||
|
||||
| Real | Illustrative or simulated |
|
||||
| --- | --- |
|
||||
| Live aircraft, military, satellites, quakes, cyclones, fires, radar, lightning, wind, air quality, GNSS interference, transit, public cameras | The Empire State Moment (synthetic tracks over real NYC footprints) |
|
||||
| Detections (circling, squawks, quake clusters) on live data | Chat in the Live lane, example posts, crews, and other contributors |
|
||||
| The time machine's recording (this session's polling plus a bundled real recording from 23 Oct 2026, 08:25–11:06Z) | Need eyes requests from other people |
|
||||
| Capture signing, hash chains and verification (WebCrypto on this device) | The room clerk (rules, not a model) |
|
||||
|
||||
Every illustrative or simulated surface carries a label in the app.
|
||||
|
||||
## Rehearse
|
||||
|
||||
Run the beats once on the presenting Mac before the meeting. On first load the keyless terrain host can be slow; if a close-up stays black for more than a few seconds, the engine falls back to flat terrain on its own.
|
||||
|
||||
Refresh the bundled data while `npm run dev` is running: `node mobile/demo/capture-snapshot.mjs --base http://localhost:4173` for the snapshot, and `node mobile/demo/capture-history.mjs --base http://localhost:4173 --minutes 150` for the recording (it writes every 5 minutes; behind a proxy, prefix it with `NODE_USE_ENV_PROXY=1`).
|
||||
@@ -1,346 +0,0 @@
|
||||
# God's Eye — Mobile
|
||||
|
||||
_The window through which you see the world, and the eye through which the world sees itself._
|
||||
|
||||
This folder holds two things:
|
||||
|
||||
| Path | What it is | Tethered to production? |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`mobile/index.html`](index.html) → `/mobile/` | **Prototype.** A working phone/foldable app that sets the design language on top of the real engine: CesiumJS, Esri cloudless imagery, NASA Black Marble night lights, Google Photorealistic 3D Tiles (when keyed), and the app's own live `/api` feeds (ADS-B, military, AIS, TLEs, quakes, FIRMS fires, cyclones, launches, CCTV) plus the bundled Bhote Koshi event pack. Capture is real: camera, IMU, GPS and a WebCrypto signature. | Yes. Runs on the existing dev server (`npm run dev`, open `http://localhost:4173/mobile/`). |
|
||||
| [`mobile/vision/index.html`](vision/index.html) | **Vision.** A single self-contained file with no server, no keys and no production code. It shows where the product goes: the full feed, 4D reconstructions with collaborative hypotheses, the provenance ledger, holograms left in places, a hand-written WebGL globe with a cinematic post chain. | No. Open the file straight from disk in any browser (the dev server's CSP blocks its inline script). Rebuild it with `node mobile/vision/build.mjs`. |
|
||||
|
||||
---
|
||||
|
||||
## 1. The goal, rewritten
|
||||
|
||||
> Build the app people open **the moment something happens in the real world**, and the app they open **when nothing is happening** just to look.
|
||||
>
|
||||
> It must make 40+ live layers feel like one calm surface on a 6.9" phone, unfold into a two-pane instrument on a foldable, scrub the planet through time like a video, and let anyone with a phone add **signed, sensor-rich observations** that make reconstructions of real events harder to fake.
|
||||
|
||||
Five commitments fall out of that sentence:
|
||||
|
||||
1. **Situations, not layers.** The public does not think in feeds. They think in _things worth watching_: a cyclone, a launch, a flood, a chopper school, a stadium, an airport at rush hour. The home screen is a feed of situations. Layers are the ingredients a situation turns on for you.
|
||||
2. **Click _into_ the world, not to the next card.** Opening a card is a spatial transition: the card's thumbnail is the globe, and it expands into the live globe at that place, at that time, with that situation's lenses on. Closing it folds the world back into the card. No page loads, no white flashes.
|
||||
3. **Time is a first-class axis.** Every view has a scrubbable timeline (past ← now → forecast). Situations carry their own time window. Reconstructions are 4D: space × time × evidence.
|
||||
4. **Truth needs walls.** Captures from this app are cryptographically signed at the moment of capture and carry the full session: IMU trace, GPS, heading, timestamps, device and sensor metadata, a hash chain. Foundation **observations** are kept separate from the **hypotheses** people (and AI) layer on top, and every change is in an inspectable audit trail.
|
||||
5. **Cinematic by default, performant always.** Day and night blend across the terminator. High orbit gets the planetary treatment (cloudless day mosaic + night lights + atmosphere + bloom); close in, it becomes photorealistic 3D. The post chain is budgeted for a phone GPU, adapts to thermal state and battery, and drops to a still frame when nothing moves.
|
||||
|
||||
## 2. Information architecture
|
||||
|
||||
Five tabs. Capture is a tab, not a floating action, because it is a _mode_ with its own full-screen surface (HIG: tab bars navigate between peer areas; capture is a peer area here, as it is in Camera-first apps).
|
||||
|
||||
| Tab | SF-style glyph | Job |
|
||||
| ----------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Now** | `dot.radiowaves` | A two-column, RedNote-style masonry feed of live and recent situations. Each card is a living thumbnail: real imagery of the place, live counters, a pulse when it changes. Sections: _Happening now_, _Near you_, _Reconstructions_, _Always on_ (chopper schools, busy airports, ISS, port of Rotterdam…). |
|
||||
| **Globe** | `globe.americas` | The window. Full-bleed globe, the lens dock, the time ribbon, search. This is the expert surface. |
|
||||
| **Capture** | `viewfinder` | Two modes on one dial: **Look** (AR — the God's-eye view projected around you: aircraft, satellites, ships, quakes as overlays on the camera, aligned by compass + gyro) and **Witness** (your phone becomes an eye for the God's eye: signed photo/video with IMU, GPS, heading, depth when available). |
|
||||
| **Threads** | `point.3.connected.trianglepath.dotted` | Reconstructions people are building together: observations, hypotheses, AI refinements, and the ledger. |
|
||||
| **You** | `person.crop.circle` | Your captures and their seals, holograms you left in places, situations you follow, alert radius. |
|
||||
|
||||
The iOS 26-era shell: a floating glass tab bar that shrinks to a pill while you scroll the feed and expands when you stop; a **tab-bar accessory** shows the one situation you are following live ("Typhoon · 185 km/h · 2 min ago"), like a now-playing bar for the planet.
|
||||
|
||||
## 3. Interaction model
|
||||
|
||||
- **Card → world (hero transition).** The tapped card's frame animates to full-screen while its static thumbnail cross-fades into the live globe already flying to the situation camera. The sheet rises to the medium detent with the situation brief. Swipe the sheet down or tap the back chevron to fold back to the card. Reduced Motion: cross-fade only.
|
||||
- **Sheets with detents** (peek ≈ 96 pt / medium ≈ 45% / large ≈ 92%). Peek shows the headline numbers; medium shows the brief and the evidence strip; large shows the full thread. Dragging the globe while the sheet is at medium collapses it to peek, so the world always wins.
|
||||
- **Lens dock.** 40+ layers live in eight families. The dock shows _family_ chips (Sky, Sea, Orbit, Hazards, Eyes, Ground, Weather, Infra); tapping a family opens a Control-Center-style grid of its layers with live counts. Long-press a family to solo it. The globe also curates by **semantic zoom**: at planetary altitude only aggregates render (density glows, cyclone cones, fire clusters); individual glyphs appear as you descend.
|
||||
- **Time ribbon.** A thin, haptic-detented scrubber above the tab bar: −72 h ← NOW → +120 h (forecast). Event pips sit on the ribbon (the quake, the launch, the flood crest). Dragging fast moves in hours; slow, in minutes. Tapping NOW snaps back to live with a single tick haptic. In Live the ribbon breathes.
|
||||
- **Pick anything.** Tap a contact on the globe: it's highlighted, a trail draws, and a compact card offers **Follow**, **Ride** (cockpit/bridge camera), **Nearest eye** (closest live camera), **Thread** (start or join a reconstruction).
|
||||
- **Foldables & large screens.** Width ≥ 600 pt switches to a two-pane layout: feed/thread list on the leading pane, globe on the trailing pane; selection in one drives the other. With a vertical hinge (`horizontal-viewport-segments: 2`) the panes split exactly at `env(viewport-segment-right 0 0)` so nothing renders under the fold. Half-folded (tabletop, `vertical-viewport-segments: 2`): globe on top, controls and timeline on the bottom half, like a laptop.
|
||||
- **One-handed reach.** All primary controls sit in the bottom 40% of the screen. Search lives in the tab bar row (iOS 26 pattern), not at the top.
|
||||
- **Haptics & sound.** `navigator.vibrate` where available for ribbon detents and capture seal; never decorative.
|
||||
|
||||
## 4. Visual language
|
||||
|
||||
- **Ground:** near-black with a blue bias (`#05070B`), never pure grey. The globe is the only light source; UI is glass over it.
|
||||
- **Glass:** `backdrop-filter: blur(24px) saturate(1.6)` panels with a 0.5 px inner highlight, 22–28 pt continuous corners, no drop shadows on glass.
|
||||
- **Signal colors** carry meaning and are reserved: Sky `#7CC4FF`, Sea `#38E1C6`, Orbit `#C9A7FF`, Hazards `#FF6A3D`, Eyes `#FFD166`, Ground `#9BE15D`, Weather `#8AB4F8`, Infra `#B8C0CC`. Live = a soft pulse; Stale = desaturated with a dotted outline; Signed = a seal glyph in warm gold.
|
||||
- **Type:** the system UI face (SF Pro / Roboto Flex via `system-ui`) for UI; tabular numerals for every live readout; a monospace for coordinates and hashes only. Large titles (34 pt) on Now and Threads, collapsing on scroll.
|
||||
- **Tufte rule for reconstructions:** maximize data-ink. Small multiples for before/after, sparklines in-line with text, direct labels instead of legends, uncertainty drawn (cones, bands, dotted where inferred), and every number carries its source and age.
|
||||
|
||||
## 5. Rendering budget (phone)
|
||||
|
||||
| Item | Target |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Frame | 60 fps while interacting, 0 fps (request-render mode) at rest |
|
||||
| Resolution scale | `min(devicePixelRatio, 2)`, dropping to 1.5 / 1.25 when frame time > 20 ms for 2 s |
|
||||
| Tiles | `maximumScreenSpaceError` 2 → 4 on compact; tile cache 256 MB → 128 MB |
|
||||
| Post chain | atmosphere + night-lights blend (built into the globe shader) → FXAA → single-pass bloom (half res) → vignette + grade. Bloom and grain switch off under Low Power / thermal pressure |
|
||||
| Labels | no Cesium labels; HTML/canvas overlay in screen space, capped at 40 visible |
|
||||
| Data | each layer has an LOD rule (aggregate above 2,000 km altitude, glyphs below) and a visible-count cap |
|
||||
|
||||
## 6. Capture and provenance
|
||||
|
||||
A Witness capture produces a **sealed bundle**:
|
||||
|
||||
```
|
||||
bundle = {
|
||||
media: JPEG/WebM bytes → sha256
|
||||
session: { start, end, imu: [{t, α, β, γ, ax, ay, az}], gps: [{t, lat, lon, acc, alt}], heading }
|
||||
context: { device, ua, screen, timezone, network-time-offset }
|
||||
prev: sha256 of this device's previous bundle (hash chain)
|
||||
hash: sha256(canonical(media hash + session + context + prev))
|
||||
signature: ECDSA P-256 over hash, key generated on device, non-extractable, stored in IndexedDB
|
||||
}
|
||||
```
|
||||
|
||||
In a native build the key lives in the Secure Enclave / StrongBox and is bound to App Attest / Play Integrity, and the bundle is written as a C2PA manifest. The prototype does the browser-equivalent with WebCrypto so the flow, the data and the verification UI are real. Verification recomputes the hash, checks the signature against the device public key, and checks IMU/GPS plausibility (the motion trace must agree with the image's horizon and the compass heading; the GPS track must agree with the time). None of this proves truth. It raises the cost of lying.
|
||||
|
||||
## 7. Reconstructions (Threads)
|
||||
|
||||
A reconstruction is a document with three strata, always drawn in this order:
|
||||
|
||||
1. **Observations** — machine-sourced or signed: ADS-B/AIS tracks, satellite imagery, seismograph readings, signed captures. Immutable; each has source, time, hash.
|
||||
2. **Hypotheses** — claims that connect observations ("this clip was filmed from the north bank at 14:02", "the aircraft entered a hold at 2,100 ft"). Each has an author (person or AI), confidence, the observations it cites, and agreement/disagreement counts.
|
||||
3. **Narrative** — the story view: a camera path through space and time that cites hypotheses.
|
||||
|
||||
Collaborative AI prompting happens on stratum 2: a participant asks the model to refine ("align the witness clip to the corridor using the bridge as a landmark"). The model's output is a _proposed hypothesis_ with its prompt, model, inputs and diff recorded in the ledger. Nobody edits an observation; you can only cite, dispute, or supersede a hypothesis. The ledger is a hash chain anyone can export and verify.
|
||||
|
||||
## 8. Layer catalog (44)
|
||||
|
||||
| Family | Layers |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Sky | Commercial flights · Military aircraft · Helicopters · Private/GA · Emergency squawks · Drones (Remote ID) · Airport boards · Holding patterns |
|
||||
| Sea | AIS vessels · Ports · Tankers/cargo · Fishing effort · Dark vessels (AIS gaps) · Submarine cables |
|
||||
| Orbit | Satellites (TLE) · ISS & crewed · Starlink trains · Launches & trajectories · Debris · Ground tracks & passes |
|
||||
| Hazards | Earthquakes · Active fires (FIRMS) · Fire perimeters · Floods · Tropical cyclones · Volcanoes · Air quality |
|
||||
| Eyes | Public CCTV · Signed captures · Street-level imagery · Holograms · Live streams |
|
||||
| Ground | Transit vehicles · Traffic flow · Bike share · Rail · Events & venues · ALPR cameras |
|
||||
| Weather | Radar · Clouds · Lightning · Wind · Forecast models |
|
||||
| Infra | Datacenters · Dams · Power plants · Military installations · GNSS interference · Recent satellite imagery |
|
||||
|
||||
## 9. Situations worth monitoring (the feed's genres)
|
||||
|
||||
Live today with the repo's feeds: tropical cyclone tracks · largest quake in 24 h · fire clusters in Europe/California · military air activity over a region · ISS pass over you · busiest airport now · helicopter school circuits (Fort Novosel) · launch countdowns with trajectory · Bhote Koshi flood reconstruction · ships queuing at Suez/Panama/Singapore · GNSS jamming hot spots · transit at rush hour.
|
||||
|
||||
Ten more to build: stadium/concert 4D reconstructions from crowd captures · storm chasers with signed dashcams · emergency squawk 7700 replays · air-show displays · marathon/route races with runner trackers · eclipse path watch parties · aurora nowcasts with sky captures · ferry crossings and harbour pilots · wildfire smoke vs. air quality vs. hospital load · building-climb or rooftop incidents reconstructed from ADS-B news helicopters + witness video · protest/march route documentation with signed captures · volcano webcams vs. ash advisories · delivery-drone corridors (Prime Air style corridor replays).
|
||||
|
||||
## 10. Prototype architecture
|
||||
|
||||
```
|
||||
mobile/
|
||||
index.html entry served at /mobile/ (CSP: no inline script)
|
||||
main.js boots the shell, lazy-loads the globe
|
||||
styles/ tokens.css, shell.css, feed.css, globe-ui.css, capture.css, threads.css
|
||||
globe/ Cesium engine for phones (owned by the globe agent)
|
||||
index.js createGlobe(container, options) → Globe API (below)
|
||||
imagery.js Esri day + Black Marble night blend, Google 3D tiles toggle
|
||||
post.js mobile post chain + adaptive quality governor
|
||||
layers/*.js live layers on top of the repo's /api routes
|
||||
situations.js builds situation cards from live feeds (+ bundled Nepal)
|
||||
ui/ shell, feed, sheet, lens dock, time ribbon, capture, threads, you
|
||||
capture/ camera, IMU, GPS, provenance (WebCrypto signing + verify)
|
||||
```
|
||||
|
||||
### Globe API contract
|
||||
|
||||
```js
|
||||
const globe = await createGlobe(containerEl, { quality: 'auto' });
|
||||
globe.flyTo({ lon, lat, height, heading?, pitch?, duration? }) → Promise
|
||||
globe.home() // planetary view
|
||||
globe.layers() → [{ id, family, name, on, count, status: 'live'|'stale'|'off'|'error' }]
|
||||
globe.setLayer(id, on)
|
||||
globe.setFamily(family, on)
|
||||
globe.setTime(date | null) // null = live
|
||||
globe.timeWindow() → { start, end, now }
|
||||
globe.setLens(id) // 'cinematic' | 'clear' | 'night' | 'thermal' | 'nvg'
|
||||
globe.focus(situation) // flies + applies situation.layers + time
|
||||
globe.select(id | null)
|
||||
globe.on(event, fn) / off // 'pick' {kind,id,title,subtitle,lon,lat,meta}
|
||||
// 'layers' (counts changed), 'camera' {lon,lat,height}
|
||||
// 'quality' {scale,fps}
|
||||
globe.pause() / resume() // tab hidden / capture open
|
||||
globe.contactsNear(lon, lat, km) → [{ kind, id, title, lon, lat, altM, headingDeg }] // for AR
|
||||
globe.situations() → Promise<Situation[]> // live feed cards
|
||||
```
|
||||
|
||||
`Situation = { id, kind, family, title, subtitle, place, lon, lat, camera:{lon,lat,height,heading,pitch}, layers:[ids], time?:{start,end}, metrics:[{label,value,unit}], thumbnail: url, live:boolean, updatedAt, source }`.
|
||||
|
||||
Thumbnails use the keyless Esri World Imagery `export` endpoint for the situation's bounding box, so every card shows the real place.
|
||||
|
||||
---
|
||||
|
||||
## 11. Round two: from weather app to God's Eye
|
||||
|
||||
Review notes on the first build: the feed showed one kind of picture (open ocean under a cyclone), the app read as a weather app, there was nowhere for people to talk, and capture stopped at a sealed photo. This section is the response. Where it disagrees with §1–§10, it wins.
|
||||
|
||||
### 11.1 Identity: space → sky → ground → inside
|
||||
|
||||
The app's subject is altitude. Every surface tells you **where the sensor is** and **how high it is**, from orbit down to street level and finally inside: a room, a cockpit, a person's own capture. That is the brand, and it replaces the weather-app palette of blue gradients and big temperature-style numbers.
|
||||
|
||||
| Altitude band | Sensor | Telemetry burned into the frame |
|
||||
| ------------------------- | --------------------------------------------------------------- | --------------------------------------------- |
|
||||
| **Orbit** · 400–36,000 km | satellites, ISS, launches, weather satellite IR | `ORBIT · 412 KM · 7.66 KM/S · PASS 21:04` |
|
||||
| **Sky** · 0–15 km | ADS-B aircraft, helicopters, military, radar returns, lightning | `SKY · FL350 · 487 KT · SQ 7700` |
|
||||
| **Ground** · 0 m | CCTV, traffic, transit, fires, quakes, floods | `GROUND · CAM 0412 · I-5 S · 14:02:11Z` |
|
||||
| **Inside** · you | signed captures, live broadcasts, holograms, conversations | `WITNESS · SEALED · ±4 M · 1.2 KM FROM EVENT` |
|
||||
|
||||
Visual rules:
|
||||
|
||||
- **Sensor framing.** Feed cards and the globe chrome use corner brackets, a hairline reticle, and a monospace telemetry strip burned into the image (coordinates, MGRS, UTC, sensor id, altitude). It is restrained: one strip per card, never a full military HUD.
|
||||
- **Type.** Display: the system face, tight and heavy. Telemetry: a monospace face (JetBrains Mono, which the desktop app already loads) in small caps with tabular numerals. The monospace face is the voice of the instruments, not of the UI.
|
||||
- **Color.** Ground stays near-black. Each altitude band has one signal hue: Orbit violet, Sky ice-blue, Ground amber, Inside warm gold for sealed. Hazard red is reserved for things that hurt people. No blue-gradient cards.
|
||||
- **Imagery is evidence.** Thumbnails are composited from the layers that actually describe the situation (§11.2). A picture of empty ocean is never acceptable.
|
||||
|
||||
### 11.2 The feed: compelling, diverse, real
|
||||
|
||||
The Now feed is the global equivalent of a neighborhood safety feed: it tells you what is happening, where, how close, and who saw it. Diversity is a requirement. The feed mixes **card archetypes** and **altitude bands**, and never puts two cards of the same archetype or band side by side when it can avoid it.
|
||||
|
||||
Card archetypes:
|
||||
|
||||
1. **Sensor card**: a composited frame. The base is the Esri image of the place or GIBS satellite IR. On top go the real data layers (radar, lightning strikes, cyclone cone and track, flight trails, quake rings, fire hotspots and smoke direction from wind), with sensor framing and telemetry burned in.
|
||||
2. **Eye card**: a real frame from the nearest public camera (`/api/cctv`), with a LIVE timestamp, camera id, and direction of view. This is the most Citizen-like card: you see the street.
|
||||
3. **Track card**: one moving thing, such as an aircraft squawking 7700, a helicopter on its 14th circuit at Fort Novosel, or the ISS crossing your night sky. It shows a trail, a big readout, and a "ride along" call to action.
|
||||
4. **Number card**: one striking fact with a sparkline. Examples: "4,812 lightning strikes in the last hour over the Gulf", "GNSS jamming over the Baltic, 340 aircraft affected".
|
||||
5. **Witness card**: a signed capture or a live broadcast from a person. It shows the seal, the distance from the event, and the reply count.
|
||||
6. **Room card**: a conversation that is heating up. It shows the top claim, its status (_Confirmed by 3 signed captures_ / _Disputed_), and the participants.
|
||||
|
||||
Situation genres to source from keyless layers. Each card must say its source:
|
||||
|
||||
- Cyclones (NHC cone, track and wind radii, GIBS IR)
|
||||
- Severe-storm cells (radar plus lightning density)
|
||||
- Heavy rain over a major city (radar)
|
||||
- Jet-stream wind anomalies (GFS wind)
|
||||
- Quakes (USGS, with intensity rings)
|
||||
- Wildfires (FIRMS or EONET, with smoke direction from wind)
|
||||
- Emergency squawks (7500, 7600, 7700)
|
||||
- Military activity
|
||||
- Helicopter schools
|
||||
- Launches (pad and trajectory)
|
||||
- ISS and Starlink passes over the viewer
|
||||
- GNSS interference (`/api/gnss-integrity`)
|
||||
- Transit at rush hour (`/api/transit`)
|
||||
- Busiest airport arrivals
|
||||
- Live public cameras at famous places (Times Square, Shibuya, Tower Bridge where available, otherwise any busy feed)
|
||||
- Ship queues at chokepoints
|
||||
- The Bhote Koshi reconstruction
|
||||
|
||||
### 11.3 Discourse: every situation has a room
|
||||
|
||||
People will talk about events anyway, on other platforms and without the evidence. The room puts the conversation **on the evidence**: anchored in space and time, and graded by how it was observed.
|
||||
|
||||
- **Structured posts.** Each post is one of: **Witness** (I saw or recorded this), **Context** (background, a link, a map), **Question**, **Correction**, or **Prediction**. The kind is chosen in the composer and drawn as an icon. Questions float to the top until answered.
|
||||
- **Anchored.** A post can pin a place (a dot on the globe) and a time (a pip on the time ribbon). Tapping a post flies the globe there and scrubs the ribbon to that moment. Pinned posts appear in 3D as small speech glyphs at their location.
|
||||
- **Earned badges, not follower counts.** Badges come from evidence:
|
||||
- _Was there_: a signed capture within N km and M minutes of the event.
|
||||
- _Sealed_: the post has a sealed attachment.
|
||||
- _Local_: the author's home area is set.
|
||||
- _Domain_: pilot, mariner, met, first responder, held as a verified credential.
|
||||
|
||||
Posts are ordered by evidence first, then recency, never by follower count.
|
||||
|
||||
- **Community notes with bridging.** Any claim can get a note. A note becomes visible only when raters who usually disagree both rate it helpful (bridging-based ranking, as Community Notes does), which resists brigading.
|
||||
- **AI synthesis, kept honest.** A pinned card at the top of every room is labelled **Known / Disputed / Unknown**. AI writes it from the observations and the posts, and every line cites its sources. It is regenerated as the room changes, with its prompt and inputs recorded in the ledger. It never states anything that has no citation.
|
||||
- **Live layer.** While you are in a room, presence shows as small avatars, reactions float up the side, and a "N people watching" count appears. Rooms for live situations sit in the feed as Room cards.
|
||||
- **Reply with evidence.** "Reply" offers **Capture** first and text second. The best answer to "is the bridge still standing?" is a sealed photo from 200 m away.
|
||||
|
||||
### 11.4 Capture: contribute a place, an event, or a live stream
|
||||
|
||||
Capture is how a person becomes a sensor. Four modes share one dial:
|
||||
|
||||
| Mode | What it makes | Trust tier |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| **Look** | AR overlay of contacts around you. Nothing is recorded unless you tap. | none |
|
||||
| **Witness** | A sealed photo or clip with the IMU, GPS and heading trace (§6). | **Sealed** |
|
||||
| **Live** | A broadcast from your camera. It appears on the globe as an _eye_: a frustum cone at your position and heading, with live telemetry, watchable by the room. Every 10 s the stream is sealed as hash-chained segments. | **Sealed live** |
|
||||
| **Upload** | A photo or video from your library. EXIF is read if present, then you place it on the map and set the time. | **Unverified**: labelled as such everywhere, and it can never become an observation, only cited by a hypothesis. |
|
||||
|
||||
After the shutter comes the **attach sheet**. It suggests the situation you are probably at, from distance and time ("Typhoon Champi · 4.2 km from track", "Times Square camera · 180 m"). It offers "new place" if none fits, a post kind (Witness by default), an optional note, and whether to drop it as a **hologram** that others can find at this spot later. Posting puts the capture in that situation's room, and a Witness card in the feed when it is notable.
|
||||
|
||||
Privacy: faces and plates are blurred before upload by on-device detection (a native-build commitment, and a disclosed limitation in the web prototype). Your exact location is shared at the precision you choose (exact, 100 m, 1 km), and the seal still proves the exact value to verifiers without publishing it.
|
||||
|
||||
---
|
||||
|
||||
## 12. Round three: Gotham for everyone
|
||||
|
||||
Founder's brief for this round: take what made Palantir Gotham work, fix what Citizen got wrong, and build the window people open every day and the first app they open when something happens. The time slider does far more. Communication feels like Twitch chat meets an X community meets Reddit. Real data wherever possible, more realistic everywhere else. It is demoed on a Mac in Chrome's device mode, as a phone and as a tablet.
|
||||
|
||||
> No longer should the audit trail for physical reality be the dominion of nation states. We have the right to know, with the receipts attached.
|
||||
|
||||
### 12.1 What we take from Gotham
|
||||
|
||||
Gotham's power is not the map. It is that **everything is an object, every object has a history and links, and every view (map, timeline, graph, dossier) shows the same objects**. Select something anywhere and every view follows. Analysts build an investigation, share it, and every fact keeps its source.
|
||||
|
||||
| Gotham idea | God's Eye version |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Ontology of objects and links | **Entities**: aircraft, vessels, satellites, cameras, places, events, captures and contributors (pseudonymous). Each has a **dossier**: identity, live state, track history, events, links and receipts. |
|
||||
| Map + timeline + graph, synchronized | Globe, **time machine** and **link graph** share one selection and one clock. |
|
||||
| Search to dossier | Universal search ("N46567", "7700", "Piccadilly", "M5.2", "ISS") opens a dossier, not a list of pins. |
|
||||
| Watchlists and alerts | **Watch**: follow an entity or a place, and write alert rules in plain chips ("any squawk 7700 within 200 km of Home"). |
|
||||
| Investigations with provenance | **Moments**: shareable 4D reconstructions whose every track, capture and claim carries its source and hash. |
|
||||
| Audit log | The room ledger: every post, note, AI synthesis and vote is hash-chained and exportable. |
|
||||
|
||||
**The line we don't cross.** Gotham tracks people; we don't. Trackable entities are things that broadcast publicly (transponders, AIS, orbital elements), public infrastructure, and events. Contributors are pseudonymous. There is no face recognition and no person search, and aircraft on owner-privacy lists (LADD/PIA) are never named. This is the product's moral spine and a talking point, not a footnote.
|
||||
|
||||
### 12.2 What Citizen got wrong, and our incentives
|
||||
|
||||
Citizen grew on fear: an alert stream about crime near you, ranked by alarm, with little verification and a paid "Protect" upsell. Anxiety is the retention loop, rumor travels as fast as truth, and nobody is rewarded for being right.
|
||||
|
||||
Ours rewards **being right, early, with receipts**:
|
||||
|
||||
- **Signal, not followers.** A contributor's reputation (Signal) rises when their sealed captures are corroborated (cited by others, matched by sensor data such as ADS-B, radar or quakes) and falls when their claims are corrected. Signal weighs their votes and notes. Follower counts are not shown.
|
||||
- **First eyes.** The first sealed capture of an event gets a permanent byline on the record ("First eyes: @harbor_cam_kid, 14:02:11Z, 180 m"). This is the reason to open the app and capture when something happens.
|
||||
- **Need eyes.** Any room can request coverage at a place ("Is the 7th St bridge underwater?"). People nearby get one quiet ask; fulfilling it with a sealed capture earns Signal and the byline. This turns curiosity into coverage, which is the incentive structure Citizen never had.
|
||||
- **Calm by default.** Notifications are severity-gated and local, "Nothing unusual near you" is a valid state, and half the feed is wonder (the ISS overhead, a launch, aurora, a helicopter school) so the daily habit is curiosity, not dread.
|
||||
- **Ranking by evidence.** Sealed, corroborated, and close beats loud.
|
||||
|
||||
### 12.3 The daily loop
|
||||
|
||||
- **Today** (first tab) opens with a brief: "Your world · 7:42 AM": since you last looked (the three biggest changes), what's overhead tonight (ISS pass, launch window), your watched places and things, and **Today's Moment**, one 20-second 4D replay a day worth watching (a ritual, like a daily puzzle). Below the brief comes the situation feed (§11.2).
|
||||
- **Your sky now.** One tap shows what is above you this minute: aircraft, satellites, the ISS. This is the most delightful hook in the app.
|
||||
- **Watch.** Follow places (home, commute, a family member's city) and things (a tail number, a ship, the ISS). A followed thing's status rides in the tab-bar accessory.
|
||||
|
||||
### 12.4 When something happens: Moments
|
||||
|
||||
A **Moment** is a 4D reconstruction assembled around an event: tracks, sensor frames, sealed captures with their view cones, the chat, and the record, all on one clock.
|
||||
|
||||
- **Detected from real data.** When two or more aircraft circle the same point (news and police helicopters orbiting a scene), the engine raises a situation before any headline: "3 helicopters circling Midtown Manhattan". It also flags an emergency squawk, a cluster of quakes or a sudden lightning cell. These are real detections on live ADS-B and sensor feeds.
|
||||
- **Replayable.** Open a Moment and the time machine replays it: helicopter tracks with trails, captures popping in at their timestamps with view cones pointing at the subject, chat replaying at its pace, and posts appearing as they were made.
|
||||
- **Hero demo Moment: "Climbers on the Empire State Building".** An illustrative reconstruction with realistic synthetic tracks: two news helicopters and one police helicopter orbiting at 1,000–1,500 ft and radii of 400–700 m, with realistic speeds and turn rates. Witness captures from 34th St, 5th Ave and an office at 1 Penn are shown with their view cones, along with the chat and the record. It is clearly labelled illustrative. It shows exactly what the product does when this happens for real.
|
||||
|
||||
### 12.5 The time machine
|
||||
|
||||
The time ribbon becomes a time machine:
|
||||
|
||||
- **It remembers.** The app records every live contact's position each poll (a ring buffer persisted in IndexedDB, about 6 h). Scrubbing back shows where everything actually was, with ghost trails. A selected aircraft's full track comes from the server's track routes. The app also ships a **bundled real recording** (`mobile/demo/history/`, a few hours over New York, London and the Gulf of Mexico, captured with `capture-history.mjs`) so a first run already has ghosts to replay; it keeps its true timestamps and the time machine shows it where it happened (`RECORDING · <date>`).
|
||||
- **Everything moves together.** Contacts, radar and lightning frames, quakes and fires appearing, cyclone positions, the sun, room posts, and captures all follow the clock.
|
||||
- **Play.** Play/pause and 1×, 10×, 60× and 600× speeds; momentum scrubbing; detents at events; a **NOW** snap.
|
||||
- **Event pips.** Pips from every layer are colour-coded by altitude band. Tapping one jumps to it.
|
||||
- **Clip.** Select a range, then "Clip this Moment", which creates a shareable Moment with its receipts.
|
||||
|
||||
### 12.6 Talking: Live and Record
|
||||
|
||||
Two lanes in every room. This is the delineation that keeps the conversation both fun and true.
|
||||
|
||||
| | **Live** (Twitch) | **Record** (Reddit and X) |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Pace | Fast, ephemeral, scrolls away | Considered, persistent, threaded |
|
||||
| Shape | One-line chat, emotes, @mentions, slow mode, viewer count | Posts with kinds (§11.3), replies, quote-posts, attachments |
|
||||
| Reactions | Hearts, 🔥, 👀 and 😮 fly up the screen, **and rise from the reactor's location on the globe**: you see the world watching | Corroborate / Dispute votes weighted by Signal |
|
||||
| Badges | Witness glow for people with a sealed capture nearby, plus First eyes | Was there, Sealed, Local, Domain |
|
||||
| Becomes the record? | Only if it carries evidence or is promoted by corroboration | Yes, hash-chained in the ledger |
|
||||
|
||||
- **The clerk.** Every few minutes, AI summarizes the Live lane into "What chat is saying", with citations to messages, and posts it into the Record as a clearly labelled synthesis.
|
||||
- **Crews.** Crews are persistent groups (X communities): "NYC Scanner Nerds", "Fort Novosel Alumni", "Gulf Storm Chasers". A crew has its own feed, members and rooms, and can co-watch a Moment together.
|
||||
- **Watch parties.** Opening a live situation drops you into its Live lane, with presence and the heart stream. That is the "hearts flying" feeling, everywhere.
|
||||
|
||||
### 12.7 Tabs
|
||||
|
||||
**Today · Live · Capture · Rooms · You**
|
||||
|
||||
- **Today**: the brief plus the feed.
|
||||
- **Live**: the globe, time machine and lenses.
|
||||
- **Capture**: Look · Witness · Live · Upload, plus Need eyes requests near you.
|
||||
- **Rooms**: live rooms, crews and Moments.
|
||||
- **You**: Signal, receipts, captures, watchlist and alert rules.
|
||||
|
||||
Search (the tab-bar search button) leads to dossiers.
|
||||
|
||||
### 12.8 Demo mode
|
||||
|
||||
The demo is presented in Chrome's device toolbar on a Mac:
|
||||
|
||||
- **Instant first frame.** The last good situations and thumbnails are cached (IndexedDB) and a bundled snapshot of real data ships with the app (`mobile/demo/`, with its capture time shown). The app paints in under a second, then hydrates live.
|
||||
- **Mouse and touch** work everywhere (drag sheets, scrub, pinch with trackpad).
|
||||
- **Director.** Press `.` (or triple-tap the logo) to open a presenter menu that jumps to the beats of the story: Today → Your sky now → the Empire State Moment → the Live lane with hearts → Need eyes → Capture → Dossier and link graph → the time machine → tablet layout. Each beat sets up its state so the demo never depends on luck.
|
||||
- **Honest labels.** Real data shows its source and age. Illustrative content (the hero Moment, simulated chat, example posts and crews) carries a small "Illustrative" or "Simulated" tag. A VC should see that the real parts are real.
|
||||
@@ -1,109 +0,0 @@
|
||||
/** Rear camera: preview, still photo (JPEG) and short video (MediaRecorder). */
|
||||
|
||||
export function createCamera(video) {
|
||||
let stream = null;
|
||||
let recorder = null;
|
||||
let chunks = [];
|
||||
|
||||
async function start() {
|
||||
if (stream) return stream;
|
||||
if (!navigator.mediaDevices?.getUserMedia)
|
||||
throw new Error('Camera API unavailable (needs https)');
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1440 },
|
||||
},
|
||||
});
|
||||
video.srcObject = stream;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
await video.play().catch(() => {});
|
||||
return stream;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
stream = null;
|
||||
video.srcObject = null;
|
||||
}
|
||||
|
||||
function info() {
|
||||
const t = stream?.getVideoTracks()[0];
|
||||
if (!t) return null;
|
||||
const s = t.getSettings?.() || {};
|
||||
return {
|
||||
label: t.label || 'camera',
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
facingMode: s.facingMode || null,
|
||||
frameRate: s.frameRate ? Math.round(s.frameRate) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Grab the current frame as a JPEG blob at the sensor's resolution. */
|
||||
async function photo() {
|
||||
const w = video.videoWidth;
|
||||
const h = video.videoHeight;
|
||||
if (!w || !h) throw new Error('Camera not ready');
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
c.getContext('2d').drawImage(video, 0, 0, w, h);
|
||||
return new Promise((resolve, reject) =>
|
||||
c.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('Encode failed'))),
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function canRecord() {
|
||||
return !!stream && typeof MediaRecorder !== 'undefined';
|
||||
}
|
||||
|
||||
function startVideo() {
|
||||
if (!canRecord()) throw new Error('Video recording unavailable');
|
||||
const types = [
|
||||
'video/mp4;codecs=avc1',
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
'video/mp4',
|
||||
];
|
||||
const mimeType = types.find((t) => MediaRecorder.isTypeSupported?.(t));
|
||||
chunks = [];
|
||||
recorder = new MediaRecorder(
|
||||
stream,
|
||||
mimeType ? { mimeType, videoBitsPerSecond: 4e6 } : undefined,
|
||||
);
|
||||
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
|
||||
recorder.start(250);
|
||||
}
|
||||
|
||||
function stopVideo() {
|
||||
return new Promise((resolve) => {
|
||||
if (!recorder || recorder.state === 'inactive') return resolve(null);
|
||||
recorder.onstop = () =>
|
||||
resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
|
||||
recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
info,
|
||||
photo,
|
||||
startVideo,
|
||||
stopVideo,
|
||||
canRecord,
|
||||
get active() {
|
||||
return !!stream;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Minimal promise wrapper over the device's IndexedDB: signing keys, sealed
|
||||
* captures (+ media), and round two's room posts, uploads and small state.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'gev-mobile';
|
||||
const DB_VERSION = 2;
|
||||
let dbp = null;
|
||||
|
||||
export function openDb() {
|
||||
if (dbp) return dbp;
|
||||
dbp = new Promise((resolve, reject) => {
|
||||
if (!('indexedDB' in globalThis)) {
|
||||
reject(new Error('IndexedDB is unavailable'));
|
||||
return;
|
||||
}
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains('keys'))
|
||||
db.createObjectStore('keys', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('captures')) {
|
||||
const s = db.createObjectStore('captures', { keyPath: 'id' });
|
||||
s.createIndex('seq', 'seq');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('media'))
|
||||
db.createObjectStore('media', { keyPath: 'id' });
|
||||
// v2: rooms and uploads.
|
||||
if (!db.objectStoreNames.contains('posts')) {
|
||||
const p = db.createObjectStore('posts', { keyPath: 'id' });
|
||||
p.createIndex('room', 'room');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('uploads'))
|
||||
db.createObjectStore('uploads', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('kv'))
|
||||
db.createObjectStore('kv', { keyPath: 'id' });
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
// Another tab upgraded the schema: let it, and reopen next time.
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
dbp = null;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => reject(new Error('IndexedDB upgrade blocked'));
|
||||
});
|
||||
dbp.catch(() => (dbp = null));
|
||||
return dbp;
|
||||
}
|
||||
|
||||
function wrap(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function get(store, key) {
|
||||
const db = await openDb();
|
||||
return wrap(db.transaction(store).objectStore(store).get(key));
|
||||
}
|
||||
|
||||
export async function put(store, value) {
|
||||
const db = await openDb();
|
||||
return wrap(db.transaction(store, 'readwrite').objectStore(store).put(value));
|
||||
}
|
||||
|
||||
export async function del(store, key) {
|
||||
const db = await openDb();
|
||||
return wrap(
|
||||
db.transaction(store, 'readwrite').objectStore(store).delete(key),
|
||||
);
|
||||
}
|
||||
|
||||
export async function all(store) {
|
||||
const db = await openDb();
|
||||
return wrap(db.transaction(store).objectStore(store).getAll());
|
||||
}
|
||||
|
||||
/** Write a capture bundle and its media atomically. */
|
||||
export async function putCapture(bundle, mediaBlob) {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(['captures', 'media'], 'readwrite');
|
||||
tx.objectStore('captures').put(bundle);
|
||||
tx.objectStore('media').put({ id: bundle.id, blob: mediaBlob });
|
||||
tx.oncomplete = () => resolve(bundle);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function lastCapture() {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction('captures')
|
||||
.objectStore('captures')
|
||||
.index('seq')
|
||||
.openCursor(null, 'prev');
|
||||
req.onsuccess = () => resolve(req.result?.value || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureBySeq(seq) {
|
||||
const db = await openDb();
|
||||
return wrap(
|
||||
db.transaction('captures').objectStore('captures').index('seq').get(seq),
|
||||
);
|
||||
}
|
||||
|
||||
export async function byIndex(store, index, key) {
|
||||
const db = await openDb();
|
||||
return wrap(
|
||||
db.transaction(store).objectStore(store).index(index).getAll(key),
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* Tiny, dependency-free EXIF reader for JPEG: GPS position, altitude and
|
||||
* image direction, DateTimeOriginal (+ offset), orientation, make/model.
|
||||
* Returns null for anything that is not a JPEG with an Exif APP1 segment.
|
||||
*/
|
||||
|
||||
const TYPE_SIZE = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
|
||||
|
||||
function readIfd(view, tiff, offset, le) {
|
||||
const out = {};
|
||||
if (offset + 2 > view.byteLength) return out;
|
||||
const n = view.getUint16(offset, le);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const e = offset + 2 + i * 12;
|
||||
if (e + 12 > view.byteLength) break;
|
||||
const tag = view.getUint16(e, le);
|
||||
const type = view.getUint16(e + 2, le);
|
||||
const count = view.getUint32(e + 4, le);
|
||||
const size = (TYPE_SIZE[type] || 1) * count;
|
||||
const at = size > 4 ? tiff + view.getUint32(e + 8, le) : e + 8;
|
||||
if (at + size > view.byteLength) continue;
|
||||
out[tag] = readValue(view, at, type, count, le);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readValue(view, at, type, count, le) {
|
||||
const one = (k) => {
|
||||
const p = at + k * (TYPE_SIZE[type] || 1);
|
||||
switch (type) {
|
||||
case 1:
|
||||
case 7:
|
||||
return view.getUint8(p);
|
||||
case 3:
|
||||
return view.getUint16(p, le);
|
||||
case 4:
|
||||
return view.getUint32(p, le);
|
||||
case 9:
|
||||
return view.getInt32(p, le);
|
||||
case 5: {
|
||||
const d = view.getUint32(p + 4, le);
|
||||
return d ? view.getUint32(p, le) / d : 0;
|
||||
}
|
||||
case 10: {
|
||||
const d = view.getInt32(p + 4, le);
|
||||
return d ? view.getInt32(p, le) / d : 0;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
if (type === 2) {
|
||||
let s = '';
|
||||
for (let k = 0; k < count; k += 1) {
|
||||
const c = view.getUint8(at + k);
|
||||
if (!c) break;
|
||||
s += String.fromCharCode(c);
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
if (count === 1) return one(0);
|
||||
return Array.from({ length: count }, (_, k) => one(k));
|
||||
}
|
||||
|
||||
const dms = (v) =>
|
||||
Array.isArray(v) ? v[0] + (v[1] || 0) / 60 + (v[2] || 0) / 3600 : Number(v);
|
||||
|
||||
/** "2026:10:18 20:53:37" (+ "-05:00") → ms epoch; local time if no offset. */
|
||||
function exifDate(s, offset) {
|
||||
const m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(s || '');
|
||||
if (!m) return null;
|
||||
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${offset && /^[+-]\d{2}:\d{2}$/.test(offset) ? offset : ''}`;
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Blob|ArrayBuffer} input
|
||||
* @returns {Promise<null | { lat?, lon?, altM?, headingDeg?, takenAt?, takenAtHasZone?, orientation?, make?, model?, raw }>}
|
||||
*/
|
||||
export async function readExif(input) {
|
||||
const buf =
|
||||
input instanceof Blob
|
||||
? await input.slice(0, 256 * 1024).arrayBuffer()
|
||||
: input;
|
||||
const view = new DataView(buf);
|
||||
if (view.byteLength < 4 || view.getUint16(0) !== 0xffd8) return null;
|
||||
let p = 2;
|
||||
while (p + 4 <= view.byteLength) {
|
||||
if (view.getUint8(p) !== 0xff) return null;
|
||||
const marker = view.getUint8(p + 1);
|
||||
const len = view.getUint16(p + 2);
|
||||
if (marker === 0xda || marker === 0xd9) return null; // start of scan: no EXIF before it
|
||||
if (
|
||||
marker === 0xe1 &&
|
||||
p + 10 <= view.byteLength &&
|
||||
view.getUint32(p + 4) === 0x45786966
|
||||
) {
|
||||
const tiff = p + 10;
|
||||
const le = view.getUint16(tiff) === 0x4949;
|
||||
if (view.getUint16(tiff + 2, le) !== 42) return null;
|
||||
const ifd0 = readIfd(view, tiff, tiff + view.getUint32(tiff + 4, le), le);
|
||||
const exif = ifd0[0x8769]
|
||||
? readIfd(view, tiff, tiff + ifd0[0x8769], le)
|
||||
: {};
|
||||
const gps = ifd0[0x8825]
|
||||
? readIfd(view, tiff, tiff + ifd0[0x8825], le)
|
||||
: {};
|
||||
const out = { raw: { ifd0, exif, gps } };
|
||||
if (gps[2] != null && gps[4] != null) {
|
||||
let lat = dms(gps[2]);
|
||||
let lon = dms(gps[4]);
|
||||
if (String(gps[1]).toUpperCase() === 'S') lat = -lat;
|
||||
if (String(gps[3]).toUpperCase() === 'W') lon = -lon;
|
||||
if (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
Math.abs(lat) <= 90 &&
|
||||
Math.abs(lon) <= 180 &&
|
||||
(lat || lon)
|
||||
) {
|
||||
out.lat = lat;
|
||||
out.lon = lon;
|
||||
}
|
||||
}
|
||||
if (gps[6] != null) out.altM = (gps[5] === 1 ? -1 : 1) * Number(gps[6]);
|
||||
if (gps[0x11] != null) out.headingDeg = Number(gps[0x11]);
|
||||
const offset = exif[0x9011] || exif[0x9010];
|
||||
const taken = exifDate(
|
||||
exif[0x9003] || exif[0x9004] || ifd0[0x0132],
|
||||
offset,
|
||||
);
|
||||
if (taken) {
|
||||
out.takenAt = taken;
|
||||
out.takenAtHasZone = !!offset;
|
||||
}
|
||||
if (ifd0[0x0112]) out.orientation = ifd0[0x0112];
|
||||
if (ifd0[0x010f]) out.make = ifd0[0x010f];
|
||||
if (ifd0[0x0110]) out.model = ifd0[0x0110];
|
||||
return out;
|
||||
}
|
||||
p += 2 + len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* Live broadcast session (DESIGN.md §11.4 "Live"): MediaRecorder timeslice
|
||||
* chunks are hashed and sealed every 10 s into a hash chain of segments,
|
||||
* each carrying the pose and position at that moment. Stopping returns the
|
||||
* whole recording, the chain and the sensor session, ready for `seal()`.
|
||||
*
|
||||
* Segment i covers bytes [offset, offset + bytes) of the final file, so a
|
||||
* verifier can re-split the file and recompute every link.
|
||||
*/
|
||||
|
||||
import { sha256Hex, canonical } from './provenance.js';
|
||||
|
||||
const TYPES = [
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
'video/mp4;codecs=avc1',
|
||||
'video/mp4',
|
||||
];
|
||||
|
||||
export function canBroadcast(stream) {
|
||||
return !!stream && typeof MediaRecorder !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ stream: MediaStream, sensors: object, pose: () => object,
|
||||
* fix: () => object|null, segmentMs?: number, onSegment?: (seg) => void }} o
|
||||
*/
|
||||
export function createLiveSession(o) {
|
||||
const segmentMs = o.segmentMs || 10000;
|
||||
const mimeType = TYPES.find((t) => MediaRecorder.isTypeSupported?.(t));
|
||||
const chunks = [];
|
||||
let pending = [];
|
||||
const segments = [];
|
||||
let recorder = null;
|
||||
let timer = 0;
|
||||
let prev = null;
|
||||
let offset = 0;
|
||||
let t0 = 0;
|
||||
let segT0 = 0;
|
||||
let sealing = Promise.resolve();
|
||||
|
||||
async function sealSegment() {
|
||||
const parts = pending;
|
||||
pending = [];
|
||||
if (!parts.length) return null;
|
||||
const blob = new Blob(parts, { type: mimeType || 'video/webm' });
|
||||
const t1 = Date.now();
|
||||
const p = o.pose?.() || {};
|
||||
const f = o.fix?.() || null;
|
||||
const seg = {
|
||||
i: segments.length,
|
||||
t0: segT0,
|
||||
t1,
|
||||
offset,
|
||||
bytes: blob.size,
|
||||
media: await sha256Hex(blob),
|
||||
pose: {
|
||||
headingDeg: Number.isFinite(p.az) ? Math.round(p.az * 10) / 10 : null,
|
||||
pitchDeg: Number.isFinite(p.el) ? Math.round(p.el * 10) / 10 : null,
|
||||
},
|
||||
gps: f
|
||||
? {
|
||||
lat: +f.lat.toFixed(6),
|
||||
lon: +f.lon.toFixed(6),
|
||||
acc: Math.round(f.acc || 0),
|
||||
}
|
||||
: null,
|
||||
prev,
|
||||
};
|
||||
seg.hash = await sha256Hex(canonical(seg));
|
||||
prev = seg.hash;
|
||||
offset += blob.size;
|
||||
segT0 = t1;
|
||||
segments.push(seg);
|
||||
o.onSegment?.(seg);
|
||||
return seg;
|
||||
}
|
||||
|
||||
function start() {
|
||||
recorder = new MediaRecorder(
|
||||
o.stream,
|
||||
mimeType ? { mimeType, videoBitsPerSecond: 2.5e6 } : undefined,
|
||||
);
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (!e.data?.size) return;
|
||||
chunks.push(e.data);
|
||||
pending.push(e.data);
|
||||
};
|
||||
recorder.start(1000);
|
||||
t0 = Date.now();
|
||||
segT0 = t0;
|
||||
o.sensors?.beginSession?.();
|
||||
timer = setInterval(() => {
|
||||
sealing = sealing
|
||||
.then(sealSegment)
|
||||
.catch((err) => console.warn('segment', err));
|
||||
}, segmentMs);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
clearInterval(timer);
|
||||
if (recorder && recorder.state !== 'inactive')
|
||||
await new Promise((resolve) => {
|
||||
recorder.onstop = resolve;
|
||||
recorder.stop();
|
||||
});
|
||||
await sealing;
|
||||
await sealSegment();
|
||||
const session = o.sensors?.endSession?.() || {
|
||||
start: new Date(t0).toISOString(),
|
||||
end: new Date().toISOString(),
|
||||
imu: [],
|
||||
gps: [],
|
||||
heading: null,
|
||||
};
|
||||
return {
|
||||
blob: new Blob(chunks, { type: mimeType || 'video/webm' }),
|
||||
segments,
|
||||
session,
|
||||
startedAt: t0,
|
||||
endedAt: Date.now(),
|
||||
mimeType: mimeType || 'video/webm',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
get segments() {
|
||||
return segments;
|
||||
},
|
||||
get startedAt() {
|
||||
return t0;
|
||||
},
|
||||
get active() {
|
||||
return !!recorder && recorder.state === 'recording';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-split a live file at the sealed offsets and recompute the chain. */
|
||||
export async function verifySegments(blob, segments) {
|
||||
let prev = null;
|
||||
for (const s of segments) {
|
||||
const part = blob.slice(s.offset, s.offset + s.bytes);
|
||||
const media = await sha256Hex(part);
|
||||
const { hash, ...core } = s;
|
||||
const h = await sha256Hex(canonical(core));
|
||||
if (media !== s.media || h !== hash || s.prev !== prev)
|
||||
return { ok: false, at: s.i };
|
||||
prev = hash;
|
||||
}
|
||||
return { ok: true, n: segments.length, head: prev };
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
/**
|
||||
* Witness provenance (DESIGN.md §6), the browser equivalent of a Secure
|
||||
* Enclave + C2PA flow:
|
||||
*
|
||||
* media → sha256
|
||||
* session { start, end, imu:[{t,α,β,γ,ax,ay,az}], gps:[{t,lat,lon,acc,alt}], heading }
|
||||
* context { device, ua, screen, timezone, network-time-offset, … }
|
||||
* prev hash of this device's previous bundle (hash chain)
|
||||
* hash sha256(canonical(core)) core = everything above + seq, key id, public key, sealedAt
|
||||
* signature ECDSA P-256 / SHA-256 over the hash bytes, with a device key that
|
||||
* is generated non-extractable and kept in IndexedDB.
|
||||
*
|
||||
* None of this proves truth. It raises the cost of lying.
|
||||
*/
|
||||
|
||||
import { get, put, putCapture, lastCapture, captureBySeq } from './db.js';
|
||||
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
const enc = new TextEncoder();
|
||||
const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
|
||||
const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
|
||||
|
||||
/** Deterministic JSON: sorted keys, no whitespace, JSON number semantics. */
|
||||
export function canonical(value) {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) return 'null';
|
||||
return JSON.stringify(value ?? null);
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return `[${value.map((v) => (v === undefined ? 'null' : canonical(v))).join(',')}]`;
|
||||
const keys = Object.keys(value)
|
||||
.filter((k) => value[k] !== undefined && typeof value[k] !== 'function')
|
||||
.sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
const normalize = (v) => JSON.parse(canonical(v));
|
||||
|
||||
export const toHex = (buf) =>
|
||||
[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const fromHex = (hex) =>
|
||||
new Uint8Array(hex.match(/../g).map((b) => parseInt(b, 16)));
|
||||
const b64url = (buf) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
const fromB64url = (s) => {
|
||||
const b = atob(
|
||||
s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4),
|
||||
);
|
||||
return Uint8Array.from(b, (c) => c.charCodeAt(0));
|
||||
};
|
||||
|
||||
export async function sha256Hex(data) {
|
||||
const bytes =
|
||||
typeof data === 'string'
|
||||
? enc.encode(data)
|
||||
: data instanceof Blob
|
||||
? await data.arrayBuffer()
|
||||
: data;
|
||||
return toHex(await subtle.digest('SHA-256', bytes));
|
||||
}
|
||||
|
||||
function assertCrypto() {
|
||||
if (!subtle)
|
||||
throw new Error('WebCrypto needs a secure context (https or localhost)');
|
||||
}
|
||||
|
||||
/** The device signing key: generated once, private half non-extractable. */
|
||||
export async function deviceKey() {
|
||||
assertCrypto();
|
||||
const stored = await get('keys', 'device').catch(() => null);
|
||||
if (stored?.privateKey && stored?.publicJwk) return stored;
|
||||
const pair = await subtle.generateKey(ALG, false, ['sign', 'verify']);
|
||||
const jwk = await subtle.exportKey('jwk', pair.publicKey);
|
||||
const publicJwk = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
|
||||
const keyId = (await sha256Hex(canonical(publicJwk))).slice(0, 16);
|
||||
const rec = {
|
||||
id: 'device',
|
||||
privateKey: pair.privateKey,
|
||||
publicKey: pair.publicKey,
|
||||
publicJwk,
|
||||
keyId,
|
||||
createdAt: new Date().toISOString(),
|
||||
extractable: pair.privateKey.extractable,
|
||||
};
|
||||
await put('keys', rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
/** Estimate the device clock's offset from the server's Date header (±1 s). */
|
||||
export async function networkTimeOffset() {
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const r = await fetch(location.pathname, {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
});
|
||||
const t1 = Date.now();
|
||||
const d = Date.parse(r.headers.get('date') || '');
|
||||
if (!Number.isFinite(d)) return null;
|
||||
return Math.round(d + 500 - (t0 + t1) / 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal a capture.
|
||||
* @param {{ media: Blob, session: object, context: object }} input
|
||||
* @returns {Promise<object>} the stored bundle
|
||||
*/
|
||||
export async function seal({ media, session, context }) {
|
||||
assertCrypto();
|
||||
const key = await deviceKey();
|
||||
const mediaHash = await sha256Hex(media);
|
||||
const last = await lastCapture().catch(() => null);
|
||||
const seq = (last?.seq || 0) + 1;
|
||||
const core = normalize({
|
||||
v: 1,
|
||||
seq,
|
||||
media: {
|
||||
sha256: mediaHash,
|
||||
type: media.type || 'application/octet-stream',
|
||||
bytes: media.size,
|
||||
},
|
||||
session,
|
||||
context,
|
||||
prev: last?.hash || null,
|
||||
keyId: key.keyId,
|
||||
publicKey: key.publicJwk,
|
||||
sealedAt: new Date().toISOString(),
|
||||
});
|
||||
const hash = await sha256Hex(canonical(core));
|
||||
const sig = await subtle.sign(SIGN, key.privateKey, fromHex(hash));
|
||||
const bundle = {
|
||||
...core,
|
||||
id: `cap-${String(seq).padStart(4, '0')}-${hash.slice(0, 8)}`,
|
||||
hash,
|
||||
alg: 'ES256',
|
||||
signature: b64url(sig),
|
||||
};
|
||||
await putCapture(bundle, media);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
const median = (a) => {
|
||||
if (!a.length) return NaN;
|
||||
const s = [...a].sort((x, y) => x - y);
|
||||
return s[Math.floor(s.length / 2)];
|
||||
};
|
||||
|
||||
/** Core fields covered by the hash (everything but the outputs). */
|
||||
function coreOf(bundle) {
|
||||
const { id, hash, alg, signature, ...core } = bundle;
|
||||
void id;
|
||||
void hash;
|
||||
void alg;
|
||||
void signature;
|
||||
return core;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a bundle: hash, signature, chain, sensor plausibility.
|
||||
* @returns {Promise<{ ok: boolean, checks: Array<{id,label,status,detail}> }>}
|
||||
*/
|
||||
export async function verify(bundle, mediaBlob) {
|
||||
assertCrypto();
|
||||
const checks = [];
|
||||
const add = (id, label, status, detail) =>
|
||||
checks.push({ id, label, status, detail });
|
||||
|
||||
// 1. Media hash
|
||||
if (mediaBlob) {
|
||||
const h = await sha256Hex(mediaBlob);
|
||||
add(
|
||||
'media',
|
||||
'Media hash',
|
||||
h === bundle.media?.sha256 ? 'pass' : 'fail',
|
||||
h === bundle.media?.sha256
|
||||
? `sha256 ${h.slice(0, 16)}… matches ${bundle.media.bytes.toLocaleString()} bytes`
|
||||
: `recomputed ${h.slice(0, 16)}… ≠ sealed ${String(bundle.media?.sha256).slice(0, 16)}…`,
|
||||
);
|
||||
} else
|
||||
add('media', 'Media hash', 'warn', 'Media not available on this device');
|
||||
|
||||
// 2. Bundle hash
|
||||
const recomputed = await sha256Hex(canonical(coreOf(bundle)));
|
||||
const hashOk = recomputed === bundle.hash;
|
||||
add(
|
||||
'hash',
|
||||
'Bundle hash',
|
||||
hashOk ? 'pass' : 'fail',
|
||||
hashOk
|
||||
? `sha256(canonical(bundle)) = ${recomputed.slice(0, 16)}…`
|
||||
: 'Bundle was modified after sealing',
|
||||
);
|
||||
|
||||
// 3. Signature
|
||||
try {
|
||||
const pub = await subtle.importKey(
|
||||
'jwk',
|
||||
{ ...bundle.publicKey, ext: true },
|
||||
ALG,
|
||||
true,
|
||||
['verify'],
|
||||
);
|
||||
const ok = await subtle.verify(
|
||||
SIGN,
|
||||
pub,
|
||||
fromB64url(bundle.signature),
|
||||
fromHex(bundle.hash),
|
||||
);
|
||||
const mine = await deviceKey().catch(() => null);
|
||||
const same = mine?.keyId === bundle.keyId;
|
||||
add(
|
||||
'signature',
|
||||
'Signature',
|
||||
ok ? (same ? 'pass' : 'warn') : 'fail',
|
||||
ok
|
||||
? `ECDSA P-256 valid · key ${bundle.keyId}${same ? ' (this device)' : ' (another device)'}`
|
||||
: 'Signature does not verify against the embedded public key',
|
||||
);
|
||||
} catch (err) {
|
||||
add('signature', 'Signature', 'fail', `Could not verify: ${err.message}`);
|
||||
}
|
||||
|
||||
// 4. Chain
|
||||
if (bundle.seq === 1)
|
||||
add(
|
||||
'chain',
|
||||
'Hash chain',
|
||||
bundle.prev === null ? 'pass' : 'fail',
|
||||
bundle.prev === null
|
||||
? 'Genesis capture for this device key'
|
||||
: 'First capture claims a predecessor',
|
||||
);
|
||||
else {
|
||||
const prev = await captureBySeq(bundle.seq - 1).catch(() => null);
|
||||
if (!prev)
|
||||
add(
|
||||
'chain',
|
||||
'Hash chain',
|
||||
'warn',
|
||||
`Capture #${bundle.seq - 1} is not on this device`,
|
||||
);
|
||||
else if (prev.hash === bundle.prev && prev.keyId === bundle.keyId)
|
||||
add(
|
||||
'chain',
|
||||
'Hash chain',
|
||||
'pass',
|
||||
`#${bundle.seq} ← #${prev.seq} ${prev.hash.slice(0, 12)}…`,
|
||||
);
|
||||
else add('chain', 'Hash chain', 'fail', `Link to #${prev.seq} is broken`);
|
||||
}
|
||||
|
||||
// 5. IMU plausibility
|
||||
const imu = bundle.session?.imu || [];
|
||||
if (imu.length < 5)
|
||||
add(
|
||||
'imu',
|
||||
'Motion trace',
|
||||
'warn',
|
||||
imu.length
|
||||
? `Only ${imu.length} samples`
|
||||
: 'No motion sensors were available',
|
||||
);
|
||||
else {
|
||||
const dur = (imu[imu.length - 1].t - imu[0].t) / 1000;
|
||||
const rate = imu.length / Math.max(0.001, dur);
|
||||
let mono = true;
|
||||
for (let i = 1; i < imu.length; i += 1)
|
||||
if (imu[i].t < imu[i - 1].t) mono = false;
|
||||
const mags = imu
|
||||
.filter((s) => Number.isFinite(s.ax))
|
||||
.map((s) => Math.hypot(s.ax, s.ay, s.az));
|
||||
const g = median(mags);
|
||||
const mean = mags.reduce((a, b) => a + b, 0) / Math.max(1, mags.length);
|
||||
const sd = Math.sqrt(
|
||||
mags.reduce((a, b) => a + (b - mean) ** 2, 0) / Math.max(1, mags.length),
|
||||
);
|
||||
const gravityOk = Number.isFinite(g) ? Math.abs(g - 9.81) < 1.6 : null;
|
||||
const alive = sd > 0.004;
|
||||
// Pitch from gravity should agree with the orientation β.
|
||||
let tiltAgree = null;
|
||||
const withBoth = imu.filter(
|
||||
(s) =>
|
||||
Number.isFinite(s.β) && Number.isFinite(s.ay) && Number.isFinite(s.az),
|
||||
);
|
||||
if (withBoth.length > 4) {
|
||||
// iOS reports gravity with the opposite sign to the W3C spec; accept either.
|
||||
const d = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
|
||||
const diffs = withBoth.map((s) => {
|
||||
const pitchG = (Math.atan2(s.ay, s.az) * 180) / Math.PI;
|
||||
const flipped = (Math.atan2(-s.ay, -s.az) * 180) / Math.PI;
|
||||
return Math.min(d(pitchG, s.β), d(flipped, s.β));
|
||||
});
|
||||
tiltAgree = median(diffs) < 20;
|
||||
}
|
||||
const status =
|
||||
!mono || gravityOk === false
|
||||
? 'fail'
|
||||
: alive && tiltAgree !== false
|
||||
? 'pass'
|
||||
: 'warn';
|
||||
add(
|
||||
'imu',
|
||||
'Motion trace',
|
||||
status,
|
||||
`${imu.length} samples · ${rate.toFixed(0)} Hz · ${dur.toFixed(1)} s · |g| ${Number.isFinite(g) ? g.toFixed(2) : '—'} m/s²${alive ? ' · hand tremor present' : ' · suspiciously still'}${tiltAgree === null ? '' : tiltAgree ? ' · tilt agrees with gravity' : ' · tilt disagrees with gravity'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. GPS plausibility
|
||||
const gps = bundle.session?.gps || [];
|
||||
if (!gps.length) add('gps', 'Location', 'warn', 'No GPS fix during capture');
|
||||
else {
|
||||
const s0 = Date.parse(bundle.session.start);
|
||||
const s1 = Date.parse(bundle.session.end);
|
||||
const inWindow = gps.every((f) => f.t >= s0 - 120e3 && f.t <= s1 + 120e3);
|
||||
let maxSpeed = 0;
|
||||
for (let i = 1; i < gps.length; i += 1) {
|
||||
const a = gps[i - 1];
|
||||
const b = gps[i];
|
||||
const dt = (b.t - a.t) / 1000;
|
||||
if (dt > 0.5) {
|
||||
const R = 6371e3;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
|
||||
const x =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((a.lat * Math.PI) / 180) *
|
||||
Math.cos((b.lat * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
maxSpeed = Math.max(maxSpeed, (2 * R * Math.asin(Math.sqrt(x))) / dt);
|
||||
}
|
||||
}
|
||||
const acc = median(gps.map((f) => f.acc).filter(Number.isFinite));
|
||||
const last = gps[gps.length - 1];
|
||||
// The clock's timezone should be roughly where the fix says we are.
|
||||
const tzH = -(bundle.context?.tzOffsetMin ?? 0) / 60;
|
||||
const solarH = last.lon / 15;
|
||||
const tzOk = Math.abs(((tzH - solarH + 36) % 24) - 12) < 3.5;
|
||||
const speedOk = maxSpeed < 300;
|
||||
const status =
|
||||
!inWindow || !speedOk ? 'fail' : tzOk && acc < 500 ? 'pass' : 'warn';
|
||||
add(
|
||||
'gps',
|
||||
'Location',
|
||||
status,
|
||||
`${gps.length} fix${gps.length > 1 ? 'es' : ''} · ±${Number.isFinite(acc) ? Math.round(acc) : '—'} m · max ${maxSpeed.toFixed(1)} m/s${inWindow ? ' · within session' : ' · outside session window'}${tzOk ? ' · clock zone agrees with longitude' : ' · clock zone disagrees with longitude'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Heading
|
||||
const hd = bundle.session?.heading;
|
||||
if (hd && Number.isFinite(hd.deg))
|
||||
add(
|
||||
'heading',
|
||||
'Heading',
|
||||
hd.source === 'compass' || hd.source === 'absolute' ? 'pass' : 'warn',
|
||||
`${Math.round(hd.deg)}° (${hd.source})${Number.isFinite(hd.pitch) ? ` · camera pitch ${hd.pitch.toFixed(0)}°` : ''}`,
|
||||
);
|
||||
else add('heading', 'Heading', 'warn', 'No compass heading recorded');
|
||||
|
||||
// 8. Clock
|
||||
const off = bundle.context?.networkTimeOffsetMs;
|
||||
const sealedVsEnd =
|
||||
Date.parse(bundle.sealedAt) - Date.parse(bundle.session?.end);
|
||||
const clockOk = Number.isFinite(off) ? Math.abs(off) < 5000 : null;
|
||||
add(
|
||||
'clock',
|
||||
'Clock',
|
||||
clockOk === false || sealedVsEnd > 60e3 || sealedVsEnd < -2000
|
||||
? 'warn'
|
||||
: clockOk
|
||||
? 'pass'
|
||||
: 'warn',
|
||||
`${Number.isFinite(off) ? `device vs network ${off >= 0 ? '+' : ''}${(off / 1000).toFixed(1)} s` : 'network time unavailable'} · sealed ${(sealedVsEnd / 1000).toFixed(1)} s after capture`,
|
||||
);
|
||||
|
||||
return { ok: !checks.some((c) => c.status === 'fail'), checks };
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
/**
|
||||
* Device sensors for Look and Witness: motion/orientation (with iOS
|
||||
* permission on a user gesture), compass heading, camera pose, GPS watch,
|
||||
* and a session recorder for the sealed IMU trace.
|
||||
*/
|
||||
|
||||
const D2R = Math.PI / 180;
|
||||
const R2D = 180 / Math.PI;
|
||||
|
||||
/** iOS 13+: must be called from a user gesture. */
|
||||
export async function requestMotionPermission() {
|
||||
const asks = [];
|
||||
if (
|
||||
typeof DeviceOrientationEvent !== 'undefined' &&
|
||||
typeof DeviceOrientationEvent.requestPermission === 'function'
|
||||
)
|
||||
asks.push(DeviceOrientationEvent.requestPermission());
|
||||
if (
|
||||
typeof DeviceMotionEvent !== 'undefined' &&
|
||||
typeof DeviceMotionEvent.requestPermission === 'function'
|
||||
)
|
||||
asks.push(DeviceMotionEvent.requestPermission());
|
||||
if (!asks.length)
|
||||
return typeof DeviceOrientationEvent === 'undefined'
|
||||
? 'unsupported'
|
||||
: 'granted';
|
||||
try {
|
||||
const res = await Promise.all(asks);
|
||||
return res.every((r) => r === 'granted') ? 'granted' : 'denied';
|
||||
} catch {
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
/** W3C Z-X'-Y'' rotation matrix (device → East-North-Up). */
|
||||
export function rotation(alpha, beta, gamma) {
|
||||
const a = alpha * D2R;
|
||||
const b = beta * D2R;
|
||||
const g = gamma * D2R;
|
||||
const cA = Math.cos(a);
|
||||
const sA = Math.sin(a);
|
||||
const cB = Math.cos(b);
|
||||
const sB = Math.sin(b);
|
||||
const cG = Math.cos(g);
|
||||
const sG = Math.sin(g);
|
||||
return [
|
||||
[cA * cG - sA * sB * sG, -cB * sA, cA * sG + cG * sA * sB],
|
||||
[cG * sA + cA * sB * sG, cA * cB, sA * sG - cA * cG * sB],
|
||||
[-cB * sG, sB, cB * cG],
|
||||
];
|
||||
}
|
||||
|
||||
/** Back-camera pose in ENU: forward / right / up unit vectors + azimuth/elevation. */
|
||||
export function cameraPose(alpha, beta, gamma, screenAngle = 0) {
|
||||
const R = rotation(alpha, beta, gamma);
|
||||
const col = (i) => [R[0][i], R[1][i], R[2][i]];
|
||||
const fwd = col(2).map((v) => -v);
|
||||
let right = col(0);
|
||||
let up = col(1);
|
||||
if (screenAngle) {
|
||||
// Landscape: rotate the screen basis about the forward axis.
|
||||
const s = screenAngle * D2R;
|
||||
const r2 = right.map((v, i) => v * Math.cos(s) + up[i] * Math.sin(s));
|
||||
const u2 = up.map((v, i) => v * Math.cos(s) - right[i] * Math.sin(s));
|
||||
right = r2;
|
||||
up = u2;
|
||||
}
|
||||
const az = (Math.atan2(fwd[0], fwd[1]) * R2D + 360) % 360;
|
||||
const el = Math.asin(Math.max(-1, Math.min(1, fwd[2]))) * R2D;
|
||||
const roll = Math.atan2(right[2], up[2]) * R2D;
|
||||
return { fwd, right, up, az, el, roll };
|
||||
}
|
||||
|
||||
/** Pose from a simulated yaw/pitch (desktop drag-to-look). */
|
||||
export function poseFromAzEl(az, el) {
|
||||
const a = az * D2R;
|
||||
const e = el * D2R;
|
||||
const fwd = [
|
||||
Math.cos(e) * Math.sin(a),
|
||||
Math.cos(e) * Math.cos(a),
|
||||
Math.sin(e),
|
||||
];
|
||||
const right = [Math.cos(a), -Math.sin(a), 0];
|
||||
const up = [
|
||||
right[1] * fwd[2] - right[2] * fwd[1],
|
||||
right[2] * fwd[0] - right[0] * fwd[2],
|
||||
right[0] * fwd[1] - right[1] * fwd[0],
|
||||
];
|
||||
return { fwd, right, up, az: ((az % 360) + 360) % 360, el, roll: 0 };
|
||||
}
|
||||
|
||||
export function createSensors() {
|
||||
const state = {
|
||||
orientation: null, // {alpha,beta,gamma,absolute}
|
||||
motion: null, // {ax,ay,az,lx,ly,lz,rr}
|
||||
compass: null, // degrees, iOS webkitCompassHeading
|
||||
compassAccuracy: null,
|
||||
fix: null, // {t,lat,lon,acc,alt,heading,speed}
|
||||
geoError: null,
|
||||
hasMotion: false,
|
||||
hasOrientation: false,
|
||||
absolute: false,
|
||||
};
|
||||
const trace = []; // live |a| for the HUD
|
||||
let rec = null;
|
||||
let geoWatch = null;
|
||||
let alphaOffset = 0;
|
||||
let listeners = [];
|
||||
|
||||
function onOrient(e) {
|
||||
if (e.alpha == null && e.beta == null) return;
|
||||
state.hasOrientation = true;
|
||||
const absolute =
|
||||
e.absolute === true || e.type === 'deviceorientationabsolute';
|
||||
if (absolute) state.absolute = true;
|
||||
if (
|
||||
!absolute &&
|
||||
state.absolute &&
|
||||
e.type === 'deviceorientation' &&
|
||||
e.webkitCompassHeading == null
|
||||
)
|
||||
return;
|
||||
state.orientation = {
|
||||
alpha: e.alpha ?? 0,
|
||||
beta: e.beta ?? 0,
|
||||
gamma: e.gamma ?? 0,
|
||||
absolute,
|
||||
};
|
||||
if (e.webkitCompassHeading != null) {
|
||||
state.compass = e.webkitCompassHeading;
|
||||
state.compassAccuracy = e.webkitCompassAccuracy;
|
||||
// Align the arbitrary iOS alpha with magnetic north, smoothly.
|
||||
// az = −α for an upright camera, so α + (raw − compass) points at north.
|
||||
const raw = cameraPose(e.alpha, e.beta, e.gamma).az;
|
||||
const target = ((raw - e.webkitCompassHeading + 540) % 360) - 180;
|
||||
alphaOffset += (((target - alphaOffset + 540) % 360) - 180) * 0.15;
|
||||
}
|
||||
if (rec) sample();
|
||||
}
|
||||
function onMotion(e) {
|
||||
const g = e.accelerationIncludingGravity;
|
||||
const l = e.acceleration;
|
||||
if (!g || g.x == null) return;
|
||||
state.hasMotion = true;
|
||||
state.motion = {
|
||||
ax: g.x,
|
||||
ay: g.y,
|
||||
az: g.z,
|
||||
lx: l?.x,
|
||||
ly: l?.y,
|
||||
lz: l?.z,
|
||||
rr: e.rotationRate
|
||||
? [e.rotationRate.alpha, e.rotationRate.beta, e.rotationRate.gamma]
|
||||
: null,
|
||||
};
|
||||
const mag = Math.hypot(g.x, g.y, g.z);
|
||||
trace.push({ t: performance.now(), v: mag });
|
||||
while (trace.length && performance.now() - trace[0].t > 5000) trace.shift();
|
||||
if (rec) sample();
|
||||
}
|
||||
|
||||
let lastSampleT = 0;
|
||||
function sample() {
|
||||
const now = performance.now();
|
||||
if (now - lastSampleT < 15) return; // ≤ ~66 Hz
|
||||
lastSampleT = now;
|
||||
const o = state.orientation || {};
|
||||
const m = state.motion || {};
|
||||
rec.imu.push({
|
||||
t: Date.now() - rec.t0Wall,
|
||||
α: round(o.alpha),
|
||||
β: round(o.beta),
|
||||
γ: round(o.gamma),
|
||||
ax: round(m.ax, 3),
|
||||
ay: round(m.ay, 3),
|
||||
az: round(m.az, 3),
|
||||
});
|
||||
if (rec.imu.length > 4000) rec.imu.shift();
|
||||
}
|
||||
|
||||
function round(v, d = 2) {
|
||||
return Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null;
|
||||
}
|
||||
|
||||
function on(target, type, fn) {
|
||||
target.addEventListener(type, fn, { passive: true });
|
||||
listeners.push(() => target.removeEventListener(type, fn));
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
if ('ondeviceorientationabsolute' in window)
|
||||
on(window, 'deviceorientationabsolute', onOrient);
|
||||
on(window, 'deviceorientation', onOrient);
|
||||
on(window, 'devicemotion', onMotion);
|
||||
if ('geolocation' in navigator) {
|
||||
geoWatch = navigator.geolocation.watchPosition(
|
||||
(p) => {
|
||||
state.geoError = null;
|
||||
state.fix = {
|
||||
t: p.timestamp || Date.now(),
|
||||
lat: p.coords.latitude,
|
||||
lon: p.coords.longitude,
|
||||
acc: p.coords.accuracy,
|
||||
alt: p.coords.altitude,
|
||||
heading: p.coords.heading,
|
||||
speed: p.coords.speed,
|
||||
};
|
||||
if (rec)
|
||||
rec.gps.push({
|
||||
t: state.fix.t,
|
||||
lat: round(state.fix.lat, 6),
|
||||
lon: round(state.fix.lon, 6),
|
||||
acc: round(state.fix.acc, 1),
|
||||
alt: round(state.fix.alt, 1),
|
||||
});
|
||||
},
|
||||
(err) => (state.geoError = err.message || 'Location unavailable'),
|
||||
{ enableHighAccuracy: true, maximumAge: 2000, timeout: 20000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
listeners.forEach((f) => f());
|
||||
listeners = [];
|
||||
if (geoWatch != null) navigator.geolocation.clearWatch(geoWatch);
|
||||
geoWatch = null;
|
||||
}
|
||||
|
||||
/** Current back-camera pose, or null without orientation sensors. */
|
||||
function pose(screenAngle = screenOrientationAngle()) {
|
||||
const o = state.orientation;
|
||||
if (!o) return null;
|
||||
const alpha = state.compass != null ? o.alpha + alphaOffset : o.alpha;
|
||||
return cameraPose(alpha, o.beta, o.gamma, screenAngle);
|
||||
}
|
||||
|
||||
function headingInfo() {
|
||||
const p = pose();
|
||||
if (!p) return null;
|
||||
return {
|
||||
deg: Math.round(p.az * 10) / 10,
|
||||
pitch: Math.round(p.el * 10) / 10,
|
||||
roll: Math.round(p.roll * 10) / 10,
|
||||
source:
|
||||
state.compass != null
|
||||
? 'compass'
|
||||
: state.absolute
|
||||
? 'absolute'
|
||||
: 'relative',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
trace,
|
||||
start,
|
||||
stop,
|
||||
pose,
|
||||
headingInfo,
|
||||
/** Begin recording a session (IMU + GPS). Keeps a pre-roll of what's already live. */
|
||||
beginSession() {
|
||||
rec = {
|
||||
t0Wall: Date.now(),
|
||||
start: new Date().toISOString(),
|
||||
imu: [],
|
||||
gps: [],
|
||||
};
|
||||
if (state.fix)
|
||||
rec.gps.push({
|
||||
t: state.fix.t,
|
||||
lat: round(state.fix.lat, 6),
|
||||
lon: round(state.fix.lon, 6),
|
||||
acc: round(state.fix.acc, 1),
|
||||
alt: round(state.fix.alt, 1),
|
||||
});
|
||||
return rec;
|
||||
},
|
||||
endSession() {
|
||||
if (!rec) return null;
|
||||
const r = rec;
|
||||
rec = null;
|
||||
// Keep the last 20 s of motion before the shutter.
|
||||
const cutoff = Date.now() - r.t0Wall - 20000;
|
||||
const imu = r.imu.filter((s) => s.t >= cutoff);
|
||||
if (!r.gps.length && state.fix)
|
||||
r.gps.push({
|
||||
t: state.fix.t,
|
||||
lat: round(state.fix.lat, 6),
|
||||
lon: round(state.fix.lon, 6),
|
||||
acc: round(state.fix.acc, 1),
|
||||
alt: round(state.fix.alt, 1),
|
||||
});
|
||||
return {
|
||||
start: r.start,
|
||||
end: new Date().toISOString(),
|
||||
imu,
|
||||
gps: r.gps,
|
||||
heading: headingInfo(),
|
||||
};
|
||||
},
|
||||
get recording() {
|
||||
return !!rec;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function screenOrientationAngle() {
|
||||
const a = screen.orientation?.angle ?? window.orientation ?? 0;
|
||||
return Number(a) || 0;
|
||||
}
|
||||
|
||||
/* ---------- AR geometry ---------- */
|
||||
|
||||
const A = 6378137;
|
||||
const E2 = 6.69437999014e-3;
|
||||
|
||||
function ecef(lat, lon, h) {
|
||||
const la = lat * D2R;
|
||||
const lo = lon * D2R;
|
||||
const N = A / Math.sqrt(1 - E2 * Math.sin(la) ** 2);
|
||||
return [
|
||||
(N + h) * Math.cos(la) * Math.cos(lo),
|
||||
(N + h) * Math.cos(la) * Math.sin(lo),
|
||||
(N * (1 - E2) + h) * Math.sin(la),
|
||||
];
|
||||
}
|
||||
|
||||
/** Azimuth, elevation (deg) and slant range (m) from an observer to a target. */
|
||||
export function lookAngles(obs, tgt) {
|
||||
const o = ecef(obs.lat, obs.lon, obs.alt || 0);
|
||||
const t = ecef(tgt.lat, tgt.lon, tgt.altM || 0);
|
||||
const d = [t[0] - o[0], t[1] - o[1], t[2] - o[2]];
|
||||
const la = obs.lat * D2R;
|
||||
const lo = obs.lon * D2R;
|
||||
const e = -Math.sin(lo) * d[0] + Math.cos(lo) * d[1];
|
||||
const n =
|
||||
-Math.sin(la) * Math.cos(lo) * d[0] -
|
||||
Math.sin(la) * Math.sin(lo) * d[1] +
|
||||
Math.cos(la) * d[2];
|
||||
const u =
|
||||
Math.cos(la) * Math.cos(lo) * d[0] +
|
||||
Math.cos(la) * Math.sin(lo) * d[1] +
|
||||
Math.sin(la) * d[2];
|
||||
const range = Math.hypot(e, n, u);
|
||||
return {
|
||||
az: (Math.atan2(e, n) * R2D + 360) % 360,
|
||||
el: Math.asin(u / range) * R2D,
|
||||
range,
|
||||
enu: [e / range, n / range, u / range],
|
||||
};
|
||||
}
|
||||
|
||||
/** Project an ENU unit direction into screen pixels for a pinhole camera. */
|
||||
export function projectDir(dir, pose, W, H, vfovDeg) {
|
||||
const x =
|
||||
dir[0] * pose.right[0] + dir[1] * pose.right[1] + dir[2] * pose.right[2];
|
||||
const y = dir[0] * pose.up[0] + dir[1] * pose.up[1] + dir[2] * pose.up[2];
|
||||
const z = dir[0] * pose.fwd[0] + dir[1] * pose.fwd[1] + dir[2] * pose.fwd[2];
|
||||
const fy = H / 2 / Math.tan((vfovDeg / 2) * D2R);
|
||||
if (z <= 0.01)
|
||||
return { behind: true, x: W / 2 + x * 1e4, y: H / 2 - y * 1e4 };
|
||||
return { behind: false, x: W / 2 + (x / z) * fy, y: H / 2 - (y / z) * fy, z };
|
||||
}
|
||||
|
||||
export function dirFromAzEl(az, el) {
|
||||
const a = az * D2R;
|
||||
const e = el * D2R;
|
||||
return [Math.cos(e) * Math.sin(a), Math.cos(e) * Math.cos(a), Math.sin(e)];
|
||||
}
|
||||
@@ -1,935 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds the hero Moment, mobile/demo/moments/empire-state.json:
|
||||
* "Climbers on the Empire State Building" (DESIGN.md §12.4).
|
||||
*
|
||||
* ILLUSTRATIVE. Nothing here happened. The event, the helicopters, their
|
||||
* callsigns and tail numbers, the witnesses, the chat and the posts are all
|
||||
* fictional, and the file says so (`illustrative: true`). What is real is the
|
||||
* place: the building's position and heights, the street corners, the
|
||||
* heliports, and the neighbouring building massing (midtown-buildings.json).
|
||||
*
|
||||
* The helicopter tracks come from a small flight model, not from hand-drawn
|
||||
* circles: each aircraft flies a constant airspeed in a steady wind, steers
|
||||
* to hold an orbit radius with a rate-limited heading (about standard rate),
|
||||
* and drifts in altitude. The ground track that results is what ADS-B shows
|
||||
* for a real news orbit: stretched downwind, faster on the downwind side,
|
||||
* with a few metres of position noise. Output is 1 Hz.
|
||||
*
|
||||
* node mobile/demo/build-empire-state.mjs
|
||||
*
|
||||
* Deterministic: the same seed always writes the same file.
|
||||
*/
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
/** JSON as the repo's Prettier would write it (so `prettier --check` passes). */
|
||||
async function prettyJson(value) {
|
||||
const text = JSON.stringify(value);
|
||||
try {
|
||||
const prettier = await import('prettier');
|
||||
return await prettier.format(text, { parser: 'json' });
|
||||
} catch {
|
||||
return `${text}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SEED = 0x35_0f1f7a;
|
||||
|
||||
/* ── Place (real) ───────────────────────────────────────────────── */
|
||||
const ESB = { lon: -73.98566, lat: 40.74844 };
|
||||
const GROUND_MSL = 15; // m, 5th Ave at 34th St
|
||||
const ROOF = 381; // m above street (102nd floor roof)
|
||||
const ANTENNA = 443; // m, antenna tip
|
||||
const DECK_86 = 320; // m, 86th floor open-air deck
|
||||
const DECK_102 = 373; // m, 102nd floor deck
|
||||
const DMH = {
|
||||
lon: -74.00905,
|
||||
lat: 40.70137,
|
||||
name: 'Downtown Manhattan Heliport',
|
||||
};
|
||||
const E34 = {
|
||||
lon: -73.97175,
|
||||
lat: 40.74245,
|
||||
name: 'East 34th Street Heliport',
|
||||
};
|
||||
const NJ = { lon: -74.0742, lat: 40.7727, name: 'over Secaucus, NJ' };
|
||||
|
||||
/* ── Clock (fictional date) ─────────────────────────────────────── */
|
||||
const T0 = Date.UTC(2026, 8, 18, 21, 18, 0); // 5:18 PM EDT
|
||||
const DURATION_S = 25 * 60;
|
||||
const at = (mmss) => {
|
||||
const [m, s] = String(mmss).split(':').map(Number);
|
||||
return T0 + (m * 60 + (s || 0)) * 1000;
|
||||
};
|
||||
|
||||
/* ── Deterministic randomness ───────────────────────────────────── */
|
||||
function mulberry32(a) {
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
const rand = mulberry32(SEED);
|
||||
const gauss = () => {
|
||||
let u = 0;
|
||||
let v = 0;
|
||||
while (!u) u = rand();
|
||||
while (!v) v = rand();
|
||||
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
||||
};
|
||||
|
||||
/* ── Local metric frame ─────────────────────────────────────────── */
|
||||
const D2R = Math.PI / 180;
|
||||
const KX = 111_320 * Math.cos(ESB.lat * D2R);
|
||||
const KY = 110_574;
|
||||
const toXY = (p) => [(p.lon - ESB.lon) * KX, (p.lat - ESB.lat) * KY];
|
||||
const toLL = (x, y) => ({ lon: ESB.lon + x / KX, lat: ESB.lat + y / KY });
|
||||
const KT = 0.514444;
|
||||
const FT = 0.3048;
|
||||
const wrap = (d) => (((d % 360) + 540) % 360) - 180;
|
||||
const bearingXY = (dx, dy) => (Math.atan2(dx, dy) / D2R + 360) % 360;
|
||||
|
||||
/* ── Flight model ───────────────────────────────────────────────── */
|
||||
// Wind at 1,000–1,500 ft over Midtown: 14 kt from 290° (blowing toward 110°).
|
||||
const WIND = { fromDeg: 290, kt: 14 };
|
||||
const windV = () => {
|
||||
const to = (WIND.fromDeg + 180) * D2R;
|
||||
return [Math.sin(to) * WIND.kt * KT, Math.cos(to) * WIND.kt * KT];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly a helicopter through `plan` (a list of legs) and return 1 Hz points.
|
||||
* Legs: { until, mode:'ground'|'goto'|'orbit', ... }.
|
||||
*/
|
||||
function fly({ start, startAltFt, plan, tStart, tEnd, noiseM = 4 }) {
|
||||
const dt = 0.2;
|
||||
let [x, y] = toXY(start);
|
||||
let alt = startAltFt * FT;
|
||||
let hdg = plan[0].initialHeading ?? 0;
|
||||
let air = 0; // airspeed m/s
|
||||
const [wx, wy] = windV();
|
||||
const out = [];
|
||||
let leg = 0;
|
||||
let nextOut = tStart;
|
||||
let altPhase = rand() * Math.PI * 2;
|
||||
for (let t = tStart; t <= tEnd; t += dt * 1000) {
|
||||
while (leg < plan.length - 1 && t >= plan[leg].until) leg++;
|
||||
const L = plan[leg];
|
||||
let targetAir = (L.kt ?? 0) * KT;
|
||||
let targetAlt = (L.altFt ?? startAltFt) * FT;
|
||||
let desired = hdg;
|
||||
if (L.mode === 'ground') {
|
||||
targetAir = 0;
|
||||
targetAlt = (L.altFt ?? 0) * FT;
|
||||
} else if (L.mode === 'goto') {
|
||||
const [tx, ty] = toXY(L.to);
|
||||
desired = bearingXY(tx - x, ty - y);
|
||||
// Arrive gently: slow down inside 600 m of the end of a goto leg.
|
||||
const d = Math.hypot(tx - x, ty - y);
|
||||
if (L.slowNear && d < 600)
|
||||
targetAir = Math.max(L.slowNear * KT, targetAir * (d / 600));
|
||||
} else if (L.mode === 'orbit') {
|
||||
const [cx, cy] = toXY(L.center);
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const beta = bearingXY(dx, dy);
|
||||
// Radius breathes a little: pilots never fly a perfect circle.
|
||||
const r =
|
||||
L.radius * (1 + 0.06 * Math.sin((t - tStart) / 1000 / 47 + L.radius));
|
||||
const err = Math.atan((d - r) / 110) / D2R;
|
||||
desired = L.dir === 'cw' ? beta + 90 + err : beta - 90 - err;
|
||||
// The heading that holds that ground track in the wind (crab angle).
|
||||
const gx = Math.sin(desired * D2R);
|
||||
const gy = Math.cos(desired * D2R);
|
||||
const cross = wx * gy - wy * gx; // wind across the track
|
||||
const crab = Math.asin(
|
||||
Math.max(-0.9, Math.min(0.9, cross / Math.max(8, targetAir))),
|
||||
);
|
||||
desired = desired + crab / D2R;
|
||||
}
|
||||
// Heading: rate-limited, about standard rate (3°/s), up to 5.5°/s.
|
||||
const maxRate = (L.maxRate ?? 5.5) * dt;
|
||||
const dh = wrap(desired - hdg);
|
||||
hdg = (hdg + Math.max(-maxRate, Math.min(maxRate, dh)) + 360) % 360;
|
||||
// Speed and altitude respond with first-order lags.
|
||||
air += (targetAir - air) * Math.min(1, dt / 6);
|
||||
altPhase += dt / 37;
|
||||
const drift =
|
||||
L.mode === 'orbit' ? (28 * Math.sin(altPhase) + 9 * gauss()) * FT : 0;
|
||||
alt += (targetAlt + drift - alt) * Math.min(1, dt / 9);
|
||||
if (L.mode === 'ground') {
|
||||
air = 0;
|
||||
alt += (0 - alt) * Math.min(1, dt / 4);
|
||||
} else {
|
||||
x += (Math.sin(hdg * D2R) * air + wx * Math.min(1, air / 10)) * dt;
|
||||
y += (Math.cos(hdg * D2R) * air + wy * Math.min(1, air / 10)) * dt;
|
||||
}
|
||||
if (t >= nextOut) {
|
||||
nextOut += 1000;
|
||||
if (L.mode === 'hidden') continue;
|
||||
const p = toLL(x + gauss() * noiseM, y + gauss() * noiseM);
|
||||
// ADS-B altitude: 25 ft steps, MSL.
|
||||
const altFt = Math.round((alt / FT + GROUND_MSL / FT) / 25) * 25;
|
||||
out.push({
|
||||
t: Math.round(t),
|
||||
lon: Math.round(p.lon * 1e6) / 1e6,
|
||||
lat: Math.round(p.lat * 1e6) / 1e6,
|
||||
altM: Math.round(altFt * FT),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const near = (p, m, deg) => {
|
||||
const [x, y] = toXY(p);
|
||||
return toLL(x + Math.sin(deg * D2R) * m, y + Math.cos(deg * D2R) * m);
|
||||
};
|
||||
const orbitCenter = (m, deg) => near(ESB, m, deg);
|
||||
|
||||
/* ── The three helicopters (fictional) ──────────────────────────── */
|
||||
const police = fly({
|
||||
start: DMH,
|
||||
startAltFt: 0,
|
||||
tStart: T0,
|
||||
tEnd: T0 + DURATION_S * 1000,
|
||||
plan: [
|
||||
{ mode: 'ground', until: at('0:20'), initialHeading: 20 },
|
||||
// Up the west side, over the Hudson shoreline, then across to 34th St.
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -74.0135, lat: 40.7205 },
|
||||
kt: 95,
|
||||
altFt: 900,
|
||||
until: at('0:55'),
|
||||
},
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -74.0055, lat: 40.739 },
|
||||
kt: 110,
|
||||
altFt: 1100,
|
||||
until: at('1:40'),
|
||||
},
|
||||
{
|
||||
mode: 'goto',
|
||||
to: near(ESB, 520, 240),
|
||||
kt: 90,
|
||||
altFt: 1100,
|
||||
slowNear: 70,
|
||||
until: at('2:10'),
|
||||
},
|
||||
{
|
||||
mode: 'orbit',
|
||||
center: orbitCenter(60, 200),
|
||||
radius: 470,
|
||||
kt: 64,
|
||||
altFt: 1100,
|
||||
dir: 'ccw',
|
||||
until: at('14:00'),
|
||||
},
|
||||
// Tightens and steps down once officers reach the 102nd floor deck.
|
||||
{
|
||||
mode: 'orbit',
|
||||
center: orbitCenter(30, 120),
|
||||
radius: 410,
|
||||
kt: 58,
|
||||
altFt: 1000,
|
||||
dir: 'ccw',
|
||||
until: at('25:01'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const news7 = fly({
|
||||
start: NJ,
|
||||
startAltFt: 900,
|
||||
tStart: T0 + 85_000,
|
||||
tEnd: T0 + DURATION_S * 1000,
|
||||
plan: [
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -74.024, lat: 40.7585 },
|
||||
kt: 120,
|
||||
altFt: 1400,
|
||||
until: at('3:20'),
|
||||
initialHeading: 120,
|
||||
},
|
||||
{
|
||||
mode: 'goto',
|
||||
to: near(ESB, 700, 300),
|
||||
kt: 105,
|
||||
altFt: 1450,
|
||||
slowNear: 80,
|
||||
until: at('4:30'),
|
||||
},
|
||||
{
|
||||
mode: 'orbit',
|
||||
center: orbitCenter(120, 30),
|
||||
radius: 660,
|
||||
kt: 76,
|
||||
altFt: 1450,
|
||||
dir: 'ccw',
|
||||
until: at('25:01'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sky4 = fly({
|
||||
start: E34,
|
||||
startAltFt: 0,
|
||||
tStart: T0 + 330_000,
|
||||
tEnd: T0 + DURATION_S * 1000,
|
||||
plan: [
|
||||
{ mode: 'ground', until: at('6:00'), initialHeading: 300 },
|
||||
{
|
||||
mode: 'goto',
|
||||
to: near(ESB, 600, 140),
|
||||
kt: 70,
|
||||
altFt: 1250,
|
||||
slowNear: 62,
|
||||
until: at('7:25'),
|
||||
},
|
||||
{
|
||||
mode: 'orbit',
|
||||
center: orbitCenter(90, 160),
|
||||
radius: 560,
|
||||
kt: 68,
|
||||
altFt: 1250,
|
||||
dir: 'ccw',
|
||||
until: at('21:00'),
|
||||
},
|
||||
// Leaves for the next story: back out over the East River, south.
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -73.962, lat: 40.728 },
|
||||
kt: 110,
|
||||
altFt: 1300,
|
||||
until: at('22:30'),
|
||||
},
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -73.99, lat: 40.69 },
|
||||
kt: 120,
|
||||
altFt: 1300,
|
||||
until: at('25:01'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tracks = [
|
||||
{
|
||||
id: 'pd-14',
|
||||
kind: 'helicopter',
|
||||
label: 'PD-14 (illustrative)',
|
||||
callsign: 'PD14',
|
||||
registration: 'ILLUS-14',
|
||||
type: 'Bell 429',
|
||||
operator: 'City police aviation (fictional)',
|
||||
role: 'police',
|
||||
color: '#5EC8FF',
|
||||
points: police,
|
||||
},
|
||||
{
|
||||
id: 'news-7',
|
||||
kind: 'helicopter',
|
||||
label: 'NEWS-7 (illustrative)',
|
||||
callsign: 'NEWS7',
|
||||
registration: 'ILLUS-07',
|
||||
type: 'Airbus H125',
|
||||
operator: 'Channel 7 News (fictional)',
|
||||
role: 'news',
|
||||
color: '#FFD166',
|
||||
points: news7,
|
||||
},
|
||||
{
|
||||
id: 'sky-4',
|
||||
kind: 'helicopter',
|
||||
label: 'SKY-4 (illustrative)',
|
||||
callsign: 'SKY4',
|
||||
registration: 'ILLUS-04',
|
||||
type: 'Airbus H130',
|
||||
operator: 'Sky 4 News (fictional)',
|
||||
role: 'news',
|
||||
color: '#FF9F6B',
|
||||
points: sky4,
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Witness captures (fictional people, real corners) ──────────── */
|
||||
function aim(p, altM, targetAltM) {
|
||||
const [x, y] = toXY(p);
|
||||
const d = Math.hypot(x, y);
|
||||
return {
|
||||
headingDeg: Math.round(bearingXY(-x, -y) * 10) / 10,
|
||||
pitchDeg: Math.round((Math.atan2(targetAltM - altM, d) / D2R) * 10) / 10,
|
||||
rangeM: Math.round(d),
|
||||
};
|
||||
}
|
||||
const capture = (id, mmss, where, altM, targetAltM, fovDeg, rest) => ({
|
||||
id,
|
||||
t: at(mmss),
|
||||
lon: where.lon,
|
||||
lat: where.lat,
|
||||
altM,
|
||||
...aim(where, altM, targetAltM),
|
||||
fovDeg,
|
||||
sealed: true,
|
||||
...rest,
|
||||
});
|
||||
const captures = [
|
||||
capture(
|
||||
'c1',
|
||||
'0:52',
|
||||
{ lon: -73.98795, lat: 40.74968 },
|
||||
1.5,
|
||||
DECK_86 + 10,
|
||||
22,
|
||||
{
|
||||
title: 'Two figures outside the 86th floor fence',
|
||||
handle: 'kvn_on_34th',
|
||||
place: 'W 34th St & 6th Ave',
|
||||
firstEyes: true,
|
||||
kind: 'photo',
|
||||
},
|
||||
),
|
||||
capture(
|
||||
'c2',
|
||||
'3:05',
|
||||
{ lon: -73.98752, lat: 40.74482 },
|
||||
1.5,
|
||||
DECK_86 + 30,
|
||||
30,
|
||||
{
|
||||
title: 'North face, police helicopter overhead',
|
||||
handle: 'jules_flatiron',
|
||||
place: '5th Ave & 29th St',
|
||||
kind: 'clip',
|
||||
},
|
||||
),
|
||||
capture('c3', '6:10', { lon: -73.99325, lat: 40.75195 }, 142, ROOF + 20, 18, {
|
||||
title: 'On the mast, east side, from the 40th floor',
|
||||
handle: 'penn_plaza_40th',
|
||||
place: '1 Penn Plaza, 40th floor',
|
||||
kind: 'clip',
|
||||
}),
|
||||
capture('c4', '8:40', { lon: -74.00355, lat: 40.75255 }, 9, ROOF, 14, {
|
||||
title: 'Three helicopters around the spire',
|
||||
handle: 'hellsk1tchen',
|
||||
place: 'The High Line at W 30th St',
|
||||
kind: 'photo',
|
||||
}),
|
||||
capture('c5', '12:20', { lon: -73.98805, lat: 40.74745 }, 55, DECK_102, 40, {
|
||||
title: 'Officers on the 102nd floor deck',
|
||||
handle: 'lena.ktown',
|
||||
place: 'Rooftop, W 32nd St (Koreatown)',
|
||||
kind: 'clip',
|
||||
}),
|
||||
capture('c6', '15:30', { lon: -73.98205, lat: 40.75112 }, 1.5, ROOF, 28, {
|
||||
title: 'Crowd on 5th Ave looking up',
|
||||
handle: 'midtown_mara',
|
||||
place: '5th Ave & 38th St',
|
||||
kind: 'photo',
|
||||
}),
|
||||
capture('c7', '19:45', { lon: -73.97765, lat: 40.74662 }, 48, DECK_102, 20, {
|
||||
title: 'Both back over the rail at 102',
|
||||
handle: 'rooftop_rafa',
|
||||
place: 'Rooftop, Murray Hill',
|
||||
kind: 'clip',
|
||||
}),
|
||||
capture(
|
||||
'c8',
|
||||
'21:10',
|
||||
{ lon: -73.98875, lat: 40.74905 },
|
||||
1.5,
|
||||
ROOF - 40,
|
||||
34,
|
||||
{
|
||||
title: 'SKY-4 peeling away east',
|
||||
handle: 'deli_guy_dan',
|
||||
place: 'Broadway & 33rd St (Herald Sq)',
|
||||
kind: 'photo',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
/* ── Live lane: chat (fictional handles) ────────────────────────── */
|
||||
// [m:ss, handle, text, badge?]. Bursts follow the beats: first sighting,
|
||||
// police arrival, news arrival, the mast climb, the top, officers on deck,
|
||||
// coming down.
|
||||
const CHAT = [
|
||||
['0:12', 'deli_guy_dan', 'why is everyone on 34th looking up'],
|
||||
[
|
||||
'0:31',
|
||||
'kvn_on_34th',
|
||||
'theres PEOPLE on the empire state. outside the fence on 86',
|
||||
'local',
|
||||
],
|
||||
['0:38', 'midtown_mara', 'wait what'],
|
||||
['0:44', 'brklyn_bea', 'link??'],
|
||||
[
|
||||
'0:52',
|
||||
'kvn_on_34th',
|
||||
'sealed photo up. north side, two of them',
|
||||
'first-eyes',
|
||||
],
|
||||
['0:58', 'throwaway_tom', 'thats a window washer rig'],
|
||||
['1:04', 'kvn_on_34th', 'no rig. no ropes that I can see', 'first-eyes'],
|
||||
['1:10', 'jules_flatiron', 'omg I see them from 29th'],
|
||||
['1:19', 'nyc_scanner_nerd', 'scanner: units responding to 350 5th', 'local'],
|
||||
['1:33', 'ess_ay_dee', '👀👀👀'],
|
||||
['1:41', 'midtown_mara', 'this is insane'],
|
||||
[
|
||||
'1:52',
|
||||
'avgeek_jfk',
|
||||
'police helo just lifted out of the downtown heliport',
|
||||
'pilot',
|
||||
],
|
||||
['2:04', 'brklyn_bea', 'I hear it'],
|
||||
['2:10', 'nyc_scanner_nerd', 'PD-14 overhead now, orbiting', 'local'],
|
||||
['2:12', 'deli_guy_dan', 'LOUD'],
|
||||
['2:14', 'ess_ay_dee', '🔥🔥'],
|
||||
['2:16', 'hellsk1tchen', 'can see the heli circling from the west side'],
|
||||
['2:21', 'throwaway_tom', 'ok not window washers lol'],
|
||||
['2:25', 'murrayhill_mo', 'is this a protest?'],
|
||||
[
|
||||
'2:31',
|
||||
'kvn_on_34th',
|
||||
'no banner, they just look like climbers. helmets',
|
||||
'first-eyes',
|
||||
],
|
||||
['2:44', 'lena.ktown', 'going up to our roof'],
|
||||
['2:58', 'jules_flatiron', 'clip sealed from 5th & 29th', 'witness'],
|
||||
['3:12', 'brklyn_bea', 'how did they even get out there'],
|
||||
[
|
||||
'3:26',
|
||||
'avgeek_jfk',
|
||||
'orbit looks ~1,100 ft, tight, maybe 500 m radius',
|
||||
'pilot',
|
||||
],
|
||||
['3:39', 'midtown_mara', 'they are not moving'],
|
||||
['3:55', 'ess_ay_dee', 'pray for them fr'],
|
||||
['4:08', 'nyc_scanner_nerd', 'second helo inbound from jersey', 'local'],
|
||||
['4:22', 'deli_guy_dan', 'news is here'],
|
||||
['4:30', 'avgeek_jfk', 'NEWS-7 up high, ~1,450', 'pilot'],
|
||||
['4:33', 'murrayhill_mo', 'its on tv now'],
|
||||
['4:35', 'brklyn_bea', 'channel 7 live rn'],
|
||||
['4:37', 'ess_ay_dee', '😮'],
|
||||
['4:41', 'throwaway_tom', 'tv is 40s behind this chat lmao'],
|
||||
['4:52', 'hellsk1tchen', 'two helis circling opposite sides'],
|
||||
['5:10', 'kvn_on_34th', 'THEY ARE CLIMBING. the mast', 'first-eyes'],
|
||||
['5:12', 'midtown_mara', 'NO'],
|
||||
['5:13', 'jules_flatiron', 'oh my god'],
|
||||
['5:15', 'deli_guy_dan', 'nope nope nope'],
|
||||
['5:17', 'ess_ay_dee', '😮😮😮'],
|
||||
['5:20', 'brklyn_bea', 'im shaking'],
|
||||
[
|
||||
'5:24',
|
||||
'lena.ktown',
|
||||
'on the roof, I can see them on the east side',
|
||||
'local',
|
||||
],
|
||||
['5:31', 'murrayhill_mo', 'how high is that'],
|
||||
['5:36', 'avgeek_jfk', 'mast starts ~1,250 ft. antenna tip 1,454', 'pilot'],
|
||||
['5:52', 'throwaway_tom', 'who does this'],
|
||||
[
|
||||
'6:02',
|
||||
'nyc_scanner_nerd',
|
||||
'third helo lifting from E 34th St heliport',
|
||||
'local',
|
||||
],
|
||||
[
|
||||
'6:10',
|
||||
'penn_plaza_40th',
|
||||
'from 1 Penn 40th fl. sealed. they are halfway up the mast',
|
||||
'witness',
|
||||
],
|
||||
['6:14', 'deli_guy_dan', 'thats so close'],
|
||||
['6:25', 'midtown_mara', 'is that a drone?'],
|
||||
['6:40', 'hellsk1tchen', 'drone over by the river too'],
|
||||
[
|
||||
'6:58',
|
||||
'avgeek_jfk',
|
||||
'not a drone, thats SKY-4 coming off the E 34th pad',
|
||||
'pilot',
|
||||
],
|
||||
['7:20', 'brklyn_bea', 'three helicopters now'],
|
||||
['7:26', 'ess_ay_dee', '🔥🔥🔥'],
|
||||
[
|
||||
'7:41',
|
||||
'metwx_paul',
|
||||
'winds ~15 kt from WNW at 1,000+ ft. thats why the orbits look stretched east',
|
||||
'met',
|
||||
],
|
||||
['7:58', 'jules_flatiron', 'the gusts up there must be brutal'],
|
||||
['8:12', 'murrayhill_mo', 'they stopped'],
|
||||
['8:30', 'kvn_on_34th', 'one is waving??', 'first-eyes'],
|
||||
[
|
||||
'8:40',
|
||||
'hellsk1tchen',
|
||||
'sealed from the high line, all three helis in frame',
|
||||
'witness',
|
||||
],
|
||||
['8:51', 'deli_guy_dan', 'crowd on 5th is massive now'],
|
||||
['9:05', 'brklyn_bea', 'please come down'],
|
||||
['9:22', 'lena.ktown', 'they are going higher', 'witness'],
|
||||
['9:34', 'throwaway_tom', 'HOW'],
|
||||
['9:40', 'kvn_on_34th', 'ONE IS AT THE TOP OF THE MAST', 'first-eyes'],
|
||||
['9:41', 'midtown_mara', 'NOOOO'],
|
||||
['9:42', 'ess_ay_dee', '😮😮😮😮'],
|
||||
['9:43', 'jules_flatiron', 'oh my god oh my god'],
|
||||
['9:44', 'deli_guy_dan', 'cant watch'],
|
||||
['9:46', 'murrayhill_mo', 'insane'],
|
||||
['9:48', 'brklyn_bea', '😭'],
|
||||
['9:51', 'hellsk1tchen', 'the whole avenue just gasped'],
|
||||
['9:55', 'penn_plaza_40th', 'whole office is at the window', 'witness'],
|
||||
[
|
||||
'10:08',
|
||||
'heli_dispatch_ret',
|
||||
'three ships stacked 1,000/1,250/1,450, all left orbits. textbook',
|
||||
'pilot',
|
||||
],
|
||||
['10:22', 'throwaway_tom', 'helmet cam. this is for a video 100%'],
|
||||
[
|
||||
'10:40',
|
||||
'avgeek_jfk',
|
||||
'NEWS-7 widening out, prob for the wide shot',
|
||||
'pilot',
|
||||
],
|
||||
['11:05', 'midtown_mara', 'is the deck closed?'],
|
||||
[
|
||||
'11:20',
|
||||
'nyc_scanner_nerd',
|
||||
'scanner says deck cleared, ESU going up',
|
||||
'local',
|
||||
],
|
||||
['11:42', 'brklyn_bea', 'come on come on'],
|
||||
['12:05', 'murrayhill_mo', 'whats ESU'],
|
||||
[
|
||||
'12:11',
|
||||
'nyc_scanner_nerd',
|
||||
'emergency service unit. rescue specialists',
|
||||
'local',
|
||||
],
|
||||
[
|
||||
'12:20',
|
||||
'lena.ktown',
|
||||
'sealed: officers on the 102 deck right under them',
|
||||
'witness',
|
||||
],
|
||||
['12:24', 'ess_ay_dee', '🙏'],
|
||||
['12:35', 'deli_guy_dan', 'they are talking to them i think'],
|
||||
['13:02', 'jules_flatiron', 'PD-14 dropping lower'],
|
||||
[
|
||||
'13:40',
|
||||
'heli_dispatch_ret',
|
||||
'police ship tightening the orbit, stepping down to ~1,000',
|
||||
'pilot',
|
||||
],
|
||||
['14:10', 'throwaway_tom', 'third climber on the setback??'],
|
||||
['14:14', 'midtown_mara', 'wait three??'],
|
||||
['14:20', 'brklyn_bea', 'I see a third one'],
|
||||
[
|
||||
'14:48',
|
||||
'jules_flatiron',
|
||||
'thats the mast shadow lol. compare the 5th ave and high line shots',
|
||||
'witness',
|
||||
],
|
||||
['15:02', 'hellsk1tchen', 'yeah shadow, sun is behind us', 'witness'],
|
||||
[
|
||||
'15:30',
|
||||
'midtown_mara',
|
||||
'sealed from 38th, the crowd is all phones',
|
||||
'witness',
|
||||
],
|
||||
['15:51', 'deli_guy_dan', 'sold out of water lol'],
|
||||
['16:20', 'murrayhill_mo', 'how long has it been'],
|
||||
['16:24', 'kvn_on_34th', '16 min since I saw them', 'first-eyes'],
|
||||
['16:50', 'ess_ay_dee', '❤️❤️'],
|
||||
['17:15', 'brklyn_bea', 'they are moving'],
|
||||
['17:30', 'lena.ktown', 'coming DOWN', 'witness'],
|
||||
['17:32', 'midtown_mara', 'YES'],
|
||||
['17:34', 'deli_guy_dan', 'thank god'],
|
||||
['17:36', 'ess_ay_dee', '❤️❤️❤️'],
|
||||
['17:55', 'throwaway_tom', 'slowly. slowly'],
|
||||
['18:30', 'jules_flatiron', 'first one at the rail'],
|
||||
['18:58', 'penn_plaza_40th', 'officers reaching over the rail', 'witness'],
|
||||
['19:20', 'kvn_on_34th', 'ONE IS IN', 'first-eyes'],
|
||||
['19:22', 'brklyn_bea', '😭😭'],
|
||||
['19:24', 'midtown_mara', 'ok im crying'],
|
||||
['19:40', 'lena.ktown', 'SECOND ONE IN', 'witness'],
|
||||
['19:42', 'deli_guy_dan', 'LETS GOOO'],
|
||||
['19:43', 'ess_ay_dee', '❤️🔥❤️🔥'],
|
||||
['19:44', 'murrayhill_mo', 'the street is clapping'],
|
||||
[
|
||||
'19:45',
|
||||
'rooftop_rafa',
|
||||
'sealed from murray hill, both over the rail',
|
||||
'witness',
|
||||
],
|
||||
['19:50', 'hellsk1tchen', 'cheering from the high line too'],
|
||||
['20:10', 'throwaway_tom', 'they are so arrested'],
|
||||
[
|
||||
'20:40',
|
||||
'nyc_scanner_nerd',
|
||||
'scanner: two in custody, no injuries reported',
|
||||
'local',
|
||||
],
|
||||
['21:00', 'avgeek_jfk', 'SKY-4 departing east, next story', 'pilot'],
|
||||
['21:10', 'deli_guy_dan', 'sealed one of SKY-4 leaving', 'witness'],
|
||||
['21:35', 'jules_flatiron', 'what a day for midtown'],
|
||||
['22:10', 'midtown_mara', 'the replay of this is gonna be wild'],
|
||||
['22:40', 'brklyn_bea', 'thank you everyone for the updates'],
|
||||
[
|
||||
'23:15',
|
||||
'heli_dispatch_ret',
|
||||
'NEWS-7 and PD-14 still up. will hold till the street clears',
|
||||
'pilot',
|
||||
],
|
||||
['24:05', 'murrayhill_mo', 'traffic on 5th still stopped'],
|
||||
[
|
||||
'24:40',
|
||||
'kvn_on_34th',
|
||||
'cant believe I just looked up at the right time',
|
||||
'first-eyes',
|
||||
],
|
||||
];
|
||||
|
||||
/* ── Record lane: posts ─────────────────────────────────────────── */
|
||||
const POSTS = [
|
||||
[
|
||||
'0:58',
|
||||
'kvn_on_34th',
|
||||
'witness',
|
||||
'Two people outside the safety fence on the 86th floor setback, north face. Sealed photo from W 34th St & 6th Ave, 22° zoom.',
|
||||
412,
|
||||
],
|
||||
[
|
||||
'2:40',
|
||||
'midtown_mara',
|
||||
'question',
|
||||
'Is the observation deck being cleared? Is anyone still up there?',
|
||||
88,
|
||||
],
|
||||
[
|
||||
'3:30',
|
||||
'nyc_scanner_nerd',
|
||||
'context',
|
||||
'Police aviation is on scene, orbiting at about 1,100 ft. News helicopters usually follow within minutes.',
|
||||
230,
|
||||
],
|
||||
[
|
||||
'6:15',
|
||||
'penn_plaza_40th',
|
||||
'witness',
|
||||
'From 1 Penn Plaza, 40th floor: both are on the mooring mast, east side, about halfway up. Sealed clip.',
|
||||
377,
|
||||
],
|
||||
[
|
||||
'7:05',
|
||||
'avgeek_jfk',
|
||||
'correction',
|
||||
'The "drone" in clips around 6:30 is SKY-4 lifting out of the East 34th St heliport. Its track matches.',
|
||||
156,
|
||||
],
|
||||
[
|
||||
'7:50',
|
||||
'metwx_paul',
|
||||
'context',
|
||||
'Wind at 1,000–1,500 ft is about 15 kt from the WNW. That is why every orbit looks stretched toward the East River.',
|
||||
141,
|
||||
],
|
||||
[
|
||||
'10:15',
|
||||
'heli_dispatch_ret',
|
||||
'context',
|
||||
'Three helicopters holding 1,000, 1,250 and 1,450 ft in left-hand orbits. Staggered altitudes are normal deconfliction.',
|
||||
264,
|
||||
],
|
||||
[
|
||||
'12:25',
|
||||
'lena.ktown',
|
||||
'witness',
|
||||
'Rooftop in Koreatown: officers visible on the 102nd floor deck directly below the climbers. Sealed.',
|
||||
198,
|
||||
],
|
||||
[
|
||||
'14:55',
|
||||
'jules_flatiron',
|
||||
'correction',
|
||||
"There is no third climber. The figure on the setback is the mast's shadow: compare the 5th Ave and High Line captures, taken 6 minutes apart.",
|
||||
301,
|
||||
],
|
||||
['16:10', 'brklyn_bea', 'question', 'Has anyone been hurt?', 64],
|
||||
[
|
||||
'19:50',
|
||||
'rooftop_rafa',
|
||||
'witness',
|
||||
'Both are back over the rail at the 102nd floor and officers have them. Sealed clip from Murray Hill.',
|
||||
520,
|
||||
],
|
||||
[
|
||||
'20:45',
|
||||
'nyc_scanner_nerd',
|
||||
'context',
|
||||
'Scanner traffic: two in custody, no injuries reported. Unconfirmed until an official statement.',
|
||||
187,
|
||||
],
|
||||
];
|
||||
|
||||
/* ── Beats (for timeline pips) ──────────────────────────────────── */
|
||||
const BEATS = [
|
||||
['0:52', 'First sealed capture: two figures outside the 86th floor fence'],
|
||||
['2:10', 'Police helicopter on scene'],
|
||||
['4:30', 'NEWS-7 arrives'],
|
||||
['5:10', 'Climbers start up the mast'],
|
||||
['7:25', 'SKY-4 joins: three helicopters orbiting'],
|
||||
['9:40', 'One climber at the top of the mast'],
|
||||
['12:20', 'Officers on the 102nd floor deck'],
|
||||
['17:30', 'Climbers start down'],
|
||||
['19:40', 'Both back over the rail'],
|
||||
['21:00', 'SKY-4 departs'],
|
||||
];
|
||||
|
||||
/* ── Cinematic camera path, keyed to time ───────────────────────── */
|
||||
// Each key: bearing from the building, ground range, the camera's pitch
|
||||
// down to the look point, and the look point's height (default the deck).
|
||||
function camKey(
|
||||
mmss,
|
||||
bearingFromEsb,
|
||||
rangeM,
|
||||
pitchDownDeg,
|
||||
lookAltM = DECK_86,
|
||||
) {
|
||||
const heightM = Math.round(lookAltM + Math.tan(pitchDownDeg * D2R) * rangeM);
|
||||
const p = near(ESB, rangeM, bearingFromEsb);
|
||||
const [x, y] = toXY(p);
|
||||
const heading = bearingXY(-x, -y);
|
||||
const pitch = Math.atan2(lookAltM - heightM, Math.hypot(x, y)) / D2R;
|
||||
return {
|
||||
t: at(mmss),
|
||||
lon: Math.round(p.lon * 1e6) / 1e6,
|
||||
lat: Math.round(p.lat * 1e6) / 1e6,
|
||||
height: heightM,
|
||||
heading: Math.round(heading * 10) / 10,
|
||||
pitch: Math.round(pitch * 10) / 10,
|
||||
};
|
||||
}
|
||||
const cameraPath = [
|
||||
camKey('0:00', 212, 4200, 30, 150),
|
||||
camKey('1:00', 218, 3200, 30, 200),
|
||||
camKey('2:00', 228, 2300, 31, 260),
|
||||
camKey('3:30', 248, 1800, 32),
|
||||
camKey('5:00', 278, 1550, 31, ROOF),
|
||||
camKey('6:30', 308, 1450, 30, ROOF),
|
||||
camKey('8:00', 338, 1350, 29, ROOF),
|
||||
camKey('9:40', 6, 1150, 26, ROOF + 20),
|
||||
camKey('11:30', 38, 1350, 29, ROOF),
|
||||
camKey('13:30', 72, 1650, 32),
|
||||
camKey('15:30', 108, 1900, 34),
|
||||
camKey('17:30', 142, 1600, 31, ROOF),
|
||||
camKey('19:40', 172, 1300, 27, DECK_102),
|
||||
camKey('21:30', 198, 2000, 31),
|
||||
camKey('23:30', 216, 3000, 31, 220),
|
||||
camKey('25:00', 224, 4000, 30, 150),
|
||||
];
|
||||
|
||||
/* ── Assemble ───────────────────────────────────────────────────── */
|
||||
const jitter = (t, s) => t + Math.round(gauss() * s * 1000);
|
||||
const chat = CHAT.map(([mmss, handle, text, badge]) => ({
|
||||
t: jitter(at(mmss), 1.2),
|
||||
handle,
|
||||
text,
|
||||
...(badge ? { badge } : {}),
|
||||
})).sort((a, b) => a.t - b.t);
|
||||
const posts = POSTS.map(([mmss, handle, kind, text, votes], i) => ({
|
||||
id: `p${i + 1}`,
|
||||
t: at(mmss),
|
||||
handle,
|
||||
kind,
|
||||
text,
|
||||
votes,
|
||||
...(captures.find(
|
||||
(c) => c.handle === handle && Math.abs(c.t - at(mmss)) < 60_000,
|
||||
)
|
||||
? {
|
||||
captureId: captures.find(
|
||||
(c) => c.handle === handle && Math.abs(c.t - at(mmss)) < 60_000,
|
||||
).id,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const moment = {
|
||||
id: 'empire-state',
|
||||
title: 'Climbers on the Empire State Building',
|
||||
place: 'Midtown Manhattan, New York',
|
||||
lon: ESB.lon,
|
||||
lat: ESB.lat,
|
||||
time: { start: T0, end: T0 + DURATION_S * 1000 },
|
||||
illustrative: true,
|
||||
illustrativeNote:
|
||||
'Illustrative reconstruction. The event, aircraft, callsigns, people, chat and posts are fictional; the place, building heights and heliports are real.',
|
||||
recommendedSpeed: 30,
|
||||
groundMslM: GROUND_MSL,
|
||||
subject: {
|
||||
lon: ESB.lon,
|
||||
lat: ESB.lat,
|
||||
altM: DECK_86,
|
||||
label: 'Empire State Building',
|
||||
roofM: ROOF,
|
||||
antennaM: ANTENNA,
|
||||
decks: [DECK_86, DECK_102],
|
||||
},
|
||||
stage: {
|
||||
buildings: 'midtown-buildings.json',
|
||||
water: 'midtown-water.json',
|
||||
highlight: '1015862',
|
||||
},
|
||||
wind: WIND,
|
||||
cameraPath,
|
||||
beats: BEATS.map(([mmss, title]) => ({ t: at(mmss), title })),
|
||||
tracks,
|
||||
captures,
|
||||
chat,
|
||||
posts,
|
||||
sources: [
|
||||
{
|
||||
name: 'Illustrative reconstruction',
|
||||
detail:
|
||||
'Helicopter tracks are synthetic, from a flight model (constant airspeed, 14 kt wind, rate-limited turns) shaped like real news and police orbits. Callsigns and tail numbers are fictional and are not real registrations.',
|
||||
},
|
||||
{
|
||||
name: 'NYC Open Data · Building Footprints',
|
||||
detail:
|
||||
'Real footprints and roof heights for Midtown (NYC OTI, dataset 5zhs-2jue).',
|
||||
},
|
||||
{
|
||||
name: 'OpenStreetMap water polygons',
|
||||
detail: 'Hudson and East River surfaces (ODbL, via OpenFreeMap).',
|
||||
},
|
||||
{
|
||||
name: 'Esri World Imagery',
|
||||
detail: 'Base imagery of the real place.',
|
||||
},
|
||||
{
|
||||
name: 'Simulated Live and Record lanes',
|
||||
detail:
|
||||
'Chat, posts, handles and witness captures are simulated to show how a room forms around an event.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mkdir(path.join(HERE, 'moments'), { recursive: true });
|
||||
const file = path.join(HERE, 'moments', 'empire-state.json');
|
||||
await writeFile(file, await prettyJson(moment));
|
||||
console.log(
|
||||
`wrote ${file}: ${tracks.map((t) => `${t.id} ${t.points.length} pts`).join(', ')}, ` +
|
||||
`${captures.length} captures, ${chat.length} chat, ${posts.length} posts`,
|
||||
);
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds mobile/demo/midtown-water.json: the Hudson and East River around
|
||||
* the Empire State Moment, from OpenStreetMap water polygons (OpenFreeMap
|
||||
* vector tiles, OpenMapTiles schema, ODbL).
|
||||
*
|
||||
* Why: Esri World Imagery stitches two different water sources across the
|
||||
* Hudson, and from a low oblique camera the seam reads as a black-and-olive
|
||||
* staircase. The stage paints these real water polygons over the imagery so
|
||||
* the rivers read as one calm dark surface.
|
||||
*
|
||||
* node mobile/demo/build-midtown-water.mjs
|
||||
*
|
||||
* Output: { origin:[lon,lat], source, fetchedAt, polys:[[[x0,y0,…], hole…]] }
|
||||
* with integer metres east/north of `origin`.
|
||||
*/
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(path.join(HERE, '..', '..', 'package.json'));
|
||||
const vt = await import(require.resolve('@mapbox/vector-tile'));
|
||||
const VectorTile = vt.VectorTile || vt.default?.VectorTile;
|
||||
const pbfMod = await import(require.resolve('pbf'));
|
||||
const Pbf = pbfMod.default?.default || pbfMod.default || pbfMod.PbfReader;
|
||||
|
||||
const ORIGIN = [-73.9857, 40.7484];
|
||||
const BBOX = { west: -74.08, east: -73.88, south: 40.68, north: 40.82 };
|
||||
const Z = 13;
|
||||
const kx = 111_320 * Math.cos((ORIGIN[1] * Math.PI) / 180);
|
||||
const ky = 110_574;
|
||||
|
||||
async function prettyJson(value) {
|
||||
const text = JSON.stringify(value);
|
||||
try {
|
||||
const prettier = await import('prettier');
|
||||
return await prettier.format(text, { parser: 'json' });
|
||||
} catch {
|
||||
return `${text}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const tileX = (lon) => Math.floor(((lon + 180) / 360) * 2 ** Z);
|
||||
const tileY = (lat) => {
|
||||
const r = (lat * Math.PI) / 180;
|
||||
return Math.floor(
|
||||
((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * 2 ** Z,
|
||||
);
|
||||
};
|
||||
|
||||
const meta = await (await fetch('https://tiles.openfreemap.org/planet')).json();
|
||||
const template = meta.tiles[0];
|
||||
const polys = [];
|
||||
for (let x = tileX(BBOX.west); x <= tileX(BBOX.east); x++)
|
||||
for (let y = tileY(BBOX.north); y <= tileY(BBOX.south); y++) {
|
||||
const url = template.replace('{z}', Z).replace('{x}', x).replace('{y}', y);
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`tile ${x}/${y} HTTP ${r.status}`);
|
||||
const tile = new VectorTile(new Pbf(new Uint8Array(await r.arrayBuffer())));
|
||||
const water = tile.layers.water;
|
||||
if (!water) continue;
|
||||
for (let i = 0; i < water.length; i++) {
|
||||
const gj = water.feature(i).toGeoJSON(x, y, Z);
|
||||
const list =
|
||||
gj.geometry.type === 'Polygon'
|
||||
? [gj.geometry.coordinates]
|
||||
: gj.geometry.type === 'MultiPolygon'
|
||||
? gj.geometry.coordinates
|
||||
: [];
|
||||
for (const rings of list) {
|
||||
const out = rings.map((ring) => {
|
||||
const flat = [];
|
||||
let px = null;
|
||||
let py = null;
|
||||
for (const [lon, lat] of ring) {
|
||||
const mx = Math.round((lon - ORIGIN[0]) * kx);
|
||||
const my = Math.round((lat - ORIGIN[1]) * ky);
|
||||
if (px !== null && Math.abs(mx - px) < 8 && Math.abs(my - py) < 8)
|
||||
continue;
|
||||
flat.push(mx, my);
|
||||
px = mx;
|
||||
py = my;
|
||||
}
|
||||
return flat;
|
||||
});
|
||||
// Drop ponds: only rivers, bays and harbour (big outer rings).
|
||||
const o = out[0];
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (let k = 0; k < o.length; k += 2) {
|
||||
minX = Math.min(minX, o[k]);
|
||||
maxX = Math.max(maxX, o[k]);
|
||||
minY = Math.min(minY, o[k + 1]);
|
||||
maxY = Math.max(maxY, o[k + 1]);
|
||||
}
|
||||
if (o.length >= 6 && maxX - minX + (maxY - minY) > 600)
|
||||
polys.push(out.filter((ring) => ring.length >= 6));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const doc = {
|
||||
origin: ORIGIN,
|
||||
source:
|
||||
'OpenStreetMap contributors (ODbL) via OpenFreeMap vector tiles, water layer',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
count: polys.length,
|
||||
polys,
|
||||
};
|
||||
const file = path.join(HERE, 'midtown-water.json');
|
||||
await writeFile(file, await prettyJson(doc));
|
||||
console.log(`wrote ${polys.length} water polygons → ${file}`);
|
||||
@@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds mobile/demo/midtown-buildings.json: real building massing for the
|
||||
* Empire State Moment, from NYC Open Data "Building Footprints" (DoITT,
|
||||
* dataset 5zhs-2jue, roof heights from photogrammetry). Every building
|
||||
* within 1.6 km of the Empire State Building whose roof is over 60 ft, and
|
||||
* towers over 200 ft out to 2.6 km (so the massing thins out instead of
|
||||
* ending at a hard circle).
|
||||
*
|
||||
* node mobile/demo/build-midtown.mjs [path/to/cached.json]
|
||||
*
|
||||
* Output: { origin:[lon,lat], source, fetchedAt, b:[[roofM, groundM, x0,y0,x1,y1,…]] }
|
||||
* where x/y are integer metres east/north of `origin` (outer ring only,
|
||||
* Douglas-Peucker simplified to 1 m).
|
||||
*/
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
/** JSON as the repo's Prettier would write it (so `prettier --check` passes). */
|
||||
async function prettyJson(value) {
|
||||
const text = JSON.stringify(value);
|
||||
try {
|
||||
const prettier = await import('prettier');
|
||||
return await prettier.format(text, { parser: 'json' });
|
||||
} catch {
|
||||
return `${text}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ORIGIN = [-73.9857, 40.7484];
|
||||
const FT = 0.3048;
|
||||
|
||||
async function load() {
|
||||
const cached = process.argv[2];
|
||||
if (cached) return JSON.parse(await readFile(cached, 'utf8'));
|
||||
const url = new URL('https://data.cityofnewyork.us/resource/5zhs-2jue.json');
|
||||
url.searchParams.set(
|
||||
'$select',
|
||||
'the_geom,height_roof,ground_elevation,bin,construction_year',
|
||||
);
|
||||
url.searchParams.set(
|
||||
'$where',
|
||||
`within_circle(the_geom,${ORIGIN[1]},${ORIGIN[0]},2600) AND height_roof > 60`,
|
||||
);
|
||||
url.searchParams.set('$limit', '20000');
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`NYC Open Data HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
const kx = 111_320 * Math.cos((ORIGIN[1] * Math.PI) / 180);
|
||||
const ky = 110_574;
|
||||
const toXY = ([lon, lat]) => [(lon - ORIGIN[0]) * kx, (lat - ORIGIN[1]) * ky];
|
||||
|
||||
function simplify(pts, tol) {
|
||||
if (pts.length < 4) return pts;
|
||||
const keep = new Uint8Array(pts.length);
|
||||
keep[0] = keep[pts.length - 1] = 1;
|
||||
const stack = [[0, pts.length - 1]];
|
||||
while (stack.length) {
|
||||
const [a, b] = stack.pop();
|
||||
const [ax, ay] = pts[a];
|
||||
const [bx, by] = pts[b];
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len = Math.hypot(dx, dy) || 1e-9;
|
||||
let best = -1;
|
||||
let bestD = tol;
|
||||
for (let i = a + 1; i < b; i++) {
|
||||
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
|
||||
if (d > bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if (best > 0) {
|
||||
keep[best] = 1;
|
||||
stack.push([a, best], [best, b]);
|
||||
}
|
||||
}
|
||||
return pts.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
const rows = await load();
|
||||
const out = [];
|
||||
for (const row of rows) {
|
||||
const roof = Number(row.height_roof) * FT;
|
||||
if (!Number.isFinite(roof) || roof < 18) continue;
|
||||
const ground = Number(row.ground_elevation) * FT;
|
||||
const polys = row.the_geom?.coordinates || [];
|
||||
// The largest outer ring stands for the building.
|
||||
let ring = null;
|
||||
for (const poly of polys) {
|
||||
const r = poly[0];
|
||||
if (r && (!ring || r.length > ring.length)) ring = r;
|
||||
}
|
||||
if (!ring || ring.length < 4) continue;
|
||||
let pts = ring.map(toXY);
|
||||
const [fx, fy] = pts[0];
|
||||
const dist = Math.hypot(fx, fy);
|
||||
if (dist > 2600) continue;
|
||||
// Beyond 1.6 km only towers, thinning out with distance.
|
||||
if (dist > 1600 && roof < 61 + ((dist - 1600) / 1000) * 60) continue;
|
||||
// Open ring: split at the farthest vertex so both halves simplify cleanly.
|
||||
pts = pts.slice(0, -1);
|
||||
let far = 1;
|
||||
for (let i = 1; i < pts.length; i++)
|
||||
if (
|
||||
Math.hypot(pts[i][0] - pts[0][0], pts[i][1] - pts[0][1]) >
|
||||
Math.hypot(pts[far][0] - pts[0][0], pts[far][1] - pts[0][1])
|
||||
)
|
||||
far = i;
|
||||
pts = [
|
||||
...simplify(pts.slice(0, far + 1), 1.0),
|
||||
...simplify([...pts.slice(far), pts[0]], 1.0).slice(1, -1),
|
||||
];
|
||||
if (pts.length < 3) continue;
|
||||
const flat = [];
|
||||
for (const [x, y] of pts) flat.push(Math.round(x), Math.round(y));
|
||||
out.push([
|
||||
Math.round(roof * 10) / 10,
|
||||
Number.isFinite(ground) ? Math.round(ground * 10) / 10 : 15,
|
||||
...flat,
|
||||
]);
|
||||
}
|
||||
out.sort((a, b) => a[0] - b[0]);
|
||||
const doc = {
|
||||
origin: ORIGIN,
|
||||
source:
|
||||
'NYC Open Data · Building Footprints (NYC OTI/DoITT, 5zhs-2jue), roof heights from photogrammetry',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
count: out.length,
|
||||
b: out,
|
||||
};
|
||||
const file = path.join(HERE, 'midtown-buildings.json');
|
||||
await writeFile(file, await prettyJson(doc));
|
||||
console.log(`wrote ${out.length} buildings → ${file}`);
|
||||
@@ -1,388 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Captures a bundled flight-history recording for the time machine
|
||||
* (DESIGN.md §12.5). For N minutes it polls the real feeds the engine uses
|
||||
* (adsb.lol point snapshots around each region's anchors, the dev server's
|
||||
* /api/flights as a fallback and its /api/military), keeps the busiest
|
||||
* regions, and writes one file per region to mobile/demo/history/<region>.json.
|
||||
*
|
||||
* npx vite --port 4191 --strictPort # a dev server, for the fallbacks
|
||||
* node mobile/demo/capture-history.mjs [--base http://localhost:4191]
|
||||
* [--minutes 150] [--poll 20] [--budget 4000000] [--out dir]
|
||||
* (behind an HTTPS proxy: NODE_USE_ENV_PROXY=1 node …)
|
||||
*
|
||||
* The engine's normalisation (flightFromState, classifyAircraft) is used, and the
|
||||
* same sampling as the recorder's persisted history: one fix per 30 s for
|
||||
* low traffic, one per 120 s for cruise. The file keeps the real capture
|
||||
* timestamps; the engine never shifts them.
|
||||
*
|
||||
* File format (quantised, delta-encoded):
|
||||
* { format: 'gev-history/1', region, label, bbox, capturedAt: { start, end },
|
||||
* source, t0 (epoch s), tracks: [{ k, m, p }] }
|
||||
* k: recorder key (icao24 / hex) m: recorder meta (callsign, type…)
|
||||
* p: comma-separated ints, 6 per sample, deltas from the previous sample:
|
||||
* dt (s) · dlon (1e-4°) · dlat (1e-4°) · dalt (10 m) · heading (°, absolute)
|
||||
* · speed (m/s, absolute)
|
||||
* Written every 5 minutes, so an interrupted run still leaves a usable file.
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { flightFromState } from '../globe/feeds.js';
|
||||
import { classifyAircraft } from '../../src/data/aircraftClass.js';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const args = Object.fromEntries(
|
||||
process.argv
|
||||
.slice(2)
|
||||
.join(' ')
|
||||
.split('--')
|
||||
.filter(Boolean)
|
||||
.map((kv) => {
|
||||
const [k, ...v] = kv.trim().split(/\s+/);
|
||||
return [k, v.join(' ') || true];
|
||||
}),
|
||||
);
|
||||
const BASE = args.base || 'http://localhost:4191';
|
||||
const MINUTES = Number(args.minutes || 150);
|
||||
const POLL_S = Number(args.poll || 20);
|
||||
const BUDGET = Number(args.budget || 4_000_000);
|
||||
const OUT = args.out ? path.resolve(args.out) : path.join(HERE, 'history');
|
||||
|
||||
const REGIONS = [
|
||||
{
|
||||
id: 'nyc',
|
||||
label: 'New York area',
|
||||
bbox: { west: -76.5, south: 39.4, east: -71.2, north: 42.2 },
|
||||
anchors: [{ lat: 40.75, lon: -73.98 }],
|
||||
},
|
||||
{
|
||||
id: 'london',
|
||||
label: 'London and the North Sea approaches',
|
||||
bbox: { west: -2.6, south: 50.2, east: 5.6, north: 53.6 },
|
||||
anchors: [
|
||||
{ lat: 51.51, lon: -0.12 },
|
||||
{ lat: 52.2, lon: 4.2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gulf',
|
||||
label: 'Gulf of Mexico',
|
||||
bbox: { west: -98.5, south: 23.5, east: -81.5, north: 31.2 },
|
||||
anchors: [
|
||||
{ lat: 27.5, lon: -90 },
|
||||
{ lat: 29.6, lon: -95.2 },
|
||||
{ lat: 27.9, lon: -83.5 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const CRUISE_ALT_M = 6000;
|
||||
const LOW_GAP_S = 30;
|
||||
const CRUISE_GAP_S = 120;
|
||||
|
||||
const inBox = (b, lon, lat) =>
|
||||
lat >= b.south && lat <= b.north && lon >= b.west && lon <= b.east;
|
||||
|
||||
/** region id → Map(key → { meta, pts: [[t, lon, lat, alt, hdg, spd]] }) */
|
||||
const store = new Map(REGIONS.map((r) => [r.id, new Map()]));
|
||||
let firstAt = Infinity;
|
||||
let lastAt = -Infinity;
|
||||
const sources = new Set();
|
||||
|
||||
function add(region, key, meta, s) {
|
||||
const tracks = store.get(region.id);
|
||||
let tr = tracks.get(key);
|
||||
if (!tr) {
|
||||
tr = { meta, pts: [] };
|
||||
tracks.set(key, tr);
|
||||
} else tr.meta = { ...tr.meta, ...meta };
|
||||
const last = tr.pts[tr.pts.length - 1];
|
||||
const gap = s[3] > CRUISE_ALT_M ? CRUISE_GAP_S : LOW_GAP_S;
|
||||
if (last && s[0] - last[0] < gap) return;
|
||||
tr.pts.push(s);
|
||||
if (s[0] < firstAt) firstAt = s[0];
|
||||
if (s[0] > lastAt) lastAt = s[0];
|
||||
}
|
||||
|
||||
function ingest(records) {
|
||||
const nowS = Date.now() / 1000;
|
||||
for (const r of records) {
|
||||
if (!Number.isFinite(r.lon) || !Number.isFinite(r.lat)) continue;
|
||||
if (r.onGround && (r.speed ?? 0) < 8) continue;
|
||||
const t = Math.floor(
|
||||
Math.min(r.seenAt ?? Date.now(), Date.now() + 5000) / 1000,
|
||||
);
|
||||
if (nowS - t > 15 * 60) continue;
|
||||
const region = REGIONS.find((g) => inBox(g.bbox, r.lon, r.lat));
|
||||
if (!region) continue;
|
||||
const meta = {
|
||||
kind: r.military ? 'military' : r.helicopter ? 'helicopter' : 'aircraft',
|
||||
layer: r.military ? 'military' : 'flights',
|
||||
callsign: r.callsign || undefined,
|
||||
...(r.registration ? { registration: r.registration } : {}),
|
||||
...(r.type ? { type: r.type } : {}),
|
||||
...(r.country ? { country: r.country } : {}),
|
||||
...(r.squawk ? { squawk: r.squawk } : {}),
|
||||
...(r.helicopter ? { helicopter: true } : {}),
|
||||
...(r.military ? { military: true } : {}),
|
||||
...(r.ga ? { ga: true } : {}),
|
||||
...(r.pia ? { pia: true } : {}),
|
||||
...(r.ladd ? { ladd: true } : {}),
|
||||
};
|
||||
add(region, String(r.id), meta, [
|
||||
t,
|
||||
r.lon,
|
||||
r.lat,
|
||||
Math.round(r.altM ?? 0),
|
||||
Math.round(r.heading ?? 0),
|
||||
Math.round(r.speed ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function getJson(url, headers = {}) {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), 30_000);
|
||||
try {
|
||||
const res = await fetch(url, { signal: ctl.signal, headers });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return { json: await res.json(), headers: res.headers };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const ADSBLOL = 'https://api.adsb.lol/v2';
|
||||
const UA = { 'User-Agent': 'gods-eye-view-history-capture/1.0' };
|
||||
|
||||
/** adsb.lol v2 aircraft → the engine's flight record shape. */
|
||||
function fromAdsbLol(a, nowMs) {
|
||||
if (!Number.isFinite(a.lon) || !Number.isFinite(a.lat)) return null;
|
||||
const onGround = a.alt_baro === 'ground';
|
||||
const altFt = Number.isFinite(a.alt_geom) ? a.alt_geom : a.alt_baro;
|
||||
const cls = classifyAircraft({ typeCode: a.t, category: a.category });
|
||||
const military = Boolean(a.dbFlags & 1);
|
||||
return {
|
||||
id: String(a.hex).replace(/^~/, ''),
|
||||
callsign: String(a.flight || a.r || a.hex).trim(),
|
||||
registration: a.r || '',
|
||||
type: a.t || '',
|
||||
lon: a.lon,
|
||||
lat: a.lat,
|
||||
altM: onGround
|
||||
? 0
|
||||
: Math.max(0, (Number.isFinite(altFt) ? altFt : 15000) * 0.3048),
|
||||
onGround,
|
||||
speed: Number.isFinite(a.gs) ? a.gs * 0.514444 : 0,
|
||||
heading: a.track ?? a.true_heading ?? 0,
|
||||
squawk: a.squawk || null,
|
||||
helicopter: cls === 'helicopter',
|
||||
military,
|
||||
pia: Boolean(a.dbFlags & 4),
|
||||
ladd: Boolean(a.dbFlags & 8),
|
||||
seenAt: nowMs - (a.seen_pos ?? a.seen ?? 0) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Each region's anchors straight from adsb.lol (250 nm point queries, the
|
||||
* same source the dev server falls back to); an anchor that fails is asked
|
||||
* through the dev server instead.
|
||||
*/
|
||||
async function pollFlights() {
|
||||
let n = 0;
|
||||
for (const g of REGIONS) {
|
||||
for (const a of g.anchors) {
|
||||
// Polite spacing: adsb.lol answers 429 to bursts.
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
try {
|
||||
const { json } = await getJson(
|
||||
`${ADSBLOL}/lat/${a.lat}/lon/${a.lon}/dist/250`,
|
||||
UA,
|
||||
);
|
||||
const now = Number.isFinite(json.now) ? json.now : Date.now();
|
||||
const recs = (json.ac || [])
|
||||
.map((x) => fromAdsbLol(x, now))
|
||||
.filter(Boolean);
|
||||
ingest(recs);
|
||||
n += recs.length;
|
||||
sources.add('adsb.lol (ODbL)');
|
||||
continue;
|
||||
} catch (error) {
|
||||
console.warn(`[adsb.lol ${g.id}]`, error.message);
|
||||
}
|
||||
try {
|
||||
const { json } = await getJson(
|
||||
`${BASE}/api/flights?lat=${a.lat}&lon=${a.lon}`,
|
||||
);
|
||||
const recs = (json.states || []).map(flightFromState).filter(Boolean);
|
||||
ingest(recs);
|
||||
n += recs.length;
|
||||
sources.add('God’s Eye dev server (OpenSky / adsb.lol)');
|
||||
} catch (error) {
|
||||
console.warn(`[flights ${g.id}]`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function pollMilitary() {
|
||||
try {
|
||||
const { json } = await getJson(`${BASE}/api/military`);
|
||||
const recs = [];
|
||||
for (const a of json.ac || []) {
|
||||
if (!Number.isFinite(a.lon) || !Number.isFinite(a.lat)) continue;
|
||||
const altFt = Number.isFinite(a.alt_geom) ? a.alt_geom : a.alt_baro;
|
||||
recs.push({
|
||||
id: String(a.hex),
|
||||
callsign: String(a.flight || a.r || a.hex).trim(),
|
||||
registration: a.r || '',
|
||||
type: a.t || '',
|
||||
lon: a.lon,
|
||||
lat: a.lat,
|
||||
altM:
|
||||
a.alt_baro === 'ground' ? 0 : Math.max(0, (altFt ?? 15000) * 0.3048),
|
||||
onGround: a.alt_baro === 'ground',
|
||||
speed: Number.isFinite(a.gs) ? a.gs * 0.514444 : 0,
|
||||
heading: a.track ?? a.true_heading ?? 0,
|
||||
squawk: a.squawk || null,
|
||||
military: true,
|
||||
pia: Boolean(a.dbFlags & 4),
|
||||
ladd: Boolean(a.dbFlags & 8),
|
||||
seenAt: Date.now() - (a.seen_pos ?? a.seen ?? 0) * 1000,
|
||||
});
|
||||
}
|
||||
ingest(recs);
|
||||
sources.add('adsb.lol military (ODbL)');
|
||||
return recs.length;
|
||||
} catch (error) {
|
||||
console.warn('[military]', error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function encode(region, cruiseGap) {
|
||||
const tracks = [];
|
||||
const t0 = Number.isFinite(firstAt) ? firstAt : Math.floor(Date.now() / 1000);
|
||||
for (const [k, tr] of store.get(region.id)) {
|
||||
if (tr.pts.length < 2) continue;
|
||||
const p = [];
|
||||
let prev = [t0, 0, 0, 0];
|
||||
let keptT = -Infinity;
|
||||
tr.pts.forEach((s, i) => {
|
||||
const isLast = i === tr.pts.length - 1;
|
||||
if (
|
||||
cruiseGap > CRUISE_GAP_S &&
|
||||
s[3] > CRUISE_ALT_M &&
|
||||
!isLast &&
|
||||
s[0] - keptT < cruiseGap
|
||||
)
|
||||
return;
|
||||
keptT = s[0];
|
||||
const lon = Math.round(s[1] * 1e4);
|
||||
const lat = Math.round(s[2] * 1e4);
|
||||
const alt = Math.round(s[3] / 10);
|
||||
p.push(
|
||||
s[0] - prev[0],
|
||||
lon - prev[1],
|
||||
lat - prev[2],
|
||||
alt - prev[3],
|
||||
s[4],
|
||||
s[5],
|
||||
);
|
||||
prev = [s[0], lon, lat, alt];
|
||||
});
|
||||
// A string, so the file stays compact under Prettier.
|
||||
tracks.push({ k, m: tr.meta, p: p.join(',') });
|
||||
}
|
||||
return {
|
||||
format: 'gev-history/1',
|
||||
region: region.id,
|
||||
label: region.label,
|
||||
bbox: region.bbox,
|
||||
capturedAt: {
|
||||
start: new Date(firstAt * 1000).toISOString(),
|
||||
end: new Date(lastAt * 1000).toISOString(),
|
||||
},
|
||||
source: [...sources].join(' · '),
|
||||
note: 'Real recorded positions, captured through the God’s Eye dev server. Timestamps are the true capture times and are never shifted.',
|
||||
t0,
|
||||
tracks,
|
||||
};
|
||||
}
|
||||
|
||||
/** Prettier-clean JSON (the repo checks mobile/ with Prettier). */
|
||||
async function pretty(text) {
|
||||
try {
|
||||
const prettier = await import('prettier');
|
||||
return await prettier.format(text, { parser: 'json' });
|
||||
} catch {
|
||||
return text + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await mkdir(OUT, { recursive: true });
|
||||
let gap = CRUISE_GAP_S;
|
||||
for (;;) {
|
||||
const files = REGIONS.map((g) => [g, JSON.stringify(encode(g, gap))]);
|
||||
const bytes = files.reduce((n, [, s]) => n + s.length, 0);
|
||||
if (bytes > BUDGET && gap < 300) {
|
||||
gap += 60;
|
||||
continue;
|
||||
}
|
||||
for (const [g, text] of files)
|
||||
await writeFile(path.join(OUT, `${g.id}.json`), await pretty(text));
|
||||
const index = {
|
||||
format: 'gev-history-index/1',
|
||||
capturedAt: {
|
||||
start: new Date(firstAt * 1000).toISOString(),
|
||||
end: new Date(lastAt * 1000).toISOString(),
|
||||
},
|
||||
regions: REGIONS.map((g, i) => ({
|
||||
id: g.id,
|
||||
label: g.label,
|
||||
bbox: g.bbox,
|
||||
file: `${g.id}.json`,
|
||||
tracks: JSON.parse(files[i][1]).tracks.length,
|
||||
})),
|
||||
};
|
||||
await writeFile(
|
||||
path.join(OUT, 'index.json'),
|
||||
await pretty(JSON.stringify(index)),
|
||||
);
|
||||
console.log(
|
||||
`[save] ${new Date().toISOString()} span ${Math.round((lastAt - firstAt) / 60)} min, ${Math.round(bytes / 1024)} KB (cruise gap ${gap}s)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const until = Date.now() + MINUTES * 60_000;
|
||||
let polls = 0;
|
||||
let lastSave = Date.now();
|
||||
let lastMil = 0;
|
||||
console.log(`capturing ${MINUTES} min from ${BASE} → ${OUT}`);
|
||||
while (Date.now() < until) {
|
||||
const started = Date.now();
|
||||
const n = await pollFlights();
|
||||
if (Date.now() - lastMil > 40_000) {
|
||||
lastMil = Date.now();
|
||||
await pollMilitary();
|
||||
}
|
||||
polls++;
|
||||
if (polls % 15 === 1)
|
||||
console.log(
|
||||
`[poll ${polls}] ${n} aircraft; tracks ${REGIONS.map((g) => `${g.id}:${store.get(g.id).size}`).join(' ')}`,
|
||||
);
|
||||
if (Date.now() - lastSave > 5 * 60_000) {
|
||||
lastSave = Date.now();
|
||||
await save().catch((e) => console.warn('[save]', e.message));
|
||||
}
|
||||
const wait = POLL_S * 1000 - (Date.now() - started);
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
await save();
|
||||
console.log('done');
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regenerates the bundled first-frame snapshot (DESIGN.md §12.8):
|
||||
*
|
||||
* mobile/demo/snapshot.json the live situations() result, with capturedAt
|
||||
* mobile/demo/thumbs/<id>.jpg each card's composited thumbnail
|
||||
*
|
||||
* It drives the real engine in headless Chromium against a running dev
|
||||
* server, so every card is real data at capture time.
|
||||
*
|
||||
* npm run dev # or: npx vite --port 4173
|
||||
* node mobile/demo/capture-snapshot.mjs [--base http://localhost:4173]
|
||||
* [--chrome /path/to/chrome] [--max 28] [--quality 0.8]
|
||||
* [--lat 40.75 --lon -73.98] # a viewer position for distances
|
||||
*
|
||||
* Budget: thumbnails are 360×480 CSS px at 1.25× and JPEG-recompressed so
|
||||
* the whole folder stays under ~3 MB.
|
||||
*/
|
||||
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(path.join(HERE, '..', '..', 'package.json'));
|
||||
const args = Object.fromEntries(
|
||||
process.argv
|
||||
.slice(2)
|
||||
.join(' ')
|
||||
.split('--')
|
||||
.filter(Boolean)
|
||||
.map((kv) => {
|
||||
const [k, ...v] = kv.trim().split(/\s+/);
|
||||
return [k, v.join(' ') || true];
|
||||
}),
|
||||
);
|
||||
const BASE = args.base || 'http://localhost:4173';
|
||||
const MAX = Number(args.max || 28);
|
||||
const QUALITY = Number(args.quality || 0.8);
|
||||
const BUDGET = 3 * 1024 * 1024;
|
||||
|
||||
const puppeteer = (await import(require.resolve('puppeteer'))).default;
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: args.chrome || process.env.CHROME_PATH || undefined,
|
||||
args: [
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--ignore-gpu-blocklist',
|
||||
'--no-sandbox',
|
||||
],
|
||||
protocolTimeout: 600_000,
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 430, height: 932, deviceScaleFactor: 1 });
|
||||
page.on('pageerror', (e) => console.warn('[page]', e.message));
|
||||
await page.goto(`${BASE}/mobile/globe/dev.html?hud=0&labels=0`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.waitForFunction('window.globe', { timeout: 120_000 });
|
||||
console.log('engine up; building live situations (this takes ~30–60 s)…');
|
||||
const viewer =
|
||||
args.lat && args.lon
|
||||
? { lat: Number(args.lat), lon: Number(args.lon) }
|
||||
: null;
|
||||
const result = await page.evaluate(
|
||||
async ({ max, quality, viewer }) => {
|
||||
if (viewer) globe.setViewer(viewer);
|
||||
// Warm the recorder a little so detections have a chance to show.
|
||||
const list = (await globe.situations()).slice(0, max);
|
||||
const out = [];
|
||||
for (const s of list) {
|
||||
let thumb = null;
|
||||
try {
|
||||
const url = await globe.thumbnail(s, {
|
||||
width: 360,
|
||||
height: 480,
|
||||
dpr: 1.25,
|
||||
});
|
||||
const img = await createImageBitmap(await (await fetch(url)).blob());
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.width;
|
||||
c.height = img.height;
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
const blob = await new Promise((r) =>
|
||||
c.toBlob(r, 'image/jpeg', quality),
|
||||
);
|
||||
const buf = new Uint8Array(await blob.arrayBuffer());
|
||||
let bin = '';
|
||||
for (let i = 0; i < buf.length; i += 0x8000)
|
||||
bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
|
||||
thumb = btoa(bin);
|
||||
} catch (error) {
|
||||
console.warn('thumbnail failed', s.id, error?.message);
|
||||
}
|
||||
out.push({ situation: JSON.parse(JSON.stringify(s)), thumb });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
{ max: MAX, quality: QUALITY, viewer },
|
||||
);
|
||||
const thumbsDir = path.join(HERE, 'thumbs');
|
||||
await mkdir(thumbsDir, { recursive: true });
|
||||
for (const f of await readdir(thumbsDir))
|
||||
if (f.endsWith('.jpg')) await rm(path.join(thumbsDir, f));
|
||||
let bytes = 0;
|
||||
const situations = [];
|
||||
for (const { situation, thumb } of result) {
|
||||
const file = `${String(situation.id).replace(/[^\w.-]+/g, '_')}.jpg`;
|
||||
if (thumb) {
|
||||
const buf = Buffer.from(thumb, 'base64');
|
||||
if (bytes + buf.length > BUDGET) {
|
||||
console.warn(`budget reached; ${situation.id} ships without a thumb`);
|
||||
} else {
|
||||
bytes += buf.length;
|
||||
await writeFile(path.join(thumbsDir, file), buf);
|
||||
situation.thumbFile = `thumbs/${file}`;
|
||||
}
|
||||
}
|
||||
situations.push(situation);
|
||||
}
|
||||
const snapshot = {
|
||||
capturedAt: new Date().toISOString(),
|
||||
source:
|
||||
'Live God’s Eye feeds through the dev server (see each card’s source)',
|
||||
note: 'Bundled first frame for demos. The shell shows capturedAt and replaces these with live cards as soon as situations() resolves.',
|
||||
situations,
|
||||
};
|
||||
let text = JSON.stringify(snapshot);
|
||||
try {
|
||||
const prettier = await import('prettier');
|
||||
text = await prettier.format(text, { parser: 'json' });
|
||||
} catch {
|
||||
text += '\n';
|
||||
}
|
||||
await writeFile(path.join(HERE, 'snapshot.json'), text);
|
||||
console.log(
|
||||
`wrote ${situations.length} situations, ${Math.round(bytes / 1024)} KB of thumbnails`,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"format": "gev-history-index/1",
|
||||
"capturedAt": {
|
||||
"start": "2026-10-23T08:25:37.000Z",
|
||||
"end": "2026-10-23T11:06:30.000Z"
|
||||
},
|
||||
"regions": [
|
||||
{
|
||||
"id": "nyc",
|
||||
"label": "New York area",
|
||||
"bbox": { "west": -76.5, "south": 39.4, "east": -71.2, "north": 42.2 },
|
||||
"file": "nyc.json",
|
||||
"tracks": 525
|
||||
},
|
||||
{
|
||||
"id": "london",
|
||||
"label": "London and the North Sea approaches",
|
||||
"bbox": { "west": -2.6, "south": 50.2, "east": 5.6, "north": 53.6 },
|
||||
"file": "london.json",
|
||||
"tracks": 1559
|
||||
},
|
||||
{
|
||||
"id": "gulf",
|
||||
"label": "Gulf of Mexico",
|
||||
"bbox": { "west": -98.5, "south": 23.5, "east": -81.5, "north": 31.2 },
|
||||
"file": "gulf.json",
|
||||
"tracks": 493
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -1,319 +0,0 @@
|
||||
# Globe API: round-two additions
|
||||
|
||||
These additions extend DESIGN.md §10 and change no existing signature. Import them from `mobile/globe/index.js`. The doc comment at the top of that file repeats this contract.
|
||||
|
||||
## Viewer position
|
||||
|
||||
```js
|
||||
globe.setViewer({ lon, lat } | null) // where the person is: sets distanceKm and the ISS/Starlink "over you" cards
|
||||
globe.viewer() → { lon, lat } | null
|
||||
```
|
||||
|
||||
## Situations (extended)
|
||||
|
||||
```js
|
||||
globe.situations({ observer? }) → Promise<Situation[]>
|
||||
globe.omitted() → [{ genre, reason }] // genres the last call left out, and why
|
||||
globe.timings() → { [genre]: ms } // per-genre build time of the last call
|
||||
```
|
||||
|
||||
`Situation` keeps every field from round one (`id, kind, family, section, title, subtitle, place, lon, lat, camera, layers, time?, metrics, thumbnail, live, updatedAt, source, severity`) and adds these:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| -------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `band` | `'orbit' \| 'sky' \| 'ground' \| 'inside'` | The altitude band of the sensor. The engine never emits `inside`, because only the shell's own witness and room cards are inside. |
|
||||
| `archetype` | `'sensor' \| 'eye' \| 'track' \| 'number'` | The card layout. The shell adds `witness` and `room` cards itself. |
|
||||
| `telemetry` | `string` | A short uppercase strip, for example `SKY · FL206 · 332 KT · SQ 7700 · EZY58XM`, `GROUND · CAM 07450 · PICCADILLY CIRCUS · 02:16:14Z` or `ORBIT · ISS · 433 KM · 7.65 KM/S`. Draw it in HTML in a monospace face. |
|
||||
| `media?` | `{ kind:'image', url, refreshMs, cameraId, headingDeg?, provider?, city? }` | Eye cards only. `url` is a same-origin frame from `/api/cctv/frame/…` that was checked to be a real upstream image, not the proxy's synthetic fallback. For a fresh frame, call `globe.eyeFrameUrl(cameraId)` every `refreshMs`. |
|
||||
| `series?` | `[{ t, v }]` | Real history for number cards, and for some sensor cards. `t` is epoch ms. |
|
||||
| `seriesLabel?` | `string` | The quantity `v` measures, for example `'lightning-active area (km²) per 15 min'`. |
|
||||
| `headline?` | `{ value, unit, label }` | The one big number on a track or number card. |
|
||||
| `distanceKm?` | `number` | The great-circle distance from the viewer. It is present only once `setViewer` has been called, or when `observer` is passed. |
|
||||
|
||||
The `thumbnail` field is still a plain Esri still, so older code keeps working. New code calls `globe.thumbnail(situation)` instead.
|
||||
|
||||
The engine orders the cards by severity, then interleaves them so neighbouring cards differ in band and archetype where possible.
|
||||
|
||||
## Card compositor
|
||||
|
||||
```js
|
||||
globe.thumbnail(situation, { width = 360, height = 480, dpr = 2 } = {}) → Promise<string /* blob: object URL */>
|
||||
```
|
||||
|
||||
The compositor builds each thumbnail as a JPEG in an offscreen 2D canvas, never in the Cesium viewer. It works in three layers:
|
||||
|
||||
1. **Base imagery.** Esri World Imagery tiles, which are CORS-clean, or NASA GIBS Black Marble for orbit cards.
|
||||
2. **Real overlays.** The data that describes the situation. Examples include NOAA IR with a cold-top enhancement, MRMS radar, lightning density dots by age, the NHC cone with the forecast track and 34/50/64-kt wind radii, aircraft with 2-minute velocity leaders, quake rings, fires with a smoke plume along the observed wind, and SGP4 ground tracks.
|
||||
3. **Grade.** A tint for the altitude band (orbit violet night, sky cool, ground amber) and a vignette.
|
||||
|
||||
The compositor draws no text and no brackets.
|
||||
|
||||
Eye cards return the camera frame itself, lightly graded. Results are cached in memory and, when the page is a secure context, in the Cache API, keyed by id, size and an update bucket. Eye cards refresh every 30 s; other cards follow `updatedAt` in 5-minute buckets. Tiles are shared across cards, and at most 3 cards render at once.
|
||||
|
||||
If an overlay fails, the card still gets its base picture. The promise rejects only when the base imagery or the camera frame fails. In that case, fall back to `situation.thumbnail` or to the shell's own placeholder.
|
||||
|
||||
## Pins (discourse and capture)
|
||||
|
||||
```js
|
||||
globe.addPin({ id, lon, lat, kind, headingDeg?, fovDeg = 60, rangeM = 350, color?, label?, altM? }) → id
|
||||
// kind: 'post' | 'witness' | 'eye' | 'live' | 'hologram'
|
||||
// 'live' and 'eye' pins draw a view-frustum wedge on the ground along headingDeg
|
||||
globe.removePin(id) → boolean
|
||||
globe.pins() → [{ id, lon, lat, kind, headingDeg, fovDeg, rangeM, color, label, altM }]
|
||||
```
|
||||
|
||||
- Picking a pin emits `'pick'` with `{ kind: 'pin', pinKind, id, title, subtitle, lon, lat, meta: { layer: 'pins', headingDeg, fovDeg, label } }`.
|
||||
- Pins appear in `globe.labels()` with `layer: 'pins'` and `pinKind`.
|
||||
- Live pins breathe at 1 Hz.
|
||||
|
||||
## Labels
|
||||
|
||||
Each entry from `globe.labels(max)` can now carry an optional `detail`, a short intensity tag:
|
||||
|
||||
- Cyclones: `CAT 2 · 85 KT · 974 HPA`
|
||||
- Emergency squawks: `SQ 7700`
|
||||
- Air quality: `AQI 152`
|
||||
|
||||
## Nearest public camera
|
||||
|
||||
```js
|
||||
globe.nearestEye(lon, lat, km = 50)
|
||||
→ Promise<{ id, title, lon, lat, distanceKm, headingDeg, fovDeg, imageUrl, city, provider } | null>
|
||||
globe.eyeFrameUrl(cameraId) → string | null // a fresh same-origin frame URL
|
||||
```
|
||||
|
||||
`nearestEye` reads the CCTV catalog at `/api/cctv/sources`, which serves about 4,000 cameras across London, Toronto, Sydney, Seattle, the Bay Area, Los Angeles, Helsinki/Finland, Tallinn, Barcelona, Calgary, Norway and other places. It does not check the frame. A camera can still answer with the proxy's synthetic image when its upstream is down.
|
||||
|
||||
## Weather and storm layers (now available in `globe.layers()`)
|
||||
|
||||
| id | Family | Source | Time |
|
||||
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
|
||||
| `radar` | Weather | NOAA MRMS base reflectivity through nowCOAST (`/api/weather`), CONUS | Scrubbing replays about 50 min of 4-minute frames |
|
||||
| `clouds` | Weather | NOAA global longwave IR mosaic through nowCOAST, drawn as an unlit cloud layer (the alpha-keyed IR) | 6 h of hourly frames |
|
||||
| `lightning` | Weather | NOAA nowCOAST lightning density, 15 min (Americas and Pacific), with a flickering glow per active 0.5° cell | 3 h of 15-minute frames |
|
||||
| `wind` | Weather | 10 m wind, drawn with the desktop app's GPU streamline renderer. Uses `/api/wind` GFS when the server can decode GRIB, otherwise a 12° Open-Meteo grid | Live |
|
||||
| `jet` | Weather | Jet stream at 250 hPa from Open-Meteo, same renderer | Live |
|
||||
| `cyclones` | Hazards | Now also draws the filled NHC cone and 34/50/64-kt wind radii, parsed from the NHC TCM text at api.weather.gov | Forecast scrubbing, as before |
|
||||
| `air-quality` | Hazards | Open-Meteo Air Quality (CAMS model), PM2.5 AQI for 40 major cities | Live |
|
||||
| `gnss` | Infra | `/api/gnss-integrity` (adsb.lol NIC/NACp), shown as 0.5° cells in gpsjam.org bands. From orbit it samples the Baltic, Black Sea, Levant, Gulf and Gulf of Finland; close in, it samples the view | Live |
|
||||
| `transit` | Ground | `/api/transit` GTFS-realtime vehicles from the open feed nearest the view | Live, 20 s |
|
||||
|
||||
Rasters are drawn a few kilometres above the ground and unlit, so storms stay visible on the night side. When an upstream is down, the layer reports `status: 'error'` with a `message`.
|
||||
|
||||
When a weather layer is on, `layers()` entries can also carry `frames` (the number of frames available) and a `message` that gives the product title and the frame age.
|
||||
|
||||
When NHC has published an advisory but its cone geometry is still pending, the cyclone layer and cards take the forecast positions from the advisory text, so a track is still drawn but there is no cone.
|
||||
|
||||
## Dev harnesses
|
||||
|
||||
- `mobile/globe/dev.html` shows the globe full-screen and exposes `window.globe`. Its labels show `detail`.
|
||||
- `mobile/globe/contact.html` is the contact sheet: it calls `situations()`, then `thumbnail()` for every card.
|
||||
- `?lat=&lon=` sets the viewer.
|
||||
- `?only=cyclone,eye` filters cards by id prefix.
|
||||
- `?w=330` sets the card width.
|
||||
|
||||
---
|
||||
|
||||
# Round three: Gotham for everyone (DESIGN.md §12)
|
||||
|
||||
Additive. Nothing above changes. Every call below exists on the object `createGlobe()` resolves to. Times are epoch ms unless a `Date` is shown.
|
||||
|
||||
## Instant first frame
|
||||
|
||||
```js
|
||||
globe.situations({ cached: true }) → Promise<Situation[]>
|
||||
// resolves at once from the last good IndexedDB snapshot (written by every live
|
||||
// situations() call), else the bundled mobile/demo/snapshot.json (real data).
|
||||
// Each cached Situation adds: cached: true, cacheOrigin: 'device' | 'bundled',
|
||||
// capturedAt (epoch ms), live: false, thumbUrl (bundled /mobile/demo/thumbs/<id>.jpg
|
||||
// or a Cache API blob URL) or null.
|
||||
globe.situations() → Promise<Situation[]> // live, as before (also saves the cache)
|
||||
globe.cachedThumbnail(situation) → string | null // sync; thumbUrl for a cached card
|
||||
globe.thumbnail(cachedSituation) → resolves to the cached JPEG when the card has no live scene
|
||||
```
|
||||
|
||||
The globe paints its first frame with imagery only. Live layers start right after it, and weather, transit, GNSS, Starlink and satellites start about a second later. Background warm-ups (jet, TCM, AQ, CCTV, rasters) start at 4 s.
|
||||
|
||||
## Recorder and time machine
|
||||
|
||||
```js
|
||||
globe.history() → { start, end, entities, points, bytes } // recorded range; start/end null when empty
|
||||
globe.setTime(t) // unchanged. Inside the recorded range, contacts are drawn at their interpolated
|
||||
// recorded positions with 4-minute ghost trails (pickable: 'pick' kind is the
|
||||
// contact's kind, meta.layer 'history', entityId). Beyond the record, dimmed as before.
|
||||
globe.trackOf(entityId) → Promise<[{ t, lon, lat, altM, headingDeg, speed }]>
|
||||
// recorder samples merged with /api/flights/track (OpenSky) or /api/military/track
|
||||
// (adsb.lol traces); the array has .source
|
||||
|
||||
globe.playback.play({ speed = 60, from?, until? } = {}) → state
|
||||
// speeds: 1, 10, 30, 60, 600. From live it replays the last 15 min. Reaching "now" goes live;
|
||||
// inside a Moment it stops at the Moment's end.
|
||||
globe.playback.stop() → state
|
||||
globe.playback.state() → { playing, speed, t, live }
|
||||
globe.playback.speeds // [1, 10, 30, 60, 600]
|
||||
// event 'playback' → state, on start/stop and every 250 ms while playing.
|
||||
// 'time' is also emitted every 250 ms while playing ({ live:false, time, playing:true }).
|
||||
// Playing runs the render loop; at rest the globe is request-render again.
|
||||
|
||||
globe.timeline({ from, to } = {}) → [{ t, kind, band, title, severity, situationId?, entityId?, momentId?, lon?, lat? }]
|
||||
// kind: 'quake' | 'quake-cluster' | 'launch' | 'squawk' | 'cyclone' | 'fire' | 'circling'
|
||||
// | 'lightning' | 'moment' (a Moment beat) | 'capture' | 'post' | 'chat-burst'
|
||||
// band: 'orbit' | 'sky' | 'ground' | 'inside'; severity 0..3; sorted by t.
|
||||
// Defaults to timeWindow(). Moment items appear for Moments that were loaded and overlap the range.
|
||||
```
|
||||
|
||||
The recorder keeps every poll for low traffic (helicopters, approaches, anything below about 6 km) and one fix every 50 s for cruise traffic. Samples older than 30 min are thinned. The buffer is capped at 1.2 M samples (about 24 MB) and persisted to IndexedDB about once a minute, so a reload keeps up to 6 h. When OpenSky is down, the recorder also polls adsb.lol snapshots around New York, London, Los Angeles and the viewer every 15 s, so the detector has real data.
|
||||
|
||||
Entity ids are layer-qualified: `flights:<icao24>`, `military:<hex>`, `satellites:<norad>`, `earthquakes:<id>`, `cctv:<id>`, `vessels:<mmsi>`, `situation:<id>`, `place:<slug>`, `moment:<momentId>`, `moment:<momentId>:<trackId>`, `capture:<momentId>:<captureId>`. Every `'pick'` payload can be passed to `entity()` as is.
|
||||
|
||||
## Detection
|
||||
|
||||
Detections run on recorder data every poll. They appear only when real data shows them.
|
||||
|
||||
- `circling`: two or more aircraft (rotorcraft and low-and-slow preferred, below about 8,000 ft and 155 kt), each turning through at least 360° within about 1.5 km of a common centre in the last 20 min. Card: `{ kind:'circling', band:'sky', archetype:'track', title:'3 helicopters circling Midtown Manhattan', momentId:'live-circling-…', members:[{ key, layer, callsign, helicopter }], entityIds, detected:true }`. Its thumbnail draws the recorded orbit tracks. A single aircraft on its second full orbit is a lesser detection (severity 4.5, `single: true`, for example "Helicopter circling Las Vegas Strip"). `momentOfTheDay()` ignores single orbits.
|
||||
- `quake-cluster`: three or more M2.5+ earthquakes within 50 km in 24 h. Archetype `number`, with a magnitude series.
|
||||
- `squawk` cards now carry `momentId: 'live-squawk-<icao24>'`.
|
||||
|
||||
```js
|
||||
globe.detections() → Situation[] // current detections, sync
|
||||
// event 'detections' → detector output, when the set of circling detections changes
|
||||
```
|
||||
|
||||
## Moments
|
||||
|
||||
```js
|
||||
globe.moments() → Promise<Moment[]> // live (circling, squawks) first, then the bundled hero
|
||||
globe.moment(id) → Promise<Moment|null> // 'empire-state' | 'live-circling-…' | 'live-squawk-<hex>'
|
||||
globe.momentOfTheDay() → Promise<Moment> // best live Moment if any, else the hero
|
||||
globe.playMoment(idOrMoment, { speed, camera = true, autoplay = true, framing = 6 } = {}) → Promise<Moment>
|
||||
// speed defaults to moment.recommendedSpeed (hero: 30 → 50 s). camera:false leaves the camera alone.
|
||||
// framing: degrees the cinematic camera looks below the subject (puts it in the upper half).
|
||||
// Hides every live layer, draws tracks (glyph, 90 s bright trail, faint flown path, drop line),
|
||||
// capture view frustums that pop in at their t, the subject building and its beacon, and flies
|
||||
// the camera path keyed to the clock. Touching the globe releases the camera.
|
||||
globe.exitMoment({ restoreCamera = true } = {}) // back to live (or the time before) and the old camera
|
||||
globe.activeMoment() → { id, title, illustrative, t, start, end, playing, speed, cinematic, moment } | null
|
||||
globe.seekMoment(t) // epoch ms, or a 0..1 fraction of the Moment
|
||||
globe.momentCamera(on = true) // re-attach (or release) the cinematic camera → boolean
|
||||
// event 'moment' → { type, item, t, momentId }
|
||||
// type: 'start' (item = Moment) | 'capture' | 'chat' | 'post' | 'beat' | 'end' | 'exit'
|
||||
// | 'camera' (item = { cinematic:false } when the person takes the camera)
|
||||
// Items fire as the clock passes them while playing, and on small seeks. A seek back, or a jump of
|
||||
// more than 90 s while paused, repositions silently: render the Live/Record lanes by filtering
|
||||
// activeMoment().moment.chat/posts/captures by t <= activeMoment().t.
|
||||
```
|
||||
|
||||
```ts
|
||||
Moment = {
|
||||
id, title, place, lon, lat,
|
||||
time: { start, end }, // epoch ms
|
||||
illustrative: boolean, // true for the bundled hero; false for live detections
|
||||
illustrativeNote?: string,
|
||||
recommendedSpeed?: number,
|
||||
subject?: { lon, lat, altM, label, roofM?, antennaM?, decks? },
|
||||
cameraPath: [{ t, lon, lat, height /* m above ground */, heading, pitch }],
|
||||
beats?: [{ t, title }],
|
||||
tracks: [{ id, kind /* 'helicopter'|'aircraft' */, label, operator, callsign?, registration?, type?,
|
||||
color?, entityId? /* live: 'flights:<hex>' */, points: [{ t, lon, lat, altM /* MSL */ }] }],
|
||||
captures: [{ id, t, lon, lat, altM, headingDeg, pitchDeg, fovDeg, rangeM, title, handle, place,
|
||||
sealed, firstEyes?, kind /* 'photo'|'clip' */, image? }],
|
||||
chat: [{ t, handle, text, badge? /* 'first-eyes'|'witness'|'local'|'pilot'|'met' */ }],
|
||||
posts: [{ id, t, handle, kind /* 'witness'|'context'|'question'|'correction' */, text, votes, captureId? }],
|
||||
sources: [{ name, detail }],
|
||||
}
|
||||
```
|
||||
|
||||
`playMoment`, `exitMoment` and `setTime(null)` are safe to call at any time, including mid-playback or while a load is still pending: a later call supersedes an earlier one, and the stage frees its primitives only after the frame in progress.
|
||||
|
||||
`labels()` collision-avoids each label as it will be drawn (`title · detail`). When a Moment label collides, it falls back to callsign plus altitude, then to the callsign alone. Such entries carry `abbreviated: true` and the shortened `title` and `detail`.
|
||||
|
||||
The hero is `mobile/demo/moments/empire-state.json` (id `empire-state`): "Climbers on the Empire State Building", 25 min, 3 helicopters, 8 captures, 120 chat lines and 12 posts. Regenerate it with `node mobile/demo/build-empire-state.mjs`. Its Midtown massing is real NYC Open Data footprints and roof heights in `mobile/demo/midtown-buildings.json` (`node mobile/demo/build-midtown.mjs`). Its river surfaces are OpenStreetMap water polygons in `mobile/demo/midtown-water.json` (`node mobile/demo/build-midtown-water.mjs`). They are drawn over Esri's mismatched water mosaic across the Hudson.
|
||||
|
||||
## Entities and dossiers
|
||||
|
||||
```js
|
||||
globe.entity(ref) → Promise<Dossier> // ref: entity id string, a 'pick' payload, or { kind, id }
|
||||
Dossier = {
|
||||
id, kind, title, subtitle,
|
||||
identity: { icao24?, callsign?, registration?, type?, typeCode?, operator?, route?, country?, squawk?,
|
||||
norad?, intlDesignator?, tle?, mmsi?, provider?, magnitude?, illustrative?, ... },
|
||||
live: { lon, lat, altM, speed /* m/s */, heading, squawk?, updatedAt, speedKt?, elevationDeg? } | null,
|
||||
track: [{ t, lon, lat, altM }],
|
||||
events: [{ t, title }],
|
||||
links: [{ id, kind, title, relation, distanceKm? }],
|
||||
sources: [{ name, fetchedAt, url?, hash /* 'sha256:…' (or 'fnv1a:…' outside secure contexts) */, detail? }],
|
||||
privacy?: 'pia' | 'ladd' | 'no-public-identity', // identity then holds only what the transponder broadcasts
|
||||
trackPending?: true, // the server track was slow; call trackOf(id) later for the full one
|
||||
nextPass?: { start, max, end, maxElevationDeg, visible }, // satellites, for the viewer
|
||||
illustrative?: true, // hero Moment tracks and captures
|
||||
}
|
||||
globe.search(query) → Promise<[{ id, kind, title, subtitle }]> // ≤ 30; "N46567", "UAL", "7700",
|
||||
// "M5.2", "ISS", "Midtown", "empire"… across live aircraft, the record, satellites, quakes,
|
||||
// cameras, situations, places (neighbourhoods, airports, cities) and Moments
|
||||
globe.graph(ref) → { center, nodes: [{ id, kind, title }], edges: [{ from, to, relation }] } // ≤ 40 nodes, sync
|
||||
```
|
||||
|
||||
Kinds: `aircraft`, `helicopter`, `military`, `satellite`, `vessel`, `quake`, `camera`, `situation`, `place`, `moment`, `track`, `capture`. There are never person entities. Aircraft dossiers wait at most 2.5 s for the server track (aircraft identity comes from adsbdb, 3.5 s budget).
|
||||
|
||||
## Reactions and attention
|
||||
|
||||
```js
|
||||
globe.react({ lon, lat, kind: 'heart'|'fire'|'eyes'|'wow', count = 1, altM = 0 }) → number
|
||||
globe.attentionHeat(points | null) // points: [{ lon, lat, weight? }]; soft glow where viewers are
|
||||
```
|
||||
|
||||
Reactions draw on a screen-space canvas that the globe owns (`.gev-reactions`, pointer-events none, above the globe and below your HTML). Each glyph stays anchored to its point as the globe moves. At most 300 are alive at once, and the canvas animates only while some are alive.
|
||||
|
||||
## Your sky now
|
||||
|
||||
```js
|
||||
globe.skyNow({ lon, lat } = viewer) → Promise<{
|
||||
at, observer, sunElevationDeg,
|
||||
aircraft: [{ id, title, kind, lon, lat, altM, distanceKm, elevationDeg, azimuthDeg, headingDeg, speedMps, squawk }],
|
||||
satellites: [{ id, title, norad, altKm, elevationDeg, azimuthDeg }],
|
||||
iss: { visibleNow, aboveHorizon, elevationDeg, azimuthDeg,
|
||||
nextPass: { start, max, end, maxElevationDeg, riseAzimuthDeg, visible } | null },
|
||||
sources: [string],
|
||||
}> // aircraft within 50 km, satellites above the horizon; each list sorted by elevation, highest first
|
||||
```
|
||||
|
||||
## Dev harness
|
||||
|
||||
`mobile/globe/dev.html?moment=empire-state&speed=30` plays a Moment on load and prints its events in the HUD. `globe.debug` exposes `{ recorder, detector, stage, ghosts }` for tests. It is not part of the contract.
|
||||
|
||||
---
|
||||
|
||||
# Polish round
|
||||
|
||||
Additive. Nothing above changes.
|
||||
|
||||
## Bundled recording (the time machine always has ghosts)
|
||||
|
||||
`mobile/demo/history/{nyc,london,gulf}.json` hold a real recording of every aircraft over the New York area, London and the North Sea approaches, and the Gulf of Mexico, captured by `node mobile/demo/capture-history.mjs` (adsb.lol point snapshots around each region's anchors, the dev server's `/api/flights` and `/api/military` as fallbacks; one fix per 30 s below 6 km, per 120 s in cruise). Positions are quantised to 1e-4° and 10 m and delta-encoded (`format: 'gev-history/1'`). Timestamps are the true capture times and are **never shifted**: the time machine shows the recording where it happened.
|
||||
|
||||
The engine loads it about 5 s after the first frame into a second recorder (no expiry) and reads both through one view: ghosts, `setTime` history mode, `entity()`, `trackOf()` and `search()` see the recording; on a key present in both, the device's own recorder wins.
|
||||
|
||||
```js
|
||||
globe.history().archive → { start, end, regions: [{ id, label, bbox, source, start, end, tracks,
|
||||
busiest: { t, aircraft } }] } | null
|
||||
globe.loadHistory() → Promise<history().archive> // resolves once it is loaded
|
||||
globe.historyNear(t, lon, lat, km = 100)
|
||||
→ [{ id /* entity id */, key, kind, title, lon, lat, altM }] // recorded contacts at t, nearest first
|
||||
// event 'history' → history(), once the recording is loaded
|
||||
```
|
||||
|
||||
The time ribbon shades the recording in teal (`RECORDING · 23 OCT`) next to the device's own `RECORDED` range, and its readout names the day when the playhead is on another UTC day.
|
||||
|
||||
## Camera
|
||||
|
||||
```js
|
||||
globe.flyTo({ lon, lat, height, heading, pitch, eye: true }); // eye: lon/lat/height place the camera itself
|
||||
```
|
||||
|
||||
## Night close-ups
|
||||
|
||||
Below ~400 km, where Cesium's lighting is faded out, a night-side view grades the Esri mosaic toward night (darker, desaturated, slightly cooler) with a soft glow of the real Black Marble lights above ~40 km; the strength follows the sun's elevation under the camera at the **scene clock**, so a Moment keeps the lighting of its own time (the hero is daylight). A terrain watchdog falls back to the flat ellipsoid when the keyless terrain host stalls (the globe would otherwise show only the star field).
|
||||
|
||||
## Dossiers
|
||||
|
||||
`privacy` is set only for real privacy programmes (`'pia'`, `'ladd'` flags). An airframe with no public registry record gets `registryMissing: true` instead, and `identity.operator` falls back to the callsign's ICAO airline prefix (`mobile/globe/airlines.js`, about 150 operators).
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* ICAO airline designators (the three letters that start an airline
|
||||
* callsign, e.g. AMX021 → Aeroméxico) for about 150 of the world's busiest
|
||||
* operators. Used to name the operator of a flight when the community
|
||||
* registry has no record for its airframe. Source: ICAO Doc 8585 designators
|
||||
* as published by the airlines; names are the common trading names.
|
||||
*/
|
||||
const AIRLINES = {
|
||||
AAL: 'American Airlines',
|
||||
AAR: 'Asiana Airlines',
|
||||
ABX: 'ABX Air',
|
||||
ACA: 'Air Canada',
|
||||
ADR: 'Air Serbia',
|
||||
AEA: 'Air Europa',
|
||||
AEE: 'Aegean Airlines',
|
||||
AFL: 'Aeroflot',
|
||||
AFR: 'Air France',
|
||||
AIC: 'Air India',
|
||||
AJX: 'Air Japan',
|
||||
ALK: 'SriLankan Airlines',
|
||||
AMX: 'Aeroméxico',
|
||||
ANA: 'All Nippon Airways',
|
||||
ANZ: 'Air New Zealand',
|
||||
ASA: 'Alaska Airlines',
|
||||
ASH: 'Mesa Airlines',
|
||||
AUA: 'Austrian Airlines',
|
||||
AVA: 'Avianca',
|
||||
AWE: 'American Airlines (US Airways)',
|
||||
AXM: 'AirAsia',
|
||||
AZA: 'ITA Airways',
|
||||
AZU: 'Azul',
|
||||
BAW: 'British Airways',
|
||||
BCS: 'European Air Transport (DHL)',
|
||||
BEL: 'Brussels Airlines',
|
||||
BTI: 'airBaltic',
|
||||
BOX: 'AeroLogic',
|
||||
CAL: 'China Airlines',
|
||||
CCA: 'Air China',
|
||||
CES: 'China Eastern Airlines',
|
||||
CFG: 'Condor',
|
||||
CHH: 'Hainan Airlines',
|
||||
CKS: 'Kalitta Air',
|
||||
CLX: 'Cargolux',
|
||||
CMP: 'Copa Airlines',
|
||||
CPA: 'Cathay Pacific',
|
||||
CPZ: 'Compass Airlines',
|
||||
CSN: 'China Southern Airlines',
|
||||
CSZ: 'Shenzhen Airlines',
|
||||
CTN: 'Croatia Airlines',
|
||||
CXA: 'Xiamen Airlines',
|
||||
DAL: 'Delta Air Lines',
|
||||
DHK: 'DHL Air',
|
||||
DLH: 'Lufthansa',
|
||||
EDV: 'Endeavor Air',
|
||||
EIN: 'Aer Lingus',
|
||||
EJA: 'NetJets',
|
||||
EJU: 'easyJet Europe',
|
||||
ELY: 'El Al',
|
||||
ENY: 'Envoy Air',
|
||||
ETD: 'Etihad Airways',
|
||||
ETH: 'Ethiopian Airlines',
|
||||
EVA: 'EVA Air',
|
||||
EWG: 'Eurowings',
|
||||
EXS: 'Jet2',
|
||||
EZS: 'easyJet Switzerland',
|
||||
EZY: 'easyJet',
|
||||
FDX: 'FedEx Express',
|
||||
FFT: 'Frontier Airlines',
|
||||
FIN: 'Finnair',
|
||||
FJI: 'Fiji Airways',
|
||||
FLE: 'Flair Airlines',
|
||||
GEC: 'Lufthansa Cargo',
|
||||
GFA: 'Gulf Air',
|
||||
GJS: 'GoJet Airlines',
|
||||
GLO: 'Gol',
|
||||
GTI: 'Atlas Air',
|
||||
HAL: 'Hawaiian Airlines',
|
||||
HVN: 'Vietnam Airlines',
|
||||
IBE: 'Iberia',
|
||||
IBS: 'Iberia Express',
|
||||
ICE: 'Icelandair',
|
||||
IGO: 'IndiGo',
|
||||
JAL: 'Japan Airlines',
|
||||
JBU: 'JetBlue',
|
||||
JIA: 'PSA Airlines',
|
||||
JJP: 'Jetstar Japan',
|
||||
JST: 'Jetstar',
|
||||
JZA: 'Jazz Aviation (Air Canada Express)',
|
||||
KAL: 'Korean Air',
|
||||
KLM: 'KLM Royal Dutch Airlines',
|
||||
KQA: 'Kenya Airways',
|
||||
LAN: 'LATAM Airlines',
|
||||
LDM: 'Lauda Europe',
|
||||
LOG: 'Loganair',
|
||||
LOT: 'LOT Polish Airlines',
|
||||
LXJ: 'Flexjet',
|
||||
MAS: 'Malaysia Airlines',
|
||||
MEA: 'Middle East Airlines',
|
||||
MSR: 'EgyptAir',
|
||||
NAX: 'Norwegian',
|
||||
NJE: 'NetJets Europe',
|
||||
NKS: 'Spirit Airlines',
|
||||
NOZ: 'Norwegian',
|
||||
NWG: 'Airwork',
|
||||
OAW: 'Helvetic Airways',
|
||||
OMA: 'Oman Air',
|
||||
PAL: 'Philippine Airlines',
|
||||
PAC: 'Polar Air Cargo',
|
||||
PDT: 'Piedmont Airlines',
|
||||
PGT: 'Pegasus Airlines',
|
||||
POE: 'Porter Airlines',
|
||||
QFA: 'Qantas',
|
||||
QTR: 'Qatar Airways',
|
||||
QXE: 'Horizon Air',
|
||||
RAM: 'Royal Air Maroc',
|
||||
RJA: 'Royal Jordanian',
|
||||
ROU: 'Air Canada Rouge',
|
||||
RPA: 'Republic Airways',
|
||||
RYR: 'Ryanair',
|
||||
RZO: 'Azores Airlines',
|
||||
SAS: 'Scandinavian Airlines',
|
||||
SAA: 'South African Airways',
|
||||
SBI: 'S7 Airlines',
|
||||
SCX: 'Sun Country Airlines',
|
||||
SIA: 'Singapore Airlines',
|
||||
SKW: 'SkyWest Airlines',
|
||||
SLK: 'SilkAir',
|
||||
SVA: 'Saudia',
|
||||
SWA: 'Southwest Airlines',
|
||||
SWR: 'Swiss',
|
||||
SXS: 'SunExpress',
|
||||
TAM: 'LATAM Brasil',
|
||||
TAP: 'TAP Air Portugal',
|
||||
TAR: 'Tunisair',
|
||||
TCX: 'Thomas Cook Airlines',
|
||||
THA: 'Thai Airways',
|
||||
THY: 'Turkish Airlines',
|
||||
TOM: 'TUI Airways',
|
||||
TRA: 'Transavia',
|
||||
TSC: 'Air Transat',
|
||||
TVF: 'Transavia France',
|
||||
TVS: 'Smartwings',
|
||||
UAE: 'Emirates',
|
||||
UAL: 'United Airlines',
|
||||
UPS: 'UPS Airlines',
|
||||
UZB: 'Uzbekistan Airways',
|
||||
VIR: 'Virgin Atlantic',
|
||||
VJT: 'VistaJet',
|
||||
VLG: 'Vueling',
|
||||
VOI: 'Volaris',
|
||||
VOZ: 'Virgin Australia',
|
||||
VTI: 'Vistara',
|
||||
WJA: 'WestJet',
|
||||
WIF: 'Widerøe',
|
||||
WMT: 'Wizz Air Malta',
|
||||
WUK: 'Wizz Air UK',
|
||||
WZZ: 'Wizz Air',
|
||||
XLF: 'XL Airways',
|
||||
};
|
||||
|
||||
/** Operator name from a callsign's ICAO airline prefix, or null. */
|
||||
export function airlineFromCallsign(callsign) {
|
||||
const m = /^([A-Z]{3})\d/.exec(
|
||||
String(callsign || '')
|
||||
.trim()
|
||||
.toUpperCase(),
|
||||
);
|
||||
return m ? AIRLINES[m[1]] || null : null;
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* The bundled recording (DESIGN.md §12.5): a few hours of real recorded
|
||||
* positions over the busiest regions, captured with
|
||||
* `node mobile/demo/capture-history.mjs` into mobile/demo/history/*.json.
|
||||
*
|
||||
* It ships so the time machine always has real ghosts to replay, even on a
|
||||
* first run when the device's own recorder holds only minutes. Timestamps
|
||||
* are the true capture times and are never shifted: the time machine shows
|
||||
* the recording where it really happened (the director navigates there).
|
||||
*
|
||||
* The archive is a second recorder (no expiry, no persistence). `history`
|
||||
* is a read-only view over both that ghosts, the dossier and search use; on
|
||||
* a key present in both, the device's own recorder wins.
|
||||
*/
|
||||
import { createRecorder } from './recorder.js';
|
||||
|
||||
// Literal URLs so Vite copies the files into the production build.
|
||||
const FILES = [
|
||||
new URL('../demo/history/nyc.json', import.meta.url).href,
|
||||
new URL('../demo/history/london.json', import.meta.url).href,
|
||||
new URL('../demo/history/gulf.json', import.meta.url).href,
|
||||
];
|
||||
|
||||
/** Decode one `gev-history/1` file into [{ key, meta, points }]. */
|
||||
export function decodeHistory(j) {
|
||||
if (j?.format !== 'gev-history/1' || !Array.isArray(j.tracks)) return [];
|
||||
const out = [];
|
||||
for (const tr of j.tracks) {
|
||||
const p = typeof tr.p === 'string' ? tr.p.split(',').map(Number) : tr.p;
|
||||
if (!Array.isArray(p) || p.length < 12) continue;
|
||||
let t = j.t0;
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
let alt = 0;
|
||||
const points = [];
|
||||
for (let i = 0; i + 5 < p.length; i += 6) {
|
||||
t += p[i];
|
||||
lon += p[i + 1];
|
||||
lat += p[i + 2];
|
||||
alt += p[i + 3];
|
||||
points.push({
|
||||
t: t * 1000,
|
||||
lon: lon / 1e4,
|
||||
lat: lat / 1e4,
|
||||
altM: alt * 10,
|
||||
headingDeg: p[i + 4],
|
||||
speed: p[i + 5],
|
||||
});
|
||||
}
|
||||
out.push({ key: String(tr.k), meta: tr.m || { kind: 'aircraft' }, points });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createArchive() {
|
||||
const rec = createRecorder({
|
||||
persist: false,
|
||||
archive: true,
|
||||
windowMs: Infinity,
|
||||
maxInterpGapS: 11 * 60,
|
||||
});
|
||||
const regions = [];
|
||||
let loading = null;
|
||||
|
||||
function load() {
|
||||
if (loading) return loading;
|
||||
loading = Promise.all(
|
||||
FILES.map((url) =>
|
||||
fetch(url)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
),
|
||||
).then((files) => {
|
||||
for (const j of files) {
|
||||
if (!j) continue;
|
||||
const tracks = decodeHistory(j);
|
||||
let start = Infinity;
|
||||
let end = -Infinity;
|
||||
for (const tr of tracks) {
|
||||
rec.merge(tr.key, tr.points, tr.meta);
|
||||
start = Math.min(start, tr.points[0].t);
|
||||
end = Math.max(end, tr.points[tr.points.length - 1].t);
|
||||
}
|
||||
if (!tracks.length) continue;
|
||||
regions.push({
|
||||
id: j.region,
|
||||
label: j.label,
|
||||
bbox: j.bbox,
|
||||
source: j.source,
|
||||
start,
|
||||
end,
|
||||
tracks: tracks.length,
|
||||
busiest: busiestInstant(tracks, start, end),
|
||||
});
|
||||
}
|
||||
return regions;
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
|
||||
return {
|
||||
recorder: rec,
|
||||
load,
|
||||
get loaded() {
|
||||
return regions.length > 0;
|
||||
},
|
||||
regions: () => regions.slice(),
|
||||
range() {
|
||||
if (!regions.length) return null;
|
||||
return {
|
||||
start: Math.min(...regions.map((r) => r.start)),
|
||||
end: Math.max(...regions.map((r) => r.end)),
|
||||
regions: regions.map((r) => ({ ...r })),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The 5-minute bucket with the most aircraft in the air. */
|
||||
function busiestInstant(tracks, start, end) {
|
||||
const B = 5 * 60_000;
|
||||
const n = Math.max(1, Math.ceil((end - start) / B));
|
||||
const counts = new Array(n).fill(0);
|
||||
for (const tr of tracks) {
|
||||
const a = Math.floor((tr.points[0].t - start) / B);
|
||||
const b = Math.floor((tr.points[tr.points.length - 1].t - start) / B);
|
||||
for (let i = Math.max(0, a); i <= Math.min(n - 1, b); i++) counts[i]++;
|
||||
}
|
||||
let best = 0;
|
||||
for (let i = 1; i < n; i++) if (counts[i] > counts[best]) best = i;
|
||||
return { t: start + best * B + B / 2, aircraft: counts[best] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only view over the device's recorder and the archive, with the
|
||||
* recorder's query surface (covers, snapshotAt, trail, samples, stateAt…).
|
||||
*/
|
||||
export function historyView(live, archive) {
|
||||
const a = archive.recorder;
|
||||
const pick = (key) => (live.has(key) ? live : a.has(key) ? a : null);
|
||||
return {
|
||||
covers: (ms, slack) => live.covers(ms, slack) || a.covers(ms, slack),
|
||||
has: (key) => live.has(key) || a.has(key),
|
||||
meta: (key) => live.meta(key) || a.meta(key),
|
||||
*keys() {
|
||||
yield* live.keys();
|
||||
for (const k of a.keys()) if (!live.has(k)) yield k;
|
||||
},
|
||||
samples(key, from, to) {
|
||||
const own = live.samples(key, from, to);
|
||||
return own.length ? own : a.samples(key, from, to);
|
||||
},
|
||||
stateAt(key, ms, opts) {
|
||||
return live.stateAt(key, ms, opts) || a.stateAt(key, ms, opts);
|
||||
},
|
||||
snapshotAt(ms, opts) {
|
||||
const own = live.snapshotAt(ms, opts);
|
||||
if (!a.covers(ms, 60_000)) return own;
|
||||
const seen = new Set(own.map((s) => s.key));
|
||||
for (const s of a.snapshotAt(ms, opts)) if (!seen.has(s.key)) own.push(s);
|
||||
return own;
|
||||
},
|
||||
trail(key, from, to, max) {
|
||||
const r = live.trail(key, from, to, max);
|
||||
return r.length >= 2 ? r : a.trail(key, from, to, max);
|
||||
},
|
||||
events: () => live.events(),
|
||||
range: () => live.range(),
|
||||
source: (key) => (pick(key) === a ? 'archive' : 'device'),
|
||||
};
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Instant first frame (DESIGN.md §12.8): the last good situations and their
|
||||
* thumbnails, so the feed paints before any live feed answers.
|
||||
*
|
||||
* 1. IndexedDB: the last live situations() result in this browser
|
||||
* 2. else the bundled snapshot, mobile/demo/snapshot.json (real data,
|
||||
* captured by mobile/demo/capture-snapshot.mjs; see `capturedAt`)
|
||||
*
|
||||
* Thumbnails: the last rendered JPEG per situation id from the Cache API
|
||||
* (secure contexts), else the bundled mobile/demo/thumbs/<id>.jpg.
|
||||
*/
|
||||
import { idbGet, idbPut } from './idb.js';
|
||||
|
||||
const SNAPSHOT_URL = new URL('../demo/snapshot.json', import.meta.url).href;
|
||||
const BUNDLED_THUMBS = import.meta.glob('../demo/thumbs/*.jpg', {
|
||||
eager: true,
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
});
|
||||
const LAST_CACHE = 'gev-thumbs-last-v1';
|
||||
const cacheApi = () =>
|
||||
typeof caches !== 'undefined' && globalThis.isSecureContext;
|
||||
|
||||
const bundledThumb = new Map();
|
||||
for (const [path, url] of Object.entries(BUNDLED_THUMBS)) {
|
||||
const id = decodeURIComponent(
|
||||
path
|
||||
.split('/')
|
||||
.pop()
|
||||
.replace(/\.jpg$/, ''),
|
||||
);
|
||||
bundledThumb.set(id, url);
|
||||
}
|
||||
const cachedThumb = new Map(); // id → blob: URL from the Cache API
|
||||
|
||||
export function thumbFileName(id) {
|
||||
return `${String(id).replace(/[^\w.-]+/g, '_')}.jpg`;
|
||||
}
|
||||
|
||||
function bundledFor(id) {
|
||||
return (
|
||||
bundledThumb.get(thumbFileName(id).replace(/\.jpg$/, '')) ||
|
||||
bundledThumb.get(id) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Sync: the best cached thumbnail URL for a situation, or null. */
|
||||
export function cachedThumbnail(situation) {
|
||||
const id = situation?.id;
|
||||
if (!id) return null;
|
||||
return cachedThumb.get(id) || bundledFor(id) || situation.thumbUrl || null;
|
||||
}
|
||||
|
||||
/** Remember the last rendered thumbnail for an id (Cache API). */
|
||||
export async function rememberThumbnail(id, blob) {
|
||||
if (!cacheApi() || !blob) return;
|
||||
try {
|
||||
const c = await caches.open(LAST_CACHE);
|
||||
await c.put(
|
||||
`${location.origin}/__gev-last/${encodeURIComponent(id)}`,
|
||||
new Response(blob, { headers: { 'Content-Type': 'image/jpeg' } }),
|
||||
);
|
||||
} catch {
|
||||
/* quota or private mode */
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateThumbs(list) {
|
||||
if (!cacheApi()) return;
|
||||
try {
|
||||
const c = await caches.open(LAST_CACHE);
|
||||
await Promise.all(
|
||||
list.map(async (s) => {
|
||||
if (cachedThumb.has(s.id)) return;
|
||||
const hit = await c.match(
|
||||
`${location.origin}/__gev-last/${encodeURIComponent(s.id)}`,
|
||||
);
|
||||
if (hit) cachedThumb.set(s.id, URL.createObjectURL(await hit.blob()));
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
let bundledPromise = null;
|
||||
function loadBundled() {
|
||||
if (!bundledPromise)
|
||||
bundledPromise = fetch(SNAPSHOT_URL)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null);
|
||||
return bundledPromise;
|
||||
}
|
||||
// Start the bundled fetch at import so a cached call never waits on it.
|
||||
loadBundled();
|
||||
|
||||
function decorate(list, capturedAt, origin) {
|
||||
return list.map((s) => ({
|
||||
...s,
|
||||
cached: true,
|
||||
cacheOrigin: origin,
|
||||
capturedAt,
|
||||
live: false,
|
||||
thumbUrl: cachedThumb.get(s.id) || bundledFor(s.id) || s.thumbUrl || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The last good situations, fast. Resolves [] only when there is nothing at
|
||||
* all (no IndexedDB snapshot and no bundled file).
|
||||
*/
|
||||
export async function loadCachedSituations() {
|
||||
const stored = await Promise.race([
|
||||
idbGet('kv', 'situations'),
|
||||
new Promise((r) => setTimeout(() => r(null), 60)),
|
||||
]);
|
||||
if (stored?.list?.length) {
|
||||
hydrateThumbs(stored.list);
|
||||
return decorate(stored.list, stored.capturedAt, 'device');
|
||||
}
|
||||
const bundled = await loadBundled();
|
||||
if (bundled?.situations?.length)
|
||||
return decorate(
|
||||
bundled.situations,
|
||||
Date.parse(bundled.capturedAt) || bundled.capturedAt,
|
||||
'bundled',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Store a live situations() result as the next first frame. */
|
||||
export function saveSituations(list) {
|
||||
if (!list?.length) return;
|
||||
const clean = list.map((s) => {
|
||||
const { thumbUrl, cached, cacheOrigin, ...rest } = s;
|
||||
void thumbUrl;
|
||||
void cached;
|
||||
void cacheOrigin;
|
||||
return rest;
|
||||
});
|
||||
idbPut('kv', 'situations', { capturedAt: Date.now(), list: clean }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
export const hasBundledThumb = (id) => Boolean(bundledFor(id));
|
||||
@@ -1,70 +0,0 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: #05070b;
|
||||
color: #dfe6f0;
|
||||
font:
|
||||
12px/1.35 system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
#globe {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
}
|
||||
#status {
|
||||
padding: 10px 14px;
|
||||
font:
|
||||
11px ui-monospace,
|
||||
monospace;
|
||||
color: #8aa0b8;
|
||||
}
|
||||
#sheet {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, var(--cw, 216px));
|
||||
gap: 10px;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
figure {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
width: var(--cw, 216px);
|
||||
}
|
||||
figure img {
|
||||
display: block;
|
||||
width: var(--cw, 216px);
|
||||
height: var(--ch, 288px);
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
background: #111;
|
||||
}
|
||||
figure .tm {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 52px;
|
||||
font:
|
||||
600 8.5px ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px #000;
|
||||
}
|
||||
figcaption {
|
||||
padding: 4px 2px 0;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
}
|
||||
figcaption b {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
figcaption small {
|
||||
color: #8aa0b8;
|
||||
}
|
||||
.err {
|
||||
color: #ff6a3d;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>Globe cards · contact sheet</title>
|
||||
<link rel="stylesheet" href="./contact.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="globe"></div>
|
||||
<header id="status">building situations…</header>
|
||||
<main id="sheet"></main>
|
||||
<script type="module" src="./contact.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,59 +0,0 @@
|
||||
// Contact sheet: every live situation with its composited thumbnail.
|
||||
// Open /mobile/globe/contact.html (optionally ?lat=&lon= for a viewer).
|
||||
import { createGlobe } from './index.js';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const status = document.getElementById('status');
|
||||
const sheet = document.getElementById('sheet');
|
||||
const globe = await createGlobe(document.getElementById('globe'), {
|
||||
layers: [],
|
||||
terrain: false,
|
||||
});
|
||||
window.globe = globe;
|
||||
if (params.has('lat'))
|
||||
globe.setViewer({
|
||||
lat: Number(params.get('lat')),
|
||||
lon: Number(params.get('lon')),
|
||||
});
|
||||
|
||||
const t0 = performance.now();
|
||||
const only = params.get('only');
|
||||
const all = await globe.situations();
|
||||
const list = only
|
||||
? all.filter((s) => only.split(',').some((o) => s.id.startsWith(o)))
|
||||
: all;
|
||||
const cw = Number(params.get('w') || 216);
|
||||
const ch = Math.round((cw * 4) / 3);
|
||||
document.documentElement.style.setProperty('--cw', `${cw}px`);
|
||||
document.documentElement.style.setProperty('--ch', `${ch}px`);
|
||||
window.situations = list;
|
||||
status.textContent = `${list.length} situations in ${Math.round(performance.now() - t0)} ms · rendering thumbnails…`;
|
||||
const t1 = performance.now();
|
||||
let done = 0;
|
||||
await Promise.all(
|
||||
list.map(async (s) => {
|
||||
const fig = document.createElement('figure');
|
||||
const img = document.createElement('img');
|
||||
const tm = document.createElement('div');
|
||||
tm.className = 'tm';
|
||||
tm.textContent = s.telemetry || '';
|
||||
const cap = document.createElement('figcaption');
|
||||
cap.innerHTML = `<b></b><small></small>`;
|
||||
cap.querySelector('b').textContent = s.title;
|
||||
cap.querySelector('small').textContent =
|
||||
`${s.band}/${s.archetype} · ${s.subtitle}`;
|
||||
fig.append(img, tm, cap);
|
||||
sheet.append(fig);
|
||||
try {
|
||||
img.src = await globe.thumbnail(s, { width: cw, height: ch, dpr: 2 });
|
||||
} catch (error) {
|
||||
cap.querySelector('small').className = 'err';
|
||||
cap.querySelector('small').textContent =
|
||||
`thumbnail failed: ${error.message}`;
|
||||
}
|
||||
done++;
|
||||
}),
|
||||
);
|
||||
status.textContent = `${list.length} situations · thumbnails in ${Math.round(performance.now() - t1)} ms · omitted: ${(globe.omitted?.() || []).map((o) => `${o.genre} (${o.reason})`).join('; ')}`;
|
||||
console.log('timings', JSON.stringify(window.globe.timings?.()));
|
||||
document.body.dataset.ready = '1';
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* contactsNear(): aircraft, satellites and ships around a point, for the AR
|
||||
* Look mode. Reads the shared feeds directly (not layer state), so AR works
|
||||
* whether or not those layers are on the globe, and nudges a stale feed to
|
||||
* refresh in the background instead of blocking the caller's frame.
|
||||
*/
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { satPosition } from './layers/orbit.js';
|
||||
import { haversineKm, offsetLonLat } from './geo.js';
|
||||
|
||||
const SAT_GROUPS = ['stations', 'visual'];
|
||||
|
||||
export function createContactIndex(feeds, { active }) {
|
||||
let satCache = { stamp: '', sats: [] };
|
||||
|
||||
function satrecs() {
|
||||
const stamp = SAT_GROUPS.map((g) => feeds[g].updatedAt).join(':');
|
||||
if (stamp === satCache.stamp) return satCache.sats;
|
||||
const seen = new Set();
|
||||
const sats = [];
|
||||
for (const g of SAT_GROUPS) {
|
||||
for (const e of feeds[g].data?.entries || []) {
|
||||
const id = e.line1.slice(2, 7).trim();
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
try {
|
||||
sats.push({
|
||||
id,
|
||||
name: e.name.trim(),
|
||||
satrec: twoline2satrec(e.line1, e.line2),
|
||||
});
|
||||
} catch {
|
||||
/* skip malformed */
|
||||
}
|
||||
}
|
||||
}
|
||||
satCache = { stamp, sats };
|
||||
return sats;
|
||||
}
|
||||
|
||||
function nudge(feed, maxAgeMs) {
|
||||
if (!active()) return;
|
||||
if (!feed.data || Date.now() - feed.updatedAt > maxAgeMs)
|
||||
feed.get({ maxAgeMs }).catch(() => {});
|
||||
}
|
||||
|
||||
return function contactsNear(
|
||||
lon,
|
||||
lat,
|
||||
km = 50,
|
||||
{ timeMs = Date.now() } = {},
|
||||
) {
|
||||
nudge(feeds.flights, 20_000);
|
||||
nudge(feeds.military, 30_000);
|
||||
nudge(feeds.vessels, 60_000);
|
||||
for (const g of SAT_GROUPS) nudge(feeds[g], 6 * 3600_000);
|
||||
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const now = Date.now();
|
||||
const addMover = (r, kind, title) => {
|
||||
if (seen.has(r.id)) return;
|
||||
let [rlon, rlat] = [r.lon, r.lat];
|
||||
if (r.speed && !r.onGround && r.seenAt) {
|
||||
const dt = Math.min(90, Math.max(0, (now - r.seenAt) / 1000));
|
||||
[rlon, rlat] = offsetLonLat(r.lon, r.lat, r.heading || 0, r.speed * dt);
|
||||
}
|
||||
const d = haversineKm(lat, lon, rlat, rlon);
|
||||
if (d > km) return;
|
||||
seen.add(r.id);
|
||||
out.push({
|
||||
kind,
|
||||
id: r.id,
|
||||
title,
|
||||
lon: rlon,
|
||||
lat: rlat,
|
||||
altM: r.altM ?? 0,
|
||||
headingDeg: r.heading ?? null,
|
||||
speedMps: r.speed ?? null,
|
||||
distanceKm: d,
|
||||
});
|
||||
};
|
||||
for (const r of feeds.military.data?.records || [])
|
||||
addMover(r, r.helicopter ? 'helicopter' : 'military', r.callsign);
|
||||
for (const r of feeds.flights.data?.records || [])
|
||||
addMover(r, r.helicopter ? 'helicopter' : 'aircraft', r.callsign);
|
||||
for (const r of feeds.vessels.data?.records || [])
|
||||
addMover(
|
||||
{ ...r, altM: 0, seenAt: feeds.vessels.updatedAt },
|
||||
'vessel',
|
||||
r.name,
|
||||
);
|
||||
|
||||
// Satellites: anything whose sub-point is within km plus the horizon
|
||||
// reach of its altitude is potentially overhead.
|
||||
for (const s of satrecs()) {
|
||||
const p = satPosition(s.satrec, timeMs);
|
||||
if (!p) continue;
|
||||
const reachKm = km + Math.sqrt(2 * 6371 * (p.altM / 1000));
|
||||
const d = haversineKm(lat, lon, p.lat, p.lon);
|
||||
if (d > reachKm) continue;
|
||||
const q = satPosition(s.satrec, timeMs + 10_000);
|
||||
const heading = q
|
||||
? (Math.atan2(
|
||||
(q.lon - p.lon) * Math.cos((p.lat * Math.PI) / 180),
|
||||
q.lat - p.lat,
|
||||
) *
|
||||
180) /
|
||||
Math.PI +
|
||||
360
|
||||
: 0;
|
||||
out.push({
|
||||
kind: s.id === '25544' ? 'station' : 'satellite',
|
||||
id: s.id,
|
||||
title: s.name,
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
altM: p.altM,
|
||||
headingDeg: heading % 360,
|
||||
speedMps: p.speedMps,
|
||||
distanceKm: d,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => a.distanceKm - b.distanceKm);
|
||||
};
|
||||
}
|
||||