Merge branch 'main' of https://github.com/bilawalsidhu/gods-eye-view into pr/mapillary-layer

# Conflicts:
#	CHANGELOG.md
#	README.md
This commit is contained in:
Said Turksever committed 2026-10-05 13:47:54 +01:00
commit 91d8bba4a3
75 files changed
+1599 -381

No files matched your search

+5 -1
View File
@@ -131,7 +131,7 @@ PORT=4173
# Optional: Live AIS vessels layer
# Browser fetch path. Keep same-origin so the private stream key stays server-side.
VITE_AIS_LIVE_API_URL=/api/ais-live
VITE_AIS_LIVE_API_URL=/api/vessels
# Client render cap. The server can keep a larger AISStream cache, but drawing
# every global vessel at once can overwhelm WebGL on laptops.
VITE_AIS_LIVE_MAX_ROWS=12000
@@ -201,6 +201,10 @@ AISSTREAM_API_KEY=
# CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras
# CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
# 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_URL= # override the Statens vegvesen catalog 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
+12 -2
View File
@@ -14,6 +14,17 @@
- Street Level coverage no longer floats over trees and bridges on the Google 3D map. At street zoom, sequence lines, image cones and the position marker are placed on the bare earth from the terrain service (with a 2 m lift) instead of being draped on the top of the photoreal mesh, so buildings and tree canopies hide what is behind them; other maps and higher views keep draped lines. Opening a photo no longer flies the camera underground when the Google tiles under it have not loaded yet. Near the camera the placement is refined against the rendered Google 3D surface, so lines also follow freeways in trenches and steep streets, and stay on the road under trees.
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
list is one keyless GeoJSON request to the agency's OGC API view of its
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
loads (~850; faulty ones are dropped) under one "Norway" category, and the
~135 working cameras that publish HLS play as live video through the existing in-memory
relay with the still as fallback. Frames and manifests are pinned to each
camera's own path on the agency hosts. `CCTV_VEGVESEN_ENABLED=0` disables
the pack, `CCTV_VEGVESEN_MAX_SOURCES` caps it (keeping cameras nearest the
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
under NLOD 2.0.
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
- Ship each bundled data pack once. The region, marine, admin-boundary,
@@ -438,6 +449,7 @@
Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347).
Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351).
- New Fire Perimeters layer (Events group): live NIFC WFIGS interagency
wildfire incident perimeters as ground-clamped polygons with a
containment-colored fire line, refreshed every 5 minutes from the public
@@ -714,7 +726,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Other changes
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution.
@@ -1523,4 +1534,3 @@ represent previously published GitHub Releases.
## [0.1.0] — 2026-02-09
- Initial project version.
+2
View File
@@ -56,6 +56,7 @@ How to read this:
| **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" |
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
| **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" |
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) |
| **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) |
@@ -90,6 +91,7 @@ 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`.
- **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.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
+23 -23
View File
@@ -285,28 +285,28 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
Twenty layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
| Layer | What you get | Source | Auth |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| 🚗 **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** | ~3,600 public cameras 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. 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 imagery from several providers behind one panel, modelled on the iD editor's photo overlay: a chip per provider, shared 360°/flat and captured-since filters, coverage drawn onto the streets from orbit down to a single road, image cones per sequence, and an embedded viewer with a **CAMERA FOLLOWS VIEW** mode. Mapillary ships in this build; Google Street View, KartaView and Panoramax are planned | Mapillary | 🟡 Mapillary client token |
| 📻 **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 | 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) |
| 🎖️ **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 | 🟢 |
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
| Layer | What you get | Source | Auth |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| 🚗 **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** | ~3,900 public cameras 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). 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 imagery from several providers behind one panel, modelled on the iD editor's photo overlay: a chip per provider, shared 360°/flat and captured-since filters, coverage drawn onto the streets from orbit down to a single road, image cones per sequence, and an embedded viewer with a **CAMERA FOLLOWS VIEW** mode. Mapillary ships in this build; Google Street View, KartaView and Panoramax are planned | Mapillary | 🟡 Mapillary client token |
| 📻 **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 | 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) |
| 🎖️ **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 | 🟢 |
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
@@ -439,7 +439,7 @@ Eight keys. Five have a free tier, and the three 🔴 ones are metered:
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |
| 🟡 | **Mapillary** | 📷 Street-level coverage and photos on the globe (the Street Level layer's Mapillary provider). A public client token by design (see SECURITY.md) | [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers) — register an app, copy its Client Token |
| 🟡 | **Mapillary** | 📷 Street-level coverage and photos on the globe (the Street Level layer's Mapillary provider). A public client token by design (see SECURITY.md) | [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers) — register an app, copy its Client Token |
![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif)
+2 -2
View File
@@ -18,8 +18,8 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
| Key | Where it lives | How the browser uses it |
|-----|----------------|--------------------------|
| `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships |
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache |
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` |
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
### Three deliberately client-side keys — restrict them
+10 -10
View File
@@ -1461,7 +1461,7 @@ This is the current runtime/source-of-truth snapshot for the project.
> dependency list rather than a fix for one layer, and the dependencies reach
> it by different routes:
> - **AIS vessels is its reachable producer.** `enable()`/`update()` both
> resolve as soon as the first `/api/ais-live` poll answers, so the manager's
> resolve as soon as the first `/api/vessels` poll answers, so the manager's
> lifecycle settles to `enabled` — but until the server-side socket delivers
> a position, `firstConnectPhase` stays `'loading'` and `getStats()` reports
> `loading: true`, `lastUpdate: null`, count 0, and an UNDEFINED status.
@@ -1865,7 +1865,7 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
> the share payload; Radio restores only its allowlisted filter and volume.
> - **AIS feed watchdog (2026-08-18):** feed liveness is judged by DATA, not
> socket state — AISStream can complete the handshake and then deliver
> nothing forever. `/api/ais-live` reports `live | stale | reconnecting |
> nothing forever. `/api/vessels` reports `live | stale | reconnecting |
down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> `silentForMs`, `reconnectAttempt` and `nextAttemptAt`. Silence is REPORTED
> at 120s and ACTED ON at 300s; recovery walks a 5s/15s/60s/300s ladder and
@@ -2911,15 +2911,15 @@ its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/opensky` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| 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 + 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) + 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) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
@@ -3745,7 +3745,7 @@ silently demoting every later lookup for the session.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/ais-live/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/opensky-track` (OAuth, own credit bucket) and `/api/adsblol/trace` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
### Optional Overpass configuration
@@ -3964,7 +3964,7 @@ and unreachable upstream (502/504) separately from road geometry.
### Live AIS Vessels (June 2026)
- Server-side `ws` websocket to `wss://stream.aisstream.io/v0/stream` maintained by Vite middleware; `AISSTREAM_API_KEY` never reaches the browser (AISStream has no browser CORS). The `ws` package is used rather than Node's built-in WebSocket specifically because only it can hard-abort a wedged socket (see the watchdog note in the delta block at the top).
- Browser polls same-origin `/api/ais-live` cache every 60s.
- Browser polls same-origin `/api/vessels` cache every 60s.
- The first enable in a session starts one 30-second client grace timer. Until
an accepted vessel position arrives, `live`/`open`/`connecting` transport reports
`LOADING`; the timer is not restarted by the 60-second poll. Expiry or a
@@ -4237,13 +4237,13 @@ are omitted rather than framing the wrong part of the globe.
- Proxy error payloads are sanitized (no internal error details returned to clients).
- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is.
- `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`.
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache.
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/vessels` cache.
- `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context.
- `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution.
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
- Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
+2 -2
View File
@@ -1,6 +1,6 @@
# OpenSky Auth Setup
God's Eye View uses explicit auth modes for `/api/opensky`:
God's Eye View uses explicit auth modes for `/api/flights`:
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
@@ -54,7 +54,7 @@ OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
Inspect proxy headers:
```bash
curl -si http://localhost:4173/api/opensky | grep -iE 'HTTP/|X-OpenSky-Auth'
curl -si http://localhost:4173/api/flights | grep -iE 'HTTP/|X-OpenSky-Auth'
```
Useful reasons:
+1
View File
@@ -265,6 +265,7 @@
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
"./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js"
}
+1
View File
@@ -525,6 +525,7 @@
"src/data/cctvEstonia.test.mjs",
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
+16
View File
@@ -890,6 +890,8 @@
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/cameraArea.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
@@ -1780,7 +1782,9 @@
"src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/cameraArea.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
@@ -2481,7 +2485,9 @@
"src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/cameraArea.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
@@ -2859,6 +2865,7 @@
"exports": ["./layers/vessels/ingestion"],
"modules": [
"src/layers/vessels/ingestion.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/recordPolicy.js"
],
"external": []
@@ -3284,6 +3291,15 @@
],
"external": []
},
"tools-panel": {
"exports": ["./tools/panel"],
"modules": [
"src/tools/panel.js",
"src/tools/globePanel.js",
"src/app/globePanelRuntime.js"
],
"external": []
},
"tools-services": {
"exports": ["./tools/services"],
"modules": [
+4 -4
View File
@@ -253,19 +253,19 @@ async function main() {
const url = new URL(request.url());
if (url.origin !== APP_ORIGIN) return void request.continue();
// Silence the live feeds: the injected field is the only population.
if (url.pathname === '/api/ais-live') {
if (url.pathname === '/api/vessels') {
return void request.respond(stubJson({
rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
newestPositionAt: null, lastMessageAt: null,
}));
}
if (url.pathname === '/api/adsblol/mil') {
if (url.pathname === '/api/military') {
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
}
if (url.pathname === '/api/opensky') {
if (url.pathname === '/api/flights') {
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
}
if (url.pathname === '/api/opensky-track') {
if (url.pathname === '/api/flights/track') {
return void request.respond(stubJson({ path: [] }));
}
if (url.pathname === '/api/openai/hud-summary') {
+5 -5
View File
@@ -42,7 +42,7 @@ page.on('pageerror', (error) => consoleErrors.push(error.message));
page.on('response', (response) => {
const url = new URL(response.url());
const expectedOptionalTrackMiss = response.status() === 404
&& url.pathname === '/api/opensky-track';
&& url.pathname === '/api/flights/track';
if (
url.origin === new URL(appUrl).origin
&& response.status() >= 400
@@ -108,7 +108,7 @@ try {
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/vessels') {
request.respond({
status: 200,
contentType: 'application/json',
@@ -124,7 +124,7 @@ try {
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/adsblol/mil') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/military') {
request.respond({
status: 200,
contentType: 'application/json',
@@ -132,7 +132,7 @@ try {
});
return;
}
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky-track') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights/track') {
request.respond({
status: 200,
contentType: 'application/json',
@@ -832,7 +832,7 @@ try {
await readinessPage.setRequestInterception(true);
readinessPage.on('request', (request) => {
const url = new URL(request.url());
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') {
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights') {
const now = Math.floor(Date.now() / 1000);
request.respond({
status: 200, contentType: 'application/json',
+3 -3
View File
@@ -12,7 +12,7 @@
* least 60 m above the rendered surface under it and under the view centre.
*
* Contacts: serves two synthetic aircraft through the page's own
* /api/opensky response (one flying north over Camp Mabry at 2,500 m, one
* /api/flights response (one flying north over Camp Mabry at 2,500 m, one
* near Fort Cavazos) and an empty military feed, starts from a wide Texas
* view, and presses CONTACTS as a user would. Asserts the Mapped
* installations row lists at least one site with a distance within 15 s,
@@ -110,7 +110,7 @@ async function openPage({ aircraft = false } = {}) {
} catch {
return realFetch(input, init);
}
if (url.origin === location.origin && url.pathname === '/api/opensky') {
if (url.origin === location.origin && url.pathname === '/api/flights') {
const now = Math.floor(Date.now() / 1000);
const seconds = (Date.now() - started) / 1000;
const lat = 30.29 + (seconds * 120) / 111_320;
@@ -159,7 +159,7 @@ async function openPage({ aircraft = false } = {}) {
};
return Promise.resolve(Response.json(json));
}
if (url.origin === location.origin && url.pathname === '/api/adsblol/mil')
if (url.origin === location.origin && url.pathname === '/api/military')
return Promise.resolve(
Response.json({ ac: [], now: Date.now(), total: 0 }),
);
+5 -5
View File
@@ -12,7 +12,7 @@
* honoring the politeness bounds.
*
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
* - /api/opensky → 12 straight-flying planes, category 0
* - /api/flights → 12 straight-flying planes, category 0
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
* F16, GLID, B77W, A320, B738, PC12, R44) + one
* found:false miss; responses are HELD until the
@@ -260,14 +260,14 @@ async function main() {
});
}
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/adsblol/mil')) {
if (url.includes('/api/military')) {
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] }));
}
if (url.includes('/api/opensky')) {
if (url.includes('/api/flights')) {
const states = S.planes.map((f) => {
const s = S.stateAt(f, tRel);
return [
+4 -2
View File
@@ -10,7 +10,7 @@
* upstream failures so the run is deterministic and never depends on live
* AISStream / CelesTrak availability:
*
* (i) AIS invalid key — /api/ais-live is answered with
* (i) AIS invalid key — /api/vessels is answered with
* {rows:[],status:'error',error:'invalid key'}. Assert the layer's
* getStats().error is set (feed down) — NOT a clean 'just now · 0'.
*
@@ -193,10 +193,12 @@ async function main() {
page.on('request', (req) => {
const url = req.url();
// (i) AIS invalid-key: the exact payload the spec calls for.
if (url.includes('/api/ais-live') && !url.includes('/track')) {
if (url.includes('/api/vessels') && !url.includes('/track')) {
req.respond({
status: 200,
contentType: 'application/json',
// The bundled server names its vessel provider; attribution reads it.
headers: { 'X-Feed-Source': 'AISStream' },
body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
});
return;
+2 -2
View File
@@ -89,7 +89,7 @@ await page.evaluateOnNewDocument((site, icao) => {
window.__TERRAIN.ok += 1;
return realFetch(input, init);
}
if (app && url.pathname === '/api/opensky') {
if (app && url.pathname === '/api/flights') {
const c = window.__CONTACT;
const t = Math.floor(Date.now() / 1000);
// baro_altitude (7) and geo_altitude (13) both null, on_ground true.
@@ -99,7 +99,7 @@ await page.evaluateOnNewDocument((site, icao) => {
null, true, 1.5, 90, 0, null, null, null, false, 0]],
}));
}
if (app && url.pathname === '/api/opensky-track') return Promise.resolve(json({ path: [] }));
if (app && url.pathname === '/api/flights/track') return Promise.resolve(json({ path: [] }));
if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
return realFetch(input, init);
};
+4 -4
View File
@@ -268,11 +268,11 @@ async function installSyntheticFetches(page) {
}));
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/api/opensky-track')) return json({ path: [] });
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] });
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
if (url.includes('/api/flights/track')) return json({ path: [] });
if (url.includes('/api/military/track')) return json({ timestamp: Date.now() / 1000, trace: [] });
if (url.includes('/api/flights')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
if (url.includes('/api/adsbdb/')) return json({ found: false });
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] });
if (url.includes('/api/vessels')) return json({ status: 'open', rows: [] });
return realFetch(input, init);
};
});
+4 -4
View File
@@ -525,11 +525,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (T.timeOffsetSec || 0);
const tRel = nowSec - T.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/opensky')) {
if (url.includes('/api/flights')) {
T.__hitsGuard = ++window.__TURN_HITS.opensky;
const row = (id, callsign, s, altM, category) => [
id, callsign, 'Synthetica',
@@ -548,7 +548,7 @@ async function main() {
if (T.heli65.active) states.push(row(T.heli65.icao, T.heli65.callsign, T.stateAt(T.heli65, tRel), T.heli65.altM, T.heli65.category));
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
}
if (url.includes('/api/adsblol/mil')) {
if (url.includes('/api/military')) {
window.__TURN_HITS.mil++;
const ac = T.military.map((m) => {
const s = T.stateAt(m, tRel);
+10 -10
View File
@@ -738,9 +738,9 @@ check({
});
check({
id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/opensky)',
id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/flights)',
run: async () => {
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
if (!r.ok) return fail(`HTTP ${r.status}: ${r.text.slice(0, 120)}`);
const n = r.json?.states?.length || 0;
return n > 0 ? pass(`${n} states, cache=${r.headers.get('x-opensky-cache') || 'n/a'}`) : fail('0 states returned');
@@ -750,7 +750,7 @@ check({
check({
id: 'B3', group: 'B', desc: 'OpenSky credentials are actually in use (not the anonymous/fallback path)',
run: async () => {
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
// Header names are exact: X-OpenSky-Auth-Mode-Used / X-OpenSky-Auth-Reason.
// (An earlier guess at these names made this check pass vacuously.)
const reason = r.headers.get('X-OpenSky-Auth-Reason') || '';
@@ -826,9 +826,9 @@ check({
});
check({
id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/ais-live)', needsKey: 'AIS',
id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/vessels)', needsKey: 'AIS',
run: async () => {
const r = await jget('/api/ais-live', { timeoutMs: 40000 });
const r = await jget('/api/vessels', { timeoutMs: 40000 });
const rows = r.json?.rows?.length || 0;
const status = r.json?.status;
if (!r.ok) return fail(`HTTP ${r.status} status=${status}`);
@@ -864,7 +864,7 @@ check({
const guard = keyGuard('AIS', env.keys.AIS);
if (guard) return guard;
if (env.keys.AIS === true) return skip('server HAS an AISStream key', 'N/A');
const r = await jget('/api/ais-live');
const r = await jget('/api/vessels');
return r.status === 503 && r.json?.status === 'missing-key' && Array.isArray(r.json?.rows)
? pass(`503 status=missing-key, rows=[] — "${String(r.json?.error).slice(0, 60)}"`)
: fail(`expected 503/missing-key, got ${r.status} ${r.text.slice(0, 120)}`);
@@ -1022,7 +1022,7 @@ check({
check({
id: 'B21', group: 'B', desc: 'No proxy echoes credential material back to the client (P1-5 acceptance #4)',
run: async () => {
const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/ais-live'];
const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/vessels'];
const leaked = [];
const unscannable = [];
for (const p of paths) {
@@ -1036,7 +1036,7 @@ check({
// An error page is not a payload. Scanning five 500s and finding no key
// is trivially true and proves nothing — a broken app must not satisfy a
// negative assertion. The one documented exception is the keyless
// 503 {status:'missing-key'} from /api/ais-live, which IS its real shape.
// 503 {status:'missing-key'} from /api/vessels, which IS its real shape.
const documentedKeyless = r.status === 503
&& (r.json?.status === 'missing-key' || r.json?.error === 'no_key' || /OPENAI_API_KEY is not set/.test(r.text));
if (!r.ok && !documentedKeyless) {
@@ -2071,13 +2071,13 @@ async function preflight() {
env.keys.FIRMS = await statusKey('/api/firms/status');
env.keys.TOMTOM = await statusKey('/api/tomtom/status');
try {
const ais = await jget('/api/ais-live');
const ais = await jget('/api/vessels');
if (ais.status === 503 && ais.json?.status === 'missing-key') env.keys.AIS = false;
else if (ais.status === 200 && ais.json && Array.isArray(ais.json.rows)) env.keys.AIS = true;
else env.keys.AIS = 'error';
} catch { env.keys.AIS = 'error'; }
try {
const os = await jget('/api/opensky?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 });
const os = await jget('/api/flights?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 });
const reason = os.headers.get('X-OpenSky-Auth-Reason') || '';
const used = os.headers.get('X-OpenSky-Auth-Mode-Used') || os.headers.get('X-OpenSky-Auth') || '';
if (!os.ok) env.keys.OPENSKY = 'error';
+1 -1
View File
@@ -180,7 +180,7 @@ async function installDeterministicDevEndpoints(page) {
});
return;
}
if (url.pathname === '/api/ais-live') {
if (url.pathname === '/api/vessels') {
await request.respond({
status: 200,
contentType: 'application/json',
+1 -1
View File
@@ -219,7 +219,7 @@ async function main() {
});
return;
}
if (url.origin === APP_ORIGIN && url.pathname === '/api/ais-live') {
if (url.origin === APP_ORIGIN && url.pathname === '/api/vessels') {
request.respond({
status: 200,
contentType: 'application/json',
+4 -4
View File
@@ -229,11 +229,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
const tRel = nowSec - S.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) {
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
}
if (url.includes('/api/opensky')) {
if (url.includes('/api/flights')) {
window.__SPR_HITS.opensky++;
const states = S.flights.map((f) => {
const s = S.stateAt(f, tRel);
@@ -249,7 +249,7 @@ async function main() {
});
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
}
if (url.includes('/api/adsblol/mil')) {
if (url.includes('/api/military')) {
window.__SPR_HITS.mil++;
const ac = S.military.map((m) => {
const s = S.stateAt(m, tRel);
+8 -8
View File
@@ -17,9 +17,9 @@
* `fetch` shim in the page that intercepts the poll endpoints and returns
* SYNTHETIC aircraft positioned near the camera, in the EXACT upstream payload
* shapes the layers parse:
* - flights → GET /api/opensky → { states: [ <state-vector[]> ] }
* - military → GET /api/adsblol/mil → { ac: [ <adsblol-aircraft{}> ] }
* - vessels → GET /api/ais-live → connected, zero-row snapshot
* - flights → GET /api/flights → { states: [ <state-vector[]> ] }
* - military → GET /api/military → { ac: [ <adsblol-aircraft{}> ] }
* - vessels → GET /api/vessels → connected, zero-row snapshot
* The shim stays installed so the layers' setInterval pollers keep the
* synthetic planes alive (under MISSING_POLL_LIMIT=3 so they're never pruned).
*
@@ -343,7 +343,7 @@ async function main() {
const url = new URL(requestUrl, window.location.href);
const isAppRequest = url.origin === appOrigin;
// OpenSky (commercial flights): { states: [ state-vector[] ] }
if (isAppRequest && url.pathname === '/api/opensky') {
if (isAppRequest && url.pathname === '/api/flights') {
window.__SYNTH_HITS.opensky++;
// A withheld contact models the ordinary case where the recipient's
// first authoritative refresh does not yet carry the shared aircraft.
@@ -371,11 +371,11 @@ async function main() {
// Trail backfill (fires on trackById). Stub with valid-but-empty
// payloads so the deterministic run never 404s against the dev proxy.
// flights: { path: [ [time, lat, lon, baroAlt, true_track, on_ground] ] }
if (isAppRequest && url.pathname === '/api/opensky-track') {
if (isAppRequest && url.pathname === '/api/flights/track') {
return Promise.resolve(jsonResponse({ path: [] }));
}
// military: { timestamp: <epochSec>, trace: [ [secAfter, lat, lon, ...] ] }
if (isAppRequest && url.pathname === '/api/adsblol/trace') {
if (isAppRequest && url.pathname === '/api/military/track') {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(Date.now() / 1000), trace: [] }));
}
// adsbdb enrichment (fires for tracked/model-eligible planes): empty
@@ -388,7 +388,7 @@ async function main() {
// Contacts enables the optional AIS source alongside aircraft. Keep
// this tracking harness independent of local AIS credentials while
// preserving the truthful "awaiting first message" lifecycle.
if (isAppRequest && url.pathname === '/api/ais-live') {
if (isAppRequest && url.pathname === '/api/vessels') {
return Promise.resolve(jsonResponse({
status: 'connected',
rows: [],
@@ -450,7 +450,7 @@ async function main() {
return Promise.resolve(jsonResponse({ places: [] }));
}
// adsb.lol military: { ac: [ aircraft{} ] } (must come AFTER /trace check)
if (isAppRequest && url.pathname === '/api/adsblol/mil') {
if (isAppRequest && url.pathname === '/api/military') {
window.__SYNTH_HITS.mil++;
const ac = window.__SYNTH.military.map((m) => ({
hex: m.hex,
+4 -2
View File
@@ -7,8 +7,10 @@ import {
coreTools,
catalogForSurface,
} from '../../src/tools/index.js';
import { panelRuntime } from '../../src/app/globePanelRuntime.js';
import { createGlobePanelResource } from '../../src/tools/globePanel.js';
import {
createGlobePanelResource,
panelRuntime,
} from '../../src/tools/panel.js';
import { createMcpServer } from '../../src/tools/mcp/index.js';
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
+8 -4
View File
@@ -1,7 +1,7 @@
/**
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
*
* Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. When upstream
* Serves GET /api/military from https://api.adsb.lol/v2/mil. When upstream
* fails — a thrown fetch OR a non-OK status such as 429 — the proxy serves its
* cached body as STALE instead of relaying the failure, and it backs off from
* upstream for the Retry-After period (bounded) so a rate limit is never
@@ -55,10 +55,11 @@ export function adsbLolProxy() {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-ADS-B-Cache': cacheStatus,
'X-Feed-Source': 'adsb.lol',
'X-Feed-Cache': cacheStatus,
...(['HIT', 'STALE'].includes(cacheStatus)
? {
'X-ADS-B-Cache-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
'X-Feed-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
}
: {}),
...extra,
@@ -67,8 +68,11 @@ export function adsbLolProxy() {
}
const installMiddleware = (server) => {
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
server.middlewares.use('/api/military', async (req, res, next) => {
try {
// This mount also matches its /track route, served by its own handler.
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
return next();
const now = Date.now();
if (_cache && now - _cacheAt < CACHE_MS) {
serve(res, 200, _cache, 'HIT');
+5 -1
View File
@@ -380,8 +380,12 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
*/
export function openSkyProxy() {
const installMiddleware = (server) => {
server.middlewares.use('/api/opensky', async (req, res) => {
server.middlewares.use('/api/flights', async (req, res, next) => {
try {
// This mount also matches its /track route, served by its own handler.
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
return next();
res.setHeader('X-Feed-Source', 'OpenSky Network');
const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE,
);
+4 -4
View File
@@ -3,11 +3,11 @@ import { readCappedResponseText } from '../common/http.js';
/**
* Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2).
*
* /api/opensky-track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
* /api/flights/track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
* own credit bucket, 4 credits per call on the free tier). OAuth via the
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
* client can fall back to its accumulated trail silently.
* /api/adsblol/trace?hex=<hex> — adsb.lol tar1090 readsb trace
* /api/military/track?hex=<hex> — adsb.lol tar1090 readsb trace
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
* of real history per aircraft. Treat as best-effort; data is ODbL —
* credit "adsb.lol (ODbL)" in the UI.
@@ -65,7 +65,7 @@ export function trackBackfillProxies() {
}
function install(middlewares) {
middlewares.use('/api/opensky-track', async (req, res) => {
middlewares.use('/api/flights/track', async (req, res) => {
try {
const incoming = new URL(req.url || '', 'http://localhost');
const icao24 = String(incoming.searchParams.get('icao24') || '')
@@ -93,7 +93,7 @@ export function trackBackfillProxies() {
}
});
middlewares.use('/api/adsblol/trace', async (req, res) => {
middlewares.use('/api/military/track', async (req, res) => {
try {
const incoming = new URL(req.url || '', 'http://localhost');
const hex = String(incoming.searchParams.get('hex') || '')
+6
View File
@@ -18,6 +18,7 @@ import {
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
loadVegvesenSourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
@@ -92,6 +93,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
load: loadDelDOTSourcesFromOpenData,
},
{
name: 'vegvesen',
enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'),
load: loadVegvesenSourcesFromOpenData,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
+31
View File
@@ -236,6 +236,37 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 };
* body cannot be buffered without limit. */
export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view
* of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole
* country (~900 cameras). The raw DATEX node needs an account; this view does
* not. Frames are stills on the agency's camera host. */
export const DEFAULT_VEGVESEN_CCTV_URL =
'https://ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items?f=application/geo%2Bjson&limit=5000';
/** The only origin Vegvesen camera frames may come from. */
export const VEGVESEN_IMAGE_ORIGIN =
'https://kamera.atlas.vegvesen.no/api/images/';
/** Live HLS for the ~140 cameras that publish video; only this exact
* per-camera manifest path is registered. */
export const VEGVESEN_VIDEO_URL = (cameraId) =>
`https://kamera.vegvesen.no/public/${cameraId}/manifest.m3u8`;
/** Above the ~850 working cameras so a default install keeps them all, while
* staying close to them: the per-pack defaults are meant to sum to no more
* than DEFAULT_CCTV_MAX_SOURCES. */
export const DEFAULT_VEGVESEN_MAX_SOURCES = 900;
/** Hard ceiling on the catalog body. The whole country is ~800 KB. */
export const VEGVESEN_MAX_CATALOG_BYTES = 8 * 1024 * 1024;
/** Prioritization anchors: the largest cities from south to north, so a cap
* keeps national coverage rather than just the capital region. */
export const NORWAY_ANCHORS = [
{ lat: 59.9139, lon: 10.7522 }, // Oslo
{ lat: 60.3913, lon: 5.3221 }, // Bergen
{ lat: 58.97, lon: 5.7331 }, // Stavanger
{ lat: 63.4305, lon: 10.3951 }, // Trondheim
{ lat: 58.1467, lon: 7.9956 }, // Kristiansand
{ lat: 67.2804, lon: 14.4049 }, // Bodø
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
];
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
export const DEFAULT_DELDOT_MAX_SOURCES = 300;
+11
View File
@@ -367,6 +367,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
);
}
/** Mainland Norway, with slack for the coast and the Finnmark border. */
export function isLikelyNorwayCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 57.9 &&
lat <= 71.3 &&
lon >= 4.4 &&
lon <= 31.3
);
}
export function isLikelyFinlandCoordinate(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
+162
View File
@@ -59,6 +59,12 @@ import {
DELDOT_CCTV_URL,
DEFAULT_DELDOT_MAX_SOURCES,
DELDOT_ANCHORS,
DEFAULT_VEGVESEN_CCTV_URL,
VEGVESEN_IMAGE_ORIGIN,
VEGVESEN_VIDEO_URL,
DEFAULT_VEGVESEN_MAX_SOURCES,
VEGVESEN_MAX_CATALOG_BYTES,
NORWAY_ANCHORS,
} from './constants.js';
import {
toFiniteNumber,
@@ -76,6 +82,7 @@ import {
isLikelyTexasCoordinate,
isLikelyNswCoordinate,
isLikelyCalgaryCoordinate,
isLikelyNorwayCoordinate,
cameraDisplayCode,
rowArrayToObject,
prioritizeSources,
@@ -1701,3 +1708,158 @@ export async function loadDelDOTSourcesFromOpenData() {
return [];
}
}
/**
* One Statens vegvesen `CctvSimple` feature -> one catalog source, or null.
*
* `orientationDescription` names the place the camera looks towards
* ("Svelgen"), not a bearing, so it goes into the label and the heading uses
* the shared id-hash fallback at low confidence, like Calgary and Fintraffic.
* Cameras the feed reports as faulty are dropped. Cameras that publish HLS
* get live video, with the still as the snapshot fallback.
*
* @param {object} feature - GeoJSON feature from the OGC items response.
* @returns {?object}
*/
export function vegvesenCameraToSource(feature) {
if (!feature || typeof feature !== 'object') return null;
const props = feature.properties;
if (!props || typeof props !== 'object') return null;
const availability = String(
props['status.stillImageAvailability'] ?? '',
).trim();
if (availability && availability !== 'videoOrImagesAvailable') return null;
const coordinates = feature?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
const lon = toFiniteNumber(coordinates[0]);
const lat = toFiniteNumber(coordinates[1]);
if (!isLikelyNorwayCoordinate(lat, lon)) return null;
const rawId = String(props.cameraId ?? '').trim();
if (!/^[A-Za-z0-9_-]{1,40}$/.test(rawId)) return null;
let image;
try {
image = new URL(String(props.stillImageUrl ?? ''));
} catch {
return null;
}
if (image.username || image.password) return null;
const imageUrl = image.href;
if (imageUrl !== `${VEGVESEN_IMAGE_ORIGIN}${rawId}`) return null;
const cameraId = `no-vegvesen-${rawId.toLowerCase()}`;
const place = String(props.description ?? '').trim() || `Kamera ${rawId}`;
const towards = String(props.orientationDescription ?? '').trim();
const road = String(props.roadNumber ?? '').trim();
const name = towards && towards !== place ? `${place} → ${towards}` : place;
// Live video when the camera publishes HLS on the agency's own host; the
// still stays the snapshot fallback. CCTV_VEGVESEN_VIDEO=0 keeps stills only.
const videoUrl =
String(process.env.CCTV_VEGVESEN_VIDEO || '1').trim() !== '0' &&
Number(props.videoServiceLevel) > 0 &&
String(props.videoEncodingStandard ?? '').toLowerCase() === 'hls' &&
String(props.videoUrl ?? '').trim() === VEGVESEN_VIDEO_URL(rawId)
? VEGVESEN_VIDEO_URL(rawId)
: '';
return {
id: cameraId,
name: road ? `${road} ${name}` : name,
// One country-wide category in the camera picker, like Finland; the
// place itself is already in the name.
city: 'Norway',
cityId: 'norway',
provider: 'Statens vegvesen',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
// KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat
// prior for the whole country, while cameras run from sea level to
// mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen).
// The point-height prior and the client's one-shot ground snap correct it
// where they resolve; on a stack where neither does (no 3D tiles), a pass
// camera stays hundreds of metres below the terrain, the same risk the
// Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless
// point-height API) would remove it.
groundElevationM: 150,
feedType: videoUrl ? 'hls' : 'image',
url: videoUrl || imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'vegvesen-datex',
license:
'Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen',
code: cameraDisplayCode(place.toUpperCase()),
};
}
/**
* Fetch Statens vegvesen (Norway) road cameras from the keyless OGC API
* Features view of the DATEX 3.1 CCTV table. Frames are stills on
* kamera.atlas.vegvesen.no.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadVegvesenSourcesFromOpenData() {
try {
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
const resp = await fetch(endpoint, {
headers: { Accept: 'application/geo+json,application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Vegvesen catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Vegvesen camera download failed:', resp.status);
return discard();
}
const payload = await readResponseJsonCapped(
resp,
VEGVESEN_MAX_CATALOG_BYTES,
);
const features = Array.isArray(payload?.features) ? payload.features : [];
const cameras = [];
const seen = new Set();
for (const feature of features) {
const camera = vegvesenCameraToSource(feature);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_VEGVESEN_MAX_SOURCES || DEFAULT_VEGVESEN_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
: DEFAULT_VEGVESEN_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
console.log(
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Vegvesen camera download error:',
error?.message || error,
);
return [];
}
}
+5 -4
View File
@@ -71,14 +71,15 @@ let _aisWebSocketImpl;
*/
export function aisLiveProxy() {
function install(middlewares) {
middlewares.use('/api/ais-live', async (req, res) => {
middlewares.use('/api/vessels', async (req, res) => {
try {
res.setHeader('X-Feed-Source', 'AISStream');
ensureAisStreamConnection();
const incoming = new URL(req.url || '', 'http://localhost');
// Track sub-route MUST be handled before the rows snapshot — this
// mount prefix-matches every subpath, so without this branch
// /api/ais-live/track would be silently answered with vessel rows.
// /api/vessels/track would be silently answered with vessel rows.
if (
incoming.pathname === '/track' ||
incoming.pathname.startsWith('/track/')
@@ -288,7 +289,7 @@ function aisKeyFingerprint() {
}
/**
* Drive the watchdog once. Called on every /api/ais-live request and on the
* Drive the watchdog once. Called on every /api/vessels request and on the
* background interval, so recovery does not depend on browser traffic.
*/
function ensureAisStreamConnection() {
@@ -307,7 +308,7 @@ function ensureAisStreamConnection() {
keyFingerprint: aisKeyFingerprint(),
});
}
/** Status metadata for /api/ais-live, safe to call before the first connect. */
/** Status metadata for /api/vessels, safe to call before the first connect. */
function aisStreamStatusSnapshot() {
const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null;
if (snapshot) return snapshot;
+3 -1
View File
@@ -19,6 +19,7 @@ import { describeError } from './errors.js';
export async function createApplicationScene({
requestServices,
googleApiKey,
googleTokens = null,
cesiumToken,
credits,
MapController = MapStackController,
@@ -59,11 +60,12 @@ export async function createApplicationScene({
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
googleApiKey || cesiumToken
googleApiKey || googleTokens || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
googleTokens,
cesiumToken,
});
const tileset = photoreal.tileset;
+1 -1
View File
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
courseDeg: 214.0,
shipType: 'Cargo',
destination: 'OAKLAND',
navStatus: null, // /api/ais-live does not surface NavigationalStatus
navStatus: null, // /api/vessels does not surface NavigationalStatus
});
});
+3 -3
View File
@@ -1,10 +1,10 @@
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
import { createAisStreamSource } from '../sources/live/standalone.js';
import { createVesselSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
source: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may serve
// from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
+2 -2
View File
@@ -1668,7 +1668,7 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
});
test('vessel selection passes the opaque source reference to optional history', async () => {
const { createAisStreamSource } = await import('../sources/live/standalone.js');
const { createVesselSource } = await import('../sources/live/standalone.js');
_setVesselStateForTest({ enabled: false });
const requests = [];
aisLiveVesselsLayer.setSource({
@@ -1688,6 +1688,6 @@ test('vessel selection passes the opaque source reference to optional history',
} finally {
harness.cleanup();
_setVesselStateForTest({ enabled: false });
aisLiveVesselsLayer.setSource(createAisStreamSource());
aisLiveVesselsLayer.setSource(createVesselSource());
}
});
+1 -1
View File
@@ -450,7 +450,7 @@ export function createAisWatchdog(options = {}) {
return generation;
}
/** Status metadata for /api/ais-live. */
/** Status metadata for /api/vessels. */
function snapshot() {
const monoNow = clock.mono();
const wallNow = clock.wall();
+202
View File
@@ -0,0 +1,202 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadVegvesenSourcesFromOpenData,
vegvesenCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_VEGVESEN_CCTV_URL,
VEGVESEN_IMAGE_ORIGIN,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/** One `datex_3_1:CctvSimple` feature, shaped like the live OGC payload. */
const feature = (props = {}, coordinates = [5.459189, 61.832706]) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates },
properties: {
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
cameraId: '3000063_1',
roadNumber: 'F614',
'status.stillImageAvailability': 'videoOrImagesAvailable',
orientationDescription: 'Svelgen',
description: 'Langesi',
...props,
},
});
test('a Vegvesen feature maps to a source on the pinned frame host', () => {
const source = vegvesenCameraToSource(feature());
assert.equal(source.id, 'no-vegvesen-3000063_1');
assert.equal(source.name, 'F614 Langesi → Svelgen');
assert.equal(source.city, 'Norway');
assert.equal(source.cityId, 'norway');
assert.equal(source.provider, 'Statens vegvesen');
assert.equal(source.lat, 61.832706);
assert.equal(source.lon, 5.459189);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.sourceKind, 'vegvesen-datex');
assert.equal(source.code, 'LANGESI');
assert.ok(source.url.startsWith(VEGVESEN_IMAGE_ORIGIN));
assert.equal(source.snapshotUrl, source.url);
});
test('cameras that publish HLS get live video with the still as fallback', (t) => {
const video = {
videoServiceLevel: 1,
videoEncodingStandard: 'hls',
videoUrl: 'https://kamera.vegvesen.no/public/3000063_1/manifest.m3u8',
};
const source = vegvesenCameraToSource(feature(video));
assert.equal(source.feedType, 'hls');
assert.equal(source.url, video.videoUrl);
assert.equal(
source.snapshotUrl,
'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
);
// A manifest anywhere but the camera's own path stays a still.
const offPath = vegvesenCameraToSource(
feature({ ...video, videoUrl: 'https://evil.test/x/manifest.m3u8' }),
);
assert.equal(offPath.feedType, 'image');
assert.equal(offPath.url, offPath.snapshotUrl);
// The kill switch keeps every camera on stills.
const saved = process.env.CCTV_VEGVESEN_VIDEO;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_VEGVESEN_VIDEO;
else process.env.CCTV_VEGVESEN_VIDEO = saved;
});
process.env.CCTV_VEGVESEN_VIDEO = '0';
assert.equal(vegvesenCameraToSource(feature(video)).feedType, 'image');
});
test('faulty cameras, off-host frames and bad geometry are dropped', () => {
assert.equal(
vegvesenCameraToSource(
feature({
'status.stillImageAvailability':
'videoOrImagesUnavailableDueToCameraFault',
}),
),
null,
);
assert.equal(
vegvesenCameraToSource(
feature({ stillImageUrl: 'https://evil.test/api/images/3000063_1' }),
),
null,
);
// The frame URL must be exactly the camera's own image path.
assert.equal(
vegvesenCameraToSource(
feature({
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/999_1',
}),
),
null,
);
assert.equal(vegvesenCameraToSource(feature({ cameraId: '../x' })), null);
// Copenhagen: a plausible coordinate, but outside the Norway box.
assert.equal(vegvesenCameraToSource(feature({}, [12.57, 55.68])), null);
assert.equal(vegvesenCameraToSource(feature({}, [5.4])), null);
assert.equal(vegvesenCameraToSource(null), null);
});
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const requested = [];
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json({
type: 'FeatureCollection',
features: [
feature(),
feature(),
feature(
{
cameraId: '0629001_1',
stillImageUrl:
'https://kamera.atlas.vegvesen.no/api/images/0629001_1',
description: 'Oslo S',
},
[10.752, 59.911],
),
],
});
});
const cameras = await loadVegvesenSourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
// Nearest-to-anchor first: Oslo leads.
assert.deepEqual(
cameras.map((c) => c.id),
['no-vegvesen-0629001_1', 'no-vegvesen-3000063_1'],
);
fetchMock.mock.mockImplementation(
async () => new Response('nope', { status: 503 }),
);
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
fetchMock.mock.mockImplementation(async () => {
throw new Error('offline');
});
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
});
const runCatalog = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === DEFAULT_VEGVESEN_CCTV_URL) {
return Response.json({
type: 'FeatureCollection',
features: [feature()],
});
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
const withEnv = async (patch, fn) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_VEGVESEN_ENABLED;
Object.assign(process.env, patch);
await fn();
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('the Vegvesen lane is wired into the catalog', async (t) => {
await withEnv({}, async () => {
const { requested, sources } = await runCatalog(t);
assert.ok(requested.includes(DEFAULT_VEGVESEN_CCTV_URL));
assert.deepEqual(
sources.filter((s) => s.cityId === 'norway').map((s) => s.id),
['no-vegvesen-3000063_1'],
);
});
});
test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
await withEnv({ CCTV_VEGVESEN_ENABLED: '0' }, async () => {
const { requested, sources } = await runCatalog(t);
assert.equal(requested.includes(DEFAULT_VEGVESEN_CCTV_URL), false);
assert.deepEqual(
sources.filter((s) => s.cityId === 'norway'),
[],
);
});
});
+7
View File
@@ -240,6 +240,13 @@ export const DATA_CREDITS = [
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'vegvesen-cctv',
html:
'Road cameras (Norway): contains data under the ' +
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'distributed by Statens vegvesen',
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
+2 -2
View File
@@ -1,12 +1,12 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationFlights } from '../app/layers/flights.js';
import { createOpenSkySource } from '../sources/live/standalone.js';
import { createFlightSource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js';
const flightsLayer = createApplicationFlights({
surface: defaultSurface,
source: createOpenSkySource(),
source: createFlightSource(),
militaryRegistry,
});
export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js';
+1 -1
View File
@@ -203,7 +203,7 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
const nowSec = Math.floor(Date.now() / 1000);
let openskyPoll = 0;
globalThis.fetch = async (url) => {
if (!String(url).startsWith('/api/opensky')) {
if (!String(url).startsWith('/api/flights')) {
return { ok: true, status: 200, json: async () => ({ ac: [] }) };
}
const states = openskyPoll++ === 0
+2 -3
View File
@@ -1,15 +1,14 @@
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
/**
* Create fresh datacenter and dam layers without starting or loading them.
* @param {object} services Caller-owned context, overlay and render operations.
* @returns {object[]} Datacenters then dams, with stable standalone identities.
*/
export function createInfrastructureLayers(services) {
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
const datacenters = createLocalGeoJsonLayer(
{
id: 'local-datacenters',
+1 -1
View File
@@ -476,7 +476,7 @@ test('pending mapped installations render as unknown instead of a false zero', a
// the dependencies reach it by different routes:
//
// - ais-live-vessels is its REACHABLE producer. enable()/update() both resolve
// as soon as the first /api/ais-live poll answers, so the manager's
// as soon as the first /api/vessels poll answers, so the manager's
// lifecycle settles to `enabled` — but until the server-side socket delivers
// a position, firstConnectPhase stays 'loading' and the module reports busy,
// no lastUpdate, count 0, and an UNDEFINED status, so the status list alone
+2 -2
View File
@@ -1,12 +1,12 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
import { createAdsbLolSource } from '../sources/live/standalone.js';
import { createMilitarySource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js';
const militaryFlightsLayer = createApplicationMilitary({
surface: defaultSurface,
source: createAdsbLolSource(),
source: createMilitarySource(),
militaryRegistry,
});
export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js';
+327 -102
View File
@@ -24,8 +24,12 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () =>
(await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status,
'source-unavailable',
);
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] });
const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
_setMilitaryTrackingRefreshOutcomeForTest({
status: 'accepted',
ids: ['different'],
});
const missing =
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
assert.equal(missing.status, 'missing');
assert.equal(missing.reason, 'target-absent-from-snapshot');
});
@@ -44,9 +48,9 @@ const FULL_INFO = {
operator: 'United States Air Force',
};
test('military stats identify adsb.lol as the primary feed, not a fallback', () => {
test('military stats name the feed until the server names its provider, not a fallback', () => {
const stats = militaryFlightsLayer.getStats();
assert.equal(stats.source, 'adsb.lol');
assert.equal(stats.source, 'Military aircraft');
assert.equal(stats.fallback, false);
});
@@ -81,8 +85,14 @@ test('military analyst record: military is ALWAYS true, routes/country always nu
});
test('military analyst record: no callsign falls back to registration, then icao24', () => {
assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152');
assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce');
assert.equal(
mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id,
'05-8152',
);
assert.equal(
mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id,
'ae01ce',
);
});
test('military analyst record: empty info yields nulls, never NaN/undefined', () => {
@@ -91,7 +101,8 @@ test('military analyst record: empty info yields nulls, never NaN/undefined', ()
assert.equal(r.onGround, false);
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
@@ -103,12 +114,17 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => {
test('military first update forwards caller cancellation into the feed request', async () => {
const realFetch = globalThis.fetch;
let observedSignal = null;
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
observedSignal = options.signal;
options.signal?.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
}, { once: true });
});
globalThis.fetch = (_url, options = {}) =>
new Promise((_resolve, reject) => {
observedSignal = options.signal;
options.signal?.addEventListener(
'abort',
() => {
reject(new DOMException('aborted', 'AbortError'));
},
{ once: true },
);
});
try {
const controller = new AbortController();
const work = militaryFlightsLayer.update({}, { signal: controller.signal });
@@ -138,11 +154,22 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target
json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }),
});
try {
await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} });
const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
await militaryFlightsLayer.update({
camera: { positionCartographic: null },
scene: {},
});
const resolution =
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
assert.equal(resolution.status, 'source-unavailable');
assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/);
assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved');
assert.match(
militaryFlightsLayer.getStats().error,
/Malformed adsb\.lol aircraft rows/,
);
assert.equal(
militaryFlightsLayer.getAnalystRecords().length,
1,
'warm aircraft data is preserved',
);
} finally {
globalThis.fetch = realFetch;
}
@@ -188,21 +215,26 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
ok: true,
status: 200,
json: async () => ({
ac: poll++ === 0 ? [{
hex: icao24,
lon: -96.9,
lat: 31.1,
alt_baro: 28_000,
alt_geom: 28_100,
track: 95,
gs: 400,
seen: 0,
seen_pos: 0,
flight: 'RCH451 ',
t: 'C17',
r: '05-8152',
ownOp: 'United States Air Force',
}] : [],
ac:
poll++ === 0
? [
{
hex: icao24,
lon: -96.9,
lat: 31.1,
alt_baro: 28_000,
alt_geom: 28_100,
track: 95,
gs: 400,
seen: 0,
seen_pos: 0,
flight: 'RCH451 ',
t: 'C17',
r: '05-8152',
ownOp: 'United States Air Force',
},
]
: [],
}),
});
@@ -227,7 +259,10 @@ test('cached military snapshots retain their observation time and expose stale f
_setTrackedMilitaryRefreshStateForTest({
icao24: 'ae01ce',
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false },
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000),
show: false,
},
billboardCollection: { show: false, remove() {} },
viewer,
history,
@@ -235,21 +270,48 @@ test('cached military snapshots retain their observation time and expose stale f
});
let cache = 'MISS';
t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'fetch', async () => Response.json({
ac: [{ hex: 'ae01ce', lon: -97, lat: 31, alt_baro: 28000,
track: 95, gs: 400, seen: 1, seen_pos: 2, flight: 'RCH451' }],
}, { headers: {
'X-ADS-B-Cache': cache,
...(cache === 'MISS' ? {} : { 'X-ADS-B-Cache-Age-Ms': String(now - receivedAt) }),
} }));
t.mock.method(globalThis, 'fetch', async () =>
Response.json(
{
ac: [
{
hex: 'ae01ce',
lon: -97,
lat: 31,
alt_baro: 28000,
track: 95,
gs: 400,
seen: 1,
seen_pos: 2,
flight: 'RCH451',
},
],
},
{
headers: {
'X-Feed-Cache': cache,
...(cache === 'MISS'
? {}
: { 'X-Feed-Age-Ms': String(now - receivedAt) }),
},
},
),
);
await militaryFlightsLayer.update(viewer);
assert.equal(history.length, 1);
assert.equal(history[0].epochMs, receivedAt - 2000);
for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) {
for (const [status, age] of [
['HIT', 5000],
['STALE', 45000],
]) {
cache = status;
now = receivedAt + age;
await militaryFlightsLayer.update(viewer);
assert.equal(history.length, 1, 'replayed positions cannot create a new fix');
assert.equal(
history.length,
1,
'replayed positions cannot create a new fix',
);
assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt);
assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE');
assert.equal(militaryFlightsLayer.getStats().error, null);
@@ -276,14 +338,18 @@ test('real military track path creates no native label and publishes every cache
trackedEntityChanged,
clock: { currentTime: now },
camera: {
cancelFlight() { cancelledFlights += 1; },
cancelFlight() {
cancelledFlights += 1;
},
position: new Cesium.Cartesian3(0, -10_000, 7_000),
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
direction: Cesium.Cartesian3.UNIT_Y,
transform: Cesium.Matrix4.IDENTITY,
viewMatrix: Cesium.Matrix4.IDENTITY,
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
lookAtTransform() { appliedFrames += 1; },
lookAtTransform() {
appliedFrames += 1;
},
},
scene: {
canvas: { clientWidth: 1600, clientHeight: 900 },
@@ -322,9 +388,12 @@ test('real military track path creates no native label and publishes every cache
const realFetch = globalThis.fetch;
globalThis.window = new EventTarget();
const selectionEvents = [];
globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => {
selectionEvents.push(event.detail);
});
globalThis.window.addEventListener(
'gev:awareness-subject-selected',
(event) => {
selectionEvents.push(event.detail);
},
);
globalThis.fetch = async () => ({ ok: false });
_setTrackedOverlayHostForTest(host);
try {
@@ -336,13 +405,15 @@ test('real military track path creates no native label and publishes every cache
billboardCollection: { show: true, remove() {} },
viewer,
tracked: false,
history: [{
time: now,
epochMs: Date.now(),
position,
velocity: 231.5,
track: 92.1,
}],
history: [
{
time: now,
epochMs: Date.now(),
position,
velocity: 231.5,
track: 92.1,
},
],
meta: {
...FULL_INFO,
type: 'C17',
@@ -352,34 +423,72 @@ test('real military track path creates no native label and publishes every cache
},
});
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
true,
);
const entity = viewer.trackedEntity;
assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity');
assert.ok(
entity instanceof Cesium.Entity,
'trackById must create the real Cesium entity',
);
assert.equal(entity.label, undefined);
assert.ok(entities.values.every((candidate) => candidate.label === undefined));
assert.ok(
entities.values.every((candidate) => candidate.label === undefined),
);
assert.deepEqual(entity.gevLabelModel, {
title: 'RCH451',
details: [
'C17 · 05-8152',
'United States Air Force · 28000 ft · 450 kt',
],
details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
accent: '#ffd166',
});
viewer.scene.preUpdate.raiseEvent();
const initialAppliedFrames = appliedFrames;
const initialCancelledFlights = cancelledFlights;
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent');
assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority');
assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
true,
);
assert.equal(
cancelledFlights,
initialCancelledFlights,
'ordinary repeated tracking stays camera-idempotent',
);
assert.equal(
selectionEvents.at(-1)?.origin,
'user',
'same-target selection upgrades durable authority',
);
assert.equal(entity.gevSelectionOrigin, 'user');
assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false);
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true);
assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority');
assert.equal(
militaryFlightsLayer.refocusTrackedById('different-flight'),
false,
);
assert.equal(
militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }),
true,
);
assert.equal(
selectionEvents.at(-1)?.origin,
'voice',
'same-target refocus forwards explicit authority',
);
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
viewer.scene.preUpdate.raiseEvent();
assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity');
assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner');
assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights');
assert.equal(
viewer.trackedEntity,
entity,
'refocus must retain the exact tracked entity',
);
assert.equal(
appliedFrames,
initialAppliedFrames + 1,
'repeated refocus keeps one camera-frame owner',
);
assert.equal(
cancelledFlights,
initialCancelledFlights + 2,
'only explicit refocus requests cancel camera flights',
);
const publication = publications.at(-1);
assert.equal(publication.sourceId, 'tracked');
const entry = publication.entries[0];
@@ -390,23 +499,40 @@ test('real military track path creates no native label and publishes every cache
const display = entity.position.getValue(now);
assert.ok(display, 'tracked position callback must seed its frame cache');
assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian');
assert.equal(
entry.position(),
display,
'host must reuse the exact cached display Cartesian',
);
militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late001' },
{ origin: 'share-restore' },
);
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001');
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
true,
);
assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
null,
'new user selection cancels stale restore',
);
_addMilitaryTrackingCandidateForTest({
icao24: 'late001',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000),
},
meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 },
history: [],
});
_applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24);
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
icao24,
);
militaryFlightsLayer.stopTracking();
militaryFlightsLayer.setParams(
@@ -415,15 +541,25 @@ test('real military track path creates no native label and publishes every cache
);
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002');
militaryFlightsLayer.stopTracking({ origin: 'user' });
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
null,
'explicit clear cancels stale restore',
);
_addMilitaryTrackingCandidateForTest({
icao24: 'late002',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000),
},
meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 },
history: [],
});
_applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
null,
);
militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late003' },
@@ -438,12 +574,18 @@ test('real military track path creates no native label and publishes every cache
);
_addMilitaryTrackingCandidateForTest({
icao24: 'late003',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000),
},
meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 },
history: [],
});
_applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
null,
);
militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late004' },
@@ -452,18 +594,27 @@ test('real military track path creates no native label and publishes every cache
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004');
_addMilitaryTrackingCandidateForTest({
icao24: 'late004',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000),
},
meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 },
history: [],
});
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004');
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
'late004',
);
militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late005' },
{ origin: 'local-restore' },
);
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
true,
);
assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
'late005',
@@ -471,12 +622,18 @@ test('real military track path creates no native label and publishes every cache
);
_addMilitaryTrackingCandidateForTest({
icao24: 'late005',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000),
},
meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 },
history: [],
});
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005');
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
'late005',
);
} finally {
militaryFlightsLayer.stopTracking();
viewer.scene.preUpdate.raiseEvent();
@@ -521,21 +678,47 @@ test('a callsign-less contact is findable by the tail number the app displays',
// The identity analyst_query hands back for this contact.
const analystId = mapAnalystRecord(icao24, {
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
callsign: null,
registration: '6606',
rawLat: 40.71,
rawLon: -74.01,
}).id;
assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail');
assert.equal(
analystId,
'6606',
'the analyst names a callsign-less contact by its tail',
);
const found = militaryFlightsLayer.findByQuery(analystId);
assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity');
assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too');
assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright');
assert.equal(
found?.icao24,
icao24,
'and the tracker must resolve that same identity',
);
assert.equal(
militaryFlightsLayer.findByQuery('660')?.icao24,
icao24,
'prefix match too',
);
assert.equal(
militaryFlightsLayer.findByQuery(icao24)?.icao24,
icao24,
'hex still wins outright',
);
});
test('the analyst record carries the hex key the tracker keys on', () => {
const record = mapAnalystRecord('ae7f01', {
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
callsign: null,
registration: '6606',
rawLat: 40.71,
rawLon: -74.01,
});
assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label');
assert.equal(
record.icao24,
'ae7f01',
'a resolvable key must ride along with the display label',
);
});
test('a registration never outranks another contact’s exact callsign', () => {
@@ -548,18 +731,35 @@ test('a registration never outranks another contact’s exact callsign', () => {
show: true,
});
const base = {
type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180,
track: 90, klass: 'widebody', onGround: false,
type: 'C17',
operator: 'USAF',
altitudeFt: 25000,
speedMps: 180,
track: 90,
klass: 'widebody',
onGround: false,
};
// Registered FIRST, so insertion order favours it.
_addMilitaryTrackingCandidateForTest({
icao24: 'ae0aa1',
meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 },
meta: {
...base,
callsign: null,
registration: 'ZULU777',
rawLat: 31.0,
rawLon: -97.0,
},
billboard: billboardAt(-97.0, 31.0),
});
_addMilitaryTrackingCandidateForTest({
icao24: 'ae0bb2',
meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 },
meta: {
...base,
callsign: 'ZULU777',
registration: '05-9999',
rawLat: 31.2,
rawLon: -97.2,
},
billboard: billboardAt(-97.2, 31.2),
});
@@ -582,24 +782,49 @@ test('two contacts matching at the same strength resolve deterministically', ()
show: true,
});
const base = {
type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150,
track: 45, klass: 'turboprop', onGround: false,
type: 'C130',
operator: 'USAF',
altitudeFt: 21000,
speedMps: 150,
track: 45,
klass: 'turboprop',
onGround: false,
};
// Both registrations begin with QQ7 — a same-tier prefix tie. Registered
// highest-hex first so insertion order would pick the wrong one.
_addMilitaryTrackingCandidateForTest({
icao24: 'aef002',
meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 },
meta: {
...base,
callsign: null,
registration: 'QQ7-222',
rawLat: 32.0,
rawLon: -98.0,
},
billboard: billboardAt(-98.0, 32.0),
});
_addMilitaryTrackingCandidateForTest({
icao24: 'aef001',
meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 },
meta: {
...base,
callsign: null,
registration: 'QQ7-111',
rawLat: 32.1,
rawLon: -98.1,
},
billboard: billboardAt(-98.1, 32.1),
});
const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24;
const second = militaryFlightsLayer.findByQuery('qq7')?.icao24;
assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order');
assert.equal(second, first, 'and the same query resolves the same way every time');
assert.equal(
first,
'aef001',
'the stable key (lowest hex) wins, not the feed order',
);
assert.equal(
second,
first,
'and the same query resolves the same way every time',
);
});
+2 -2
View File
@@ -1,7 +1,7 @@
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createAdsbLolSource } from '../sources/live/standalone.js';
import { createMilitarySource } from '../sources/live/standalone.js';
const registry = createMilitaryRegistry({ source: createAdsbLolSource() });
const registry = createMilitaryRegistry({ source: createMilitarySource() });
export const isMilitaryLayerActive = registry.isMilitaryLayerActive;
export const setMilitaryLayerActive = registry.setMilitaryLayerActive;
export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange;
+2 -2
View File
@@ -189,7 +189,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
const realFetch = globalThis.fetch;
const nowSec = Math.floor(Date.now() / 1000);
globalThis.fetch = async (url) => {
if (!String(url).startsWith('/api/opensky')) {
if (!String(url).startsWith('/api/flights')) {
return { ok: true, status: 200, json: async () => ({ ac: [] }) };
}
return {
@@ -309,7 +309,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
meta: { callsign: 'OLD1', altitude: 9_000, klass: 'airliner', rawLat: 30.2, rawLon: -97.7 },
});
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) => (String(url).startsWith('/api/opensky')
globalThis.fetch = async (url) => (String(url).startsWith('/api/flights')
? { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }) }
: { ok: true, status: 200, json: async () => ({ ac: [] }) });
try {
+1 -1
View File
@@ -35,7 +35,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
// setInstallationStatus is only ever called with
// loading/zoom-in/ready/stale/empty/unavailable.
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
// update() both resolve as soon as the first /api/ais-live poll answers,
// update() both resolve as soon as the first /api/vessels poll answers,
// so the lifecycle settles to `enabled` — but until the server-side socket
// delivers a position, firstConnectPhase is 'loading' and getStats()
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
+15 -10
View File
@@ -1,14 +1,19 @@
// This dataset is CC BY-NC-SA 3.0, not the project's MIT license.
// Commercial users must remove it or obtain a TeleGeography license.
// See DATA_SOURCES.md and the bundled dataset's source.json.
const cableUrl = new URL(
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
import.meta.url,
).href;
const landingPointUrl = new URL(
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
import.meta.url,
).href;
// Resolved when read, so importing this module never needs a module URL.
function cableUrl() {
return new URL(
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
import.meta.url,
).href;
}
function landingPointUrl() {
return new URL(
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
import.meta.url,
).href;
}
/** Supply GeoJSON collections without coupling the renderer to asset URLs. */
export function createBundledCableSource({
@@ -33,8 +38,8 @@ export function createBundledCableSource({
label: 'TeleGeography',
async fetch(signal) {
const [cables, landingPoints] = await Promise.all([
read(cableUrl, signal),
read(landingPointUrl, signal),
read(cableUrl(), signal),
read(landingPointUrl(), signal),
]);
return { cables, landingPoints };
},
+33
View File
@@ -0,0 +1,33 @@
import * as Cesium from 'cesium';
import { viewAreaRadiusKm } from './viewArea.js';
/**
* The area a viewer is looking at, as `{ lat, lon, radiusKm }`: centered on
* the point in the middle of the screen (or below the camera when the middle
* shows sky), sized by the camera's height. Null for a view that wants every
* vessel.
*/
export function vesselViewArea(viewer) {
const camera = viewer?.camera;
const scene = viewer?.scene;
const height = camera?.positionCartographic?.height;
const radiusKm = viewAreaRadiusKm(height);
if (radiusKm == null) return null;
let center = camera.positionCartographic;
const canvas = scene?.canvas;
if (canvas?.clientWidth && canvas?.clientHeight) {
try {
const hit = camera.pickEllipsoid(
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
scene.globe?.ellipsoid,
);
if (hit) center = Cesium.Cartographic.fromCartesian(hit);
} catch {
/* the camera position stands in */
}
}
const lat = Cesium.Math.toDegrees(center.latitude);
const lon = Cesium.Math.toDegrees(center.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { lat, lon, radiusKm };
}
+2
View File
@@ -1,6 +1,7 @@
import { createVesselState } from './state.js';
import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js';
import { vesselViewArea } from './cameraArea.js';
import { createVesselSnapshotRenderer } from './snapshotRenderer.js';
import { createRendering } from './rendering.js';
import { createSelection } from './selection.js';
@@ -36,6 +37,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
feed: vesselState.state.feed,
readSource: () => vesselState._source,
readViewer: () => vesselState.state.viewer,
readArea: vesselViewArea,
getRowLimit: parts.rendering.renderRowLimit,
readCount: () => vesselState.state.records.all.length,
applyRows: parts.snapshots.reconcileVessels,
+20 -1
View File
@@ -1,10 +1,12 @@
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
import { viewAreaMovedEnough } from './viewArea.js';
/** Own source requests and classified feed state through explicit operations. */
export function createIngestion({
feed,
readSource,
readViewer,
readArea = () => null,
getRowLimit,
readCount,
applyRows,
@@ -34,8 +36,11 @@ export function createIngestion({
AbortSignal.timeout(10000),
])
: requestController.signal;
// Ask for the vessels in view; a wide view asks for every vessel.
const area = readArea(viewer);
feed.lastArea = area;
const snapshot = await readSource().getSnapshot(
{ maxRows: getRowLimit() },
{ maxRows: getRowLimit(), ...(area ? { area } : {}) },
{ signal },
);
if (!ownsAisRequest(requestController, requestSessionId)) return;
@@ -169,6 +174,15 @@ export function createIngestion({
: new Date(record.observedAtMs).toISOString(),
};
}
/** Ask again when the view moved away from the last area asked for. */
function refreshIfMoved(viewer) {
if (!feed.enabled || feed.loading) return Promise.resolve();
const active = viewer || readViewer();
if (!viewAreaMovedEnough(feed.lastArea ?? null, readArea(active)))
return Promise.resolve();
return loadLivePositions(active);
}
const methods = {
update(viewer) {
if (!feed.enabled) return Promise.resolve();
@@ -178,6 +192,7 @@ export function createIngestion({
return {
loadLivePositions,
refreshIfMoved,
ownsAisRequest,
applyAisFeedSnapshot,
vesselDisplayRow,
@@ -209,5 +224,9 @@ export function createVesselFeed() {
firstConnectDeadline: null,
firstConnectTimer: null,
abort: null,
/** The area last asked for, null for every vessel. */
lastArea: null,
/** Stops listening for camera moves. */
removeViewListener: null,
};
}
+43
View File
@@ -104,3 +104,46 @@ test('vessel ingestion converts source units once and retains warm records on a
assert.equal(probe.feed.loading, false);
assert.equal(probe.feed.abort, null);
});
test('vessels are asked for by the area in view, and again only when the view moves away', async () => {
const asked = [];
let area = { lat: 37.8, lon: -122.4, radiusKm: 40 };
const feed = createVesselFeed();
feed.enabled = true;
feed.sessionId = 1;
const ingestion = createIngestion({
feed,
readSource: () => ({
getSnapshot: async (query) => {
asked.push(query);
return { records: [], source: 'Feed' };
},
}),
readViewer: () => ({}),
readArea: () => area,
getRowLimit: () => 500,
readCount: () => 0,
now: () => 0,
setSourceLabel: () => {},
applyRows: () => {},
classifySnapshot: () => ({
acceptedRows: [],
acceptedRowCount: 0,
rawRowCount: 0,
}),
isDefinitiveTransportFailure: () => false,
isGraceEligibleTransport: () => false,
markUnavailable: () => {},
settleFirstConnect: () => {},
});
await ingestion.methods.update();
assert.deepEqual(asked, [{ maxRows: 500, area }]);
await ingestion.refreshIfMoved();
assert.equal(asked.length, 1);
area = { ...area, lat: 38.2 };
await ingestion.refreshIfMoved();
assert.deepEqual(asked.at(-1), { maxRows: 500, area });
area = null;
await ingestion.refreshIfMoved();
assert.deepEqual(asked.at(-1), { maxRows: 500 });
});
+15
View File
@@ -196,10 +196,18 @@ export function createLifecycle({
state.records.byMmsi.has(pickedId),
);
restoreSpriteOrderOnEnable('ais', activeViewer);
// Vessels are asked for by view, so ask again when the view moves away.
removeViewListener();
const moveEnd = activeViewer?.camera?.moveEnd;
if (typeof moveEnd?.addEventListener === 'function')
state.feed.removeViewListener = moveEnd.addEventListener(() => {
components.ingestion.refreshIfMoved(activeViewer);
});
return components.ingestion.loadLivePositions(activeViewer);
},
disable() {
removeViewListener();
state.feed.enabled = false;
invalidateAisSession();
releaseContinuousRender('ais-vessels');
@@ -218,6 +226,7 @@ export function createLifecycle({
},
destroy(viewer) {
removeViewListener();
const activeViewer = viewer || state.viewer;
invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
@@ -241,6 +250,12 @@ export function createLifecycle({
},
};
function removeViewListener() {
const remove = state.feed.removeViewListener;
state.feed.removeViewListener = null;
if (typeof remove === 'function') remove();
}
return {
clearFirstConnectTimer,
invalidateAisSession,
+5 -5
View File
@@ -36,7 +36,7 @@ export function createQueries({
/**
* Chip text for a feed the server has reported as not delivering.
* @param {string} status - 'stale' | 'reconnecting' | 'down'
* @param {Object} payload - Parsed /api/ais-live JSON.
* @param {Object} payload - Parsed /api/vessels JSON.
* @returns {string}
*/
@@ -61,12 +61,12 @@ export function createQueries({
}
/**
* Derive a surfaced error string from an /api/ais-live payload, or null when the
* Derive a surfaced error string from an /api/vessels payload, or null when the
* feed has accepted product data. Socket transport, message receipt, and usable
* vessel positions are separate health stages: an open socket with no message
* or no accepted positions must not read as a fresh successful update.
*
* @param {Object|null|undefined} payload - Parsed /api/ais-live JSON.
* @param {Object|null|undefined} payload - Parsed /api/vessels JSON.
* @param {number} acceptedRowCount - Number of rows accepted by vessel normalization.
* @returns {string|null} A short reason for the chip, or null if healthy.
*/
@@ -106,7 +106,7 @@ export function createQueries({
/**
* Classify one server snapshot before any destructive reconciliation.
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload.
* @param {Object|null|undefined} payload - Parsed /api/vessels payload.
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number,
* acceptedRowCount: number, error: string|null}}
@@ -140,7 +140,7 @@ export function createQueries({
* Map one internal vessel record to a plain JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. navStatus is always null: the
* /api/ais-live proxy does not surface AIS NavigationalStatus, so it
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
* cannot be derived client-side.
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
* @returns {{id: string|null, mmsi: string|null, name: string|null,
+31
View File
@@ -0,0 +1,31 @@
/** Smallest area asked for around the view, in kilometres. */
export const VIEW_AREA_MIN_RADIUS_KM = 10;
/** Largest area asked for; a wider view asks for every vessel instead. */
export const VIEW_AREA_MAX_RADIUS_KM = 450;
/**
* The radius worth asking for from a camera this high, in kilometres: about
* the height, so the area covers what a camera looking down shows. Null when
* the view is wide enough that every vessel is wanted.
*/
export function viewAreaRadiusKm(heightM) {
if (!Number.isFinite(heightM)) return null;
const radiusKm = heightM / 1000;
if (radiusKm > VIEW_AREA_MAX_RADIUS_KM) return null;
return Math.max(VIEW_AREA_MIN_RADIUS_KM, radiusKm);
}
/**
* Whether a view moved far enough from the last area asked for to ask
* again: its radius changed by a quarter, or its center moved a quarter of
* the radius. Switching between an area and every vessel always counts.
*/
export function viewAreaMovedEnough(previous, next) {
if (!previous || !next) return previous !== next;
if (Math.abs(next.radiusKm - previous.radiusKm) >= previous.radiusKm * 0.25)
return true;
const lat = ((previous.lat + next.lat) / 2) * (Math.PI / 180);
const dLat = (next.lat - previous.lat) * 111.32;
const dLon = (next.lon - previous.lon) * 111.32 * Math.cos(lat);
return Math.hypot(dLat, dLon) >= previous.radiusKm * 0.25;
}
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
VIEW_AREA_MAX_RADIUS_KM,
VIEW_AREA_MIN_RADIUS_KM,
viewAreaMovedEnough,
viewAreaRadiusKm,
} from './viewArea.js';
test('the area asked for follows the camera height, and a wide view asks for every vessel', () => {
assert.equal(viewAreaRadiusKm(2_000), VIEW_AREA_MIN_RADIUS_KM);
assert.equal(viewAreaRadiusKm(80_000), 80);
assert.equal(
viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000),
VIEW_AREA_MAX_RADIUS_KM,
);
assert.equal(viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000 + 1), null);
assert.equal(viewAreaRadiusKm(Number.NaN), null);
});
test('a view asks again only after moving or zooming a quarter of its radius', () => {
const here = { lat: 37.8, lon: -122.4, radiusKm: 40 };
assert.equal(viewAreaMovedEnough(null, here), true);
assert.equal(viewAreaMovedEnough(here, null), true);
assert.equal(viewAreaMovedEnough(null, null), false);
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.85 }), false);
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.9 }), true);
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 45 }), false);
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 55 }), true);
});
+77 -1
View File
@@ -15,7 +15,16 @@ function fakeCesium(outcomes = []) {
return {
calls,
Ion: { defaultAccessToken: undefined },
GoogleMaps: { defaultApiKey: undefined },
GoogleMaps: {
defaultApiKey: undefined,
mapTilesApiEndpoint: 'https://tile.googleapis.com/',
getDefaultCredit: () => undefined,
},
Resource: class {
constructor(options) {
Object.assign(this, options);
}
},
async createGooglePhotorealistic3DTileset(options) {
calls.push({ options, googleKey: options.key });
return next();
@@ -29,6 +38,7 @@ function fakeCesium(outcomes = []) {
async fromUrl(resource, options) {
calls.push({
options,
resource,
ionToken: resource.accessToken,
assetId: resource.assetId,
});
@@ -140,3 +150,69 @@ test('independent source configurations never mutate shared SDK credentials', as
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
});
function tokenSource(...issued) {
const requests = [];
return {
requests,
async token(options = {}) {
requests.push(options);
return issued.shift() ?? null;
},
};
}
test('without a key, server tokens load Google 3D directly', async () => {
const tileset = { id: 'token' };
const Cesium = fakeCesium([tileset]);
const result = await loadPhotorealisticTileset(Cesium, {
googleTokens: tokenSource('short-lived'),
cesiumToken: 'ion-secret',
});
assert.equal(result.tileset, tileset);
assert.equal(result.route, 'google-token');
const { resource, options } = Cesium.calls[0];
assert.equal(
resource.url,
'https://tile.googleapis.com/v1/3dtiles/root.json',
);
assert.equal(resource.headers.Authorization, 'Bearer short-lived');
assert.equal(resource.queryParameters, undefined);
assert.equal(options.enableCollision, true);
});
test('a tile refused for an expired token retries once with a renewed one', async () => {
const Cesium = fakeCesium([{ id: 'token' }]);
const tokens = tokenSource('first', 'second');
await loadPhotorealisticTileset(Cesium, { googleTokens: tokens });
const { resource } = Cesium.calls[0];
const tile = { headers: { ...resource.headers } };
assert.equal(await resource.retryCallback(tile, { statusCode: 401 }), true);
assert.equal(tile.headers.Authorization, 'Bearer second');
assert.deepEqual(tokens.requests.at(-1), { replacing: 'first' });
assert.equal(resource.retryAttempts, 1);
assert.equal(await resource.retryCallback(tile, { statusCode: 500 }), false);
});
test('a key takes precedence over server tokens', async () => {
const Cesium = fakeCesium([{ id: 'direct' }]);
const tokens = tokenSource('short-lived');
const result = await loadPhotorealisticTileset(Cesium, {
googleApiKey: 'google-secret',
googleTokens: tokens,
});
assert.equal(result.route, 'google-direct');
assert.equal(tokens.requests.length, 0);
});
test('a server without tokens falls back to ion', async () => {
const tileset = { id: 'ion' };
const Cesium = fakeCesium([tileset]);
const result = await loadPhotorealisticTileset(Cesium, {
googleTokens: tokenSource(),
cesiumToken: 'ion-secret',
});
assert.equal(result.route, 'google-ion');
assert.equal(result.errors.length, 1);
assert.equal(Cesium.calls.length, 1);
});
+56 -10
View File
@@ -16,30 +16,43 @@ export function selectMapStartupRoute({
/**
* Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the
* direct request fails and an ion token is available, ion is the recovery path.
* configured, otherwise through Cesium ion's hosted Google asset. Direct
* access uses a browser key, or else short-lived tokens from the app's
* server (see googleTokens.js). If direct access fails and an ion token is
* available, ion is the recovery path.
*
* @param {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
* @param {{googleApiKey?: string, cesiumToken?: string, googleTokens?: {token: Function}|null}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-token'|'google-ion'|'osm', errors: Error[]}>}
*/
export async function loadPhotorealisticTileset(
Cesium,
{ googleApiKey = '', cesiumToken = '' } = {},
{ googleApiKey = '', cesiumToken = '', googleTokens = null } = {},
) {
const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken);
const errors = [];
const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
if (googleKey)
attempts.push({
route: 'google-direct',
create: () => createGoogleDirectTileset(Cesium, googleKey),
});
else if (googleTokens)
attempts.push({
route: 'google-token',
create: () => createGoogleTokenTileset(Cesium, googleTokens),
});
if (ionToken)
attempts.push({
route: 'google-ion',
create: () => createGoogleIonTileset(Cesium, ionToken),
});
for (const attempt of attempts) {
try {
const tileset = attempt.googleKey
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
: await createGoogleIonTileset(Cesium, ionToken);
const tileset = await attempt.create();
return { tileset, route: attempt.route, errors };
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
@@ -60,6 +73,39 @@ export function createGoogleDirectTileset(Cesium, key) {
);
}
/**
* Google 3D with short-lived tokens instead of a key. Every tile request
* carries the token as a header; a tile refused for an expired token gets
* the renewed one and is tried once more.
*/
export async function createGoogleTokenTileset(Cesium, tokens) {
const token = await tokens.token();
if (!token) throw new Error('Google 3D tokens are not offered');
const credit = Cesium.GoogleMaps.getDefaultCredit?.();
const resource = new Cesium.Resource({
url: `${Cesium.GoogleMaps.mapTilesApiEndpoint}v1/3dtiles/root.json`,
headers: { Authorization: `Bearer ${token}` },
credits: credit ? [credit] : undefined,
retryAttempts: 1,
async retryCallback(failed, error) {
if (![401, 403].includes(error?.statusCode)) return false;
const used = failed.headers.Authorization?.replace(/^Bearer /, '');
const renewed = await tokens.token({ replacing: used });
if (!renewed || renewed === used) return false;
failed.headers.Authorization = `Bearer ${renewed}`;
return true;
},
});
// The settings Cesium's Google helper applies to the key route.
return Cesium.Cesium3DTileset.fromUrl(resource, {
cacheBytes: 1536 * 1024 * 1024,
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
enableCollision: true,
// Tiles keep drawing their own texture while draped weather loads.
asynchronouslyLoadImagery: true,
});
}
export async function createGoogleIonTileset(
Cesium,
accessToken,
+62
View File
@@ -0,0 +1,62 @@
/**
* Short-lived Google access tokens the app's server may offer in place of a
* browser key. A server that offers them answers GET GOOGLE_TOKEN_PATH with
* `{ accessToken, expiresAt }` (expiresAt in epoch milliseconds); any other
* answer means it does not, and the app carries on without them.
*/
export const GOOGLE_TOKEN_PATH = '/api/google/tiles-token';
/** Renew a token this long before it expires. */
const RENEW_EARLY_MS = 60_000;
function parseToken(data, now) {
const accessToken = data?.accessToken;
const expiresAt = Number(data?.expiresAt);
if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
if (!Number.isFinite(expiresAt) || expiresAt <= now) return null;
return { accessToken, expiresAt };
}
/**
* A source of tokens from the app's server. `token()` resolves the current
* token, renewing it shortly before it expires; `token({ replacing })`
* renews only if `replacing` is still the current token, so many tile
* requests rejected for the same expired token renew it once. Resolves null
* when the server does not offer tokens.
*/
export function createGoogleTokenSource({
fetchImpl = (...args) => fetch(...args),
url = GOOGLE_TOKEN_PATH,
now = () => Date.now(),
} = {}) {
let current = null;
let pending = null;
async function request() {
try {
const response = await fetchImpl(url, {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!response.ok) return null;
return parseToken(await response.json(), now());
} catch {
return null;
}
}
return {
async token({ replacing } = {}) {
const usable =
current &&
current.expiresAt - RENEW_EARLY_MS > now() &&
(replacing === undefined || current.accessToken !== replacing);
if (usable) return current.accessToken;
pending ??= request().finally(() => {
pending = null;
});
current = await pending;
return current?.accessToken ?? null;
},
};
}
+64
View File
@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGoogleTokenSource, GOOGLE_TOKEN_PATH } from './googleTokens.js';
function server(answers) {
const calls = [];
return {
calls,
fetchImpl: async (url) => {
calls.push(url);
const answer = answers.shift();
if (answer instanceof Error) throw answer;
return answer;
},
};
}
const offer = (accessToken, expiresAt) =>
Response.json({ accessToken, expiresAt });
test('a token is reused until shortly before it expires', async () => {
let now = 1_000_000;
const { calls, fetchImpl } = server([
offer('first', now + 900_000),
offer('second', now + 1_800_000),
]);
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
assert.equal(await tokens.token(), 'first');
assert.equal(await tokens.token(), 'first');
assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]);
now += 850_000;
assert.equal(await tokens.token(), 'second');
assert.equal(calls.length, 2);
});
test('requests refused for the same token renew it once', async () => {
const now = 1_000_000;
const { calls, fetchImpl } = server([
offer('first', now + 900_000),
offer('second', now + 900_000),
]);
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
await tokens.token();
const renewed = await Promise.all([
tokens.token({ replacing: 'first' }),
tokens.token({ replacing: 'first' }),
]);
assert.deepEqual(renewed, ['second', 'second']);
assert.equal(await tokens.token({ replacing: 'first' }), 'second');
assert.equal(calls.length, 2);
});
test('a server that does not offer tokens yields none', async () => {
for (const answer of [
new Response('Not found', { status: 404 }),
new Response('<!doctype html>', { status: 200 }),
Response.json({ accessToken: '', expiresAt: Date.now() + 60_000 }),
Response.json({ accessToken: 'stale', expiresAt: 1 }),
new TypeError('offline'),
]) {
const { fetchImpl } = server([answer]);
assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null);
}
});
+12 -9
View File
@@ -3,16 +3,19 @@
* data files, line-delimited GeoJSON parsing and analyst records.
*/
// Resolved by Vite in builds and relative to this module in other consumers.
// Resolved by Vite in builds and relative to this module in other consumers,
// when read, so importing this module never needs a module URL.
export const INFRASTRUCTURE_DATA_URLS = Object.freeze({
'local-datacenters': new URL(
'../data/local_data/datacenters/datacenters.geojsonl',
import.meta.url,
).href,
'local-dams': new URL(
'../data/local_data/dams/dams.geojsonl',
import.meta.url,
).href,
get 'local-datacenters'() {
return new URL(
'../data/local_data/datacenters/datacenters.geojsonl',
import.meta.url,
).href;
},
get 'local-dams'() {
return new URL('../data/local_data/dams/dams.geojsonl', import.meta.url)
.href;
},
});
/** Parse GeoJSON Lines: one Feature per non-empty line. */
+18 -18
View File
@@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createOpenSkySource,
createAdsbLolSource,
createAisStreamSource,
createFlightSource,
createMilitarySource,
createVesselSource,
normalizeReadsbAircraft,
normalizeVesselObservation,
openSkySnapshot,
@@ -133,7 +133,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
const requests = [];
const fetchImpl = async (url, init) => {
requests.push({ url, init });
if (url.startsWith('/api/opensky?'))
if (url.startsWith('/api/flights?'))
return response(
{ time: now / 1000, states: [aircraft] },
{
@@ -141,27 +141,27 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
'x-flight-coverage': '250 nm regional',
},
);
if (url.startsWith('/api/opensky-track'))
if (url.startsWith('/api/flights/track'))
return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
if (url === '/api/adsblol/mil')
if (url === '/api/military')
return response(
{ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] },
{ 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' },
{ 'x-feed-age-ms': '60000', 'x-feed-cache': 'STALE' },
);
return response({
timestamp: now / 1000 - 60,
trace: [[10, 30, -97, 1000]],
});
};
const civil = createOpenSkySource({ fetchImpl, now: () => now });
const military = createAdsbLolSource({ fetchImpl, now: () => now });
const civil = createFlightSource({ fetchImpl, now: () => now });
const military = createMilitarySource({ fetchImpl, now: () => now });
assert.equal(requests.length, 0);
const signal = new AbortController().signal;
const snapshot = await civil.getSnapshot(
{ latitude: 30.123456, longitude: -97 },
{ signal },
);
assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000');
assert.equal(requests[0].url, '/api/flights?lat=30.1235&lon=-97.0000');
assert.equal(requests[0].init.signal, signal);
assert.equal(snapshot.source, 'adsb.lol');
assert.equal(snapshot.coverage, '250 nm regional');
@@ -180,7 +180,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
let release;
const controller = new AbortController();
const source = createOpenSkySource({
const source = createFlightSource({
fetchImpl: async () => ({
ok: true,
json: () =>
@@ -199,7 +199,7 @@ test('cancellation after slow body parsing rejects even with a transport that ig
test('denials and outages never start another source and do not echo arbitrary response bodies', async () => {
for (const status of [401, 403, 429, 502]) {
let calls = 0;
const source = createOpenSkySource({
const source = createFlightSource({
fetchImpl: async () => {
calls++;
return response({ error: 'sensitive upstream detail' }, {}, status);
@@ -216,12 +216,12 @@ test('denials and outages never start another source and do not echo arbitrary r
});
test('AIS reports limited received coverage and keeps connection state separate from positions', async () => {
const source = createAisStreamSource({
const source = createVesselSource({
origin: () => 'http://example.test',
fetchImpl: async (url) => {
if (url.includes('/track?'))
return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] });
assert.equal(url, 'http://example.test/api/ais-live?maxRows=500');
assert.equal(url, 'http://example.test/api/vessels?maxRows=500');
return response({
status: 'reconnecting',
refreshing: true,
@@ -249,7 +249,7 @@ test('AIS reports limited received coverage and keeps connection state separate
});
test('a malformed vessel row cannot prevent admission of valid positions', async () => {
const source = createAisStreamSource({
const source = createVesselSource({
fetchImpl: async () =>
response({
status: 'live',
@@ -273,7 +273,7 @@ test('out-of-range source epochs do not reach Date or globe time constructors',
});
test('classification identities include positionless aircraft without admitting them to rendering', async () => {
const source = createAdsbLolSource({
const source = createMilitarySource({
fetchImpl: async () =>
response({
ac: [
@@ -293,12 +293,12 @@ test('classification identities include positionless aircraft without admitting
});
test('identity lookup validates its response and honors body-parse cancellation', async () => {
const malformed = createAdsbLolSource({
const malformed = createMilitarySource({
fetchImpl: async () => response({ ac: [{}] }),
});
await assert.rejects(malformed.getIdentities(), /Malformed/);
const abort = new AbortController();
const source = createAdsbLolSource({
const source = createMilitarySource({
fetchImpl: async () => ({
ok: true,
status: 200,
+3 -3
View File
@@ -13,7 +13,7 @@ export {
vesselSnapshot,
} from './vessels.js';
export {
createOpenSkySource,
createAdsbLolSource,
createAisStreamSource,
createFlightSource,
createMilitarySource,
createVesselSource,
} from './standalone.js';
+62 -33
View File
@@ -17,7 +17,7 @@ const defaultFetch = (...args) => globalThis.fetch(...args);
const header = (response, name) => response.headers?.get?.(name);
function openSkyError(response) {
const error = httpError(response, 'OpenSky');
const error = httpError(response, 'Flights');
const mode = String(
header(response, 'x-opensky-auth-mode-used') ||
header(response, 'x-opensky-auth') ||
@@ -49,13 +49,24 @@ function openSkyError(response) {
return error;
}
/** Existing same-origin aircraft routes; no request starts during construction. */
export function createOpenSkySource({
/**
* The provider a feed response names in `X-Feed-Source`, so a server that
* answers a feed route from another provider is reported as that provider.
*/
function feedSource(response, fallback) {
const named = header(response, 'x-feed-source');
return typeof named === 'string' && named.trim()
? named.trim().slice(0, 80)
: fallback;
}
/** The flights feed (/api/flights); no request starts during construction. */
export function createFlightSource({
fetchImpl = defaultFetch,
now = () => Date.now(),
} = {}) {
return {
label: 'OpenSky Network',
label: 'Flights',
async getSnapshot(query = {}, { signal } = {}) {
const params = new URLSearchParams();
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
@@ -64,14 +75,17 @@ export function createOpenSkySource({
}
const { response, payload } = await readResponse(
fetchImpl,
`/api/opensky${params.size ? '?' + params : ''}`,
`/api/flights${params.size ? '?' + params : ''}`,
{ signal },
'OpenSky',
'Flights',
);
if (!response.ok) throw openSkyError(response);
return {
...openSkySnapshot(payload, {
source: header(response, 'x-flight-source') || 'OpenSky Network',
// A fallback provider names itself in X-Flight-Source.
source:
header(response, 'x-flight-source') ||
feedSource(response, 'Flights'),
coverage:
header(response, 'x-flight-coverage') ||
'worldwide upstream snapshot',
@@ -83,11 +97,11 @@ export function createOpenSkySource({
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/opensky-track?icao24=' + encodeURIComponent(reference),
'/api/flights/track?icao24=' + encodeURIComponent(reference),
{ signal },
'OpenSky',
'Flights',
);
if (!response.ok) throw httpError(response, 'OpenSky');
if (!response.ok) throw httpError(response, 'Flights');
return {
records: normalizeAircraftTrack(payload?.path),
complete: false,
@@ -108,36 +122,37 @@ export function createOpenSkySource({
};
}
export function createAdsbLolSource({
export function createMilitarySource({
fetchImpl = defaultFetch,
now = () => Date.now(),
} = {}) {
return {
label: 'adsb.lol',
label: 'Military aircraft',
async getIdentities(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/mil',
'/api/military',
{ signal },
'adsb.lol',
'Military aircraft',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
if (!response.ok) throw httpError(response, 'Military aircraft');
return readsbIdentities(payload);
},
async getSnapshot(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/mil',
'/api/military',
{ signal },
'adsb.lol',
'Military aircraft',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
const age = finite(header(response, 'x-ads-b-cache-age-ms'));
if (!response.ok) throw httpError(response, 'Military aircraft');
const age = finite(header(response, 'x-feed-age-ms'));
return {
...readsbSnapshot(payload, {
source: feedSource(response, 'Military aircraft'),
observedAtMs: now() - (age != null && age > 0 ? age : 0),
now: now(),
stale: header(response, 'x-ads-b-cache') === 'STALE',
stale: header(response, 'x-feed-cache') === 'STALE',
}),
status: response.status,
};
@@ -145,11 +160,11 @@ export function createAdsbLolSource({
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/trace?hex=' + encodeURIComponent(reference),
'/api/military/track?hex=' + encodeURIComponent(reference),
{ signal },
'adsb.lol',
'Military aircraft',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
if (!response.ok) throw httpError(response, 'Military aircraft');
const baseTimeMs = epoch(payload?.timestamp, 1000);
return {
records:
@@ -165,24 +180,35 @@ export function createAdsbLolSource({
};
}
export function createAisStreamSource({
export function createVesselSource({
fetchImpl = defaultFetch,
apiUrl = '/api/ais-live',
apiUrl = '/api/vessels',
origin = () => globalThis.location?.origin || 'http://localhost',
} = {}) {
return {
label: 'AISStream',
async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) {
label: 'Vessels',
async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) {
const url = new URL(apiUrl, origin());
url.searchParams.set('maxRows', String(maxRows));
// An area asks for the vessels around a point; servers that hold every
// vessel may ignore it, since callers still keep only what they need.
if (
Number.isFinite(area?.lat) &&
Number.isFinite(area?.lon) &&
Number.isFinite(area?.radiusKm)
) {
url.searchParams.set('lat', area.lat.toFixed(5));
url.searchParams.set('lon', area.lon.toFixed(5));
url.searchParams.set('radius_km', area.radiusKm.toFixed(1));
}
const { response, payload } = await readResponse(
fetchImpl,
url.toString(),
{ signal, cache: 'no-store' },
'AIS live',
'Vessels',
);
if (!response.ok) {
const error = httpError(response, 'AIS live');
const error = httpError(response, 'Vessels');
const reasons = {
'missing-key': 'AISSTREAM_API_KEY not set',
'auth-failed': 'API key rejected — check AISSTREAM_API_KEY',
@@ -193,16 +219,19 @@ export function createAisStreamSource({
error.message = reasons[payload?.status] || error.message;
throw error;
}
return { ...vesselSnapshot(payload), status: response.status };
return {
...vesselSnapshot(payload, { source: feedSource(response, 'Vessels') }),
status: response.status,
};
},
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/ais-live/track?mmsi=' + encodeURIComponent(reference),
'/api/vessels/track?mmsi=' + encodeURIComponent(reference),
{ signal },
'AIS live',
'Vessels',
);
if (!response.ok) throw httpError(response, 'AIS live');
if (!response.ok) throw httpError(response, 'Vessels');
return {
records: normalizeVesselTrack(payload?.samples),
complete: false,
+4
View File
@@ -6,6 +6,7 @@ import { createStandaloneScene } from './scene.js';
import { createStandaloneControls } from './controls.js';
import { createStandaloneData } from './data.js';
import { createStandaloneTools } from './tools.js';
import { createGoogleTokenSource } from '../maps/googleTokens.js';
// The existing controls and layer catalog contain page-scoped state.
let constructed = false;
@@ -39,6 +40,9 @@ export function createStandaloneApplication({
const scene = await createStandaloneScene({
...context,
googleApiKey,
// Without a key, Google 3D can still load with tokens from the
// app's server when it offers them.
googleTokens: googleApiKey ? null : createGoogleTokenSource(),
cesiumToken,
loaderStatus,
});
+7 -7
View File
@@ -1,8 +1,8 @@
import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
import {
createOpenSkySource,
createAdsbLolSource,
createAisStreamSource,
createFlightSource,
createMilitarySource,
createVesselSource,
} from '../sources/live/standalone.js';
import { createCctvSource } from '../layers/cctv/source.js';
import { createRadioSource } from '../layers/radio/source.js';
@@ -26,10 +26,10 @@ export function createStandaloneLayerSources() {
const mapTiles = createOpenFreeMapSource();
return {
...createReferenceSources(),
flights: createOpenSkySource(),
military: createAdsbLolSource(),
vessels: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
flights: createFlightSource(),
military: createMilitarySource(),
vessels: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may
// serve from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
+34 -34
View File
@@ -93,11 +93,11 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
});
const states = install(providers.openSkyProxy());
assert.equal(
(await states('/api/opensky', '?lat=30&lon=-97')).statusCode,
(await states('/api/flights', '?lat=30&lon=-97')).statusCode,
200,
);
assert.equal(
(await states('/api/opensky', '?lat=30&lon=-97')).headers[
(await states('/api/flights', '?lat=30&lon=-97')).headers[
'x-opensky-cache'
],
'HIT',
@@ -105,14 +105,14 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
assert.equal(calls.length, 2);
const tracks = install(providers.trackBackfillProxies(), true);
assert.equal(
(await tracks('/api/opensky-track', '?icao24=ABC123')).statusCode,
(await tracks('/api/flights/track', '?icao24=ABC123')).statusCode,
200,
);
await tracks('/api/opensky-track', '?icao24=abc123');
await tracks('/api/flights/track', '?icao24=abc123');
assert.equal(calls.filter((call) => call.url.includes('/token')).length, 1);
assert.equal(calls.filter((call) => call.url.includes('/tracks/')).length, 1);
assert.equal(
(await tracks('/api/adsblol/trace', '?hex=invalid')).statusCode,
(await tracks('/api/military/track', '?hex=invalid')).statusCode,
400,
);
now += 130_000;
@@ -131,7 +131,7 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
);
process.env.OPENSKY_AUTH_MODE = 'anon';
const fallback = await install(fresh.openSkyProxy())(
'/api/opensky',
'/api/flights',
'?lat=30&lon=-97',
);
assert.equal(fallback.statusCode, 200);
@@ -174,7 +174,7 @@ test('OpenSky global snapshot attempts are time-limited, retried once, then fall
`../../server/providers/aircraft/opensky.js?timeout=${Math.random()}`
);
const response = await install(fresh.openSkyProxy())(
'/api/opensky',
'/api/flights',
'?lat=30&lon=-97',
);
return { response, attempts };
@@ -203,11 +203,11 @@ test('military aircraft route preserves fresh cache and stale response after ups
return Response.json({ ac: [{ hex: 'abc123' }] });
});
const request = install(providers.adsbLolProxy());
const first = await request('/api/adsblol/mil');
assert.equal((await request('/api/adsblol/mil')).body, first.body);
const first = await request('/api/military');
assert.equal((await request('/api/military')).body, first.body);
assert.equal(calls, 1);
now += 13_000;
assert.equal((await request('/api/adsblol/mil')).body, first.body);
assert.equal((await request('/api/military')).body, first.body);
assert.equal(calls, 2);
});
@@ -261,7 +261,7 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
const request = install(plugin, true);
let res;
for (let i = 0; i < 100; i++) {
res = await request('/api/ais-live');
res = await request('/api/vessels');
if (JSON.parse(res.body).rows.length) break;
await delay(10);
}
@@ -269,10 +269,10 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
assert.equal(data.status, 'live');
assert.equal(data.rows[0].mmsi, '123456789');
assert.equal(data.rows[0].heading, null);
const history = await request('/api/ais-live', '/track?mmsi=123456789');
const history = await request('/api/vessels', '/track?mmsi=123456789');
assert.equal(JSON.parse(history.body).samples.length, 2);
assert.equal(
(await request('/api/ais-live', '/track?mmsi=bad')).statusCode,
(await request('/api/vessels', '/track?mmsi=bad')).statusCode,
400,
);
assert.equal(sockets.length, 1);
@@ -280,7 +280,7 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10);
assert.equal(sockets[0].readyState, 3);
const restarted = install(plugin);
await restarted('/api/ais-live');
await restarted('/api/vessels');
for (let i = 0; i < 100 && sockets.length < 2; i++) await delay(10);
assert.equal(sockets.length, 2);
});
@@ -299,10 +299,10 @@ test('military aircraft route serves stale cache on an upstream 429 and cools do
});
});
const request = install(providers.adsbLolProxy());
const first = await request('/api/adsblol/mil');
const first = await request('/api/military');
assert.equal(first.statusCode, 200);
now += 13_000;
const limited = await request('/api/adsblol/mil');
const limited = await request('/api/military');
assert.equal(calls, 2);
assert.equal(
limited.statusCode,
@@ -310,16 +310,16 @@ test('military aircraft route serves stale cache on an upstream 429 and cools do
'a 429 with a cached body is never relayed',
);
assert.equal(limited.body, first.body);
assert.equal(limited.headers['x-ads-b-cache'], 'STALE');
assert.equal(limited.headers['x-feed-cache'], 'STALE');
assert.equal(limited.headers['x-ads-b-upstream-status'], '429');
assert.equal(limited.headers['x-ads-b-cache-age-ms'], '13000');
assert.equal(limited.headers['x-feed-age-ms'], '13000');
now += 5_000;
const cooling = await request('/api/adsblol/mil');
const cooling = await request('/api/military');
assert.equal(calls, 2, 'no upstream call inside the Retry-After window');
assert.equal(cooling.headers['x-ads-b-cache'], 'STALE');
assert.equal(cooling.headers['x-ads-b-cache-age-ms'], '18000');
assert.equal(cooling.headers['x-feed-cache'], 'STALE');
assert.equal(cooling.headers['x-feed-age-ms'], '18000');
now += 16_000;
await request('/api/adsblol/mil');
await request('/api/military');
assert.equal(calls, 3, 'upstream is retried once Retry-After elapses');
});
@@ -331,10 +331,10 @@ test('military aircraft route relays an upstream 429 when nothing is cached', as
return new Response('{"error":"rate limited"}', { status: 429 });
});
const request = install(providers.adsbLolProxy());
const limited = await request('/api/adsblol/mil');
const limited = await request('/api/military');
assert.equal(limited.statusCode, 429);
assert.ok(limited.headers['retry-after']);
const again = await request('/api/adsblol/mil');
const again = await request('/api/military');
assert.equal(again.statusCode, 429);
assert.equal(
calls,
@@ -369,21 +369,21 @@ test('military fallback cancels a stalled 5xx body and starts cooldown at receip
);
});
const request = install(providers.adsbLolProxy());
await request('/api/adsblol/mil');
await request('/api/military');
now += 1000;
const hit = await request('/api/adsblol/mil');
assert.equal(hit.headers['x-ads-b-cache-age-ms'], '1000');
const hit = await request('/api/military');
assert.equal(hit.headers['x-feed-age-ms'], '1000');
now += 12000;
const fallback = await request('/api/adsblol/mil');
const fallback = await request('/api/military');
assert.equal(fallback.statusCode, 200);
assert.equal(cancelled, true);
assert.equal(fallback.headers['x-ads-b-cache-age-ms'], '23000');
assert.equal(fallback.headers['x-feed-age-ms'], '23000');
assert.equal(fallback.headers['x-ads-b-retry-after-seconds'], '20');
now += 19000;
await request('/api/adsblol/mil');
await request('/api/military');
assert.equal(calls, 2);
now += 2000;
await request('/api/adsblol/mil');
await request('/api/military');
assert.equal(calls, 3);
});
@@ -403,7 +403,7 @@ test('military cooldown bounds untrusted Retry-After and defaults server errors'
headers: { 'Retry-After': raw },
}),
);
const result = await install(providers.adsbLolProxy())('/api/adsblol/mil');
const result = await install(providers.adsbLolProxy())('/api/military');
assert.equal(Number(result.headers['retry-after']), seconds);
}
});
@@ -423,7 +423,7 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
};
});
const res1 = await tracks('/api/opensky-track', '?icao24=def456');
const res1 = await tracks('/api/flights/track', '?icao24=def456');
assert.equal(res1.statusCode, 502);
assert.deepEqual(JSON.parse(res1.body), {
error: 'Upstream track response too large',
@@ -431,7 +431,7 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
// Cached as a 502 (never a 200): a retry inside the window neither
// reads as an empty track nor spends OpenSky credits on another download.
const res2 = await tracks('/api/opensky-track', '?icao24=def456');
const res2 = await tracks('/api/flights/track', '?icao24=def456');
assert.equal(res2.statusCode, 502);
assert.equal(callCount, 1);
});
+2 -2
View File
@@ -97,8 +97,8 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
try {
for (const [route, status] of [
['/api/opensky', 200],
['/api/adsblol/mil', 200],
['/api/flights', 200],
['/api/military', 200],
['/api/adsbdb/type/invalid', 400],
['/api/firms/status', 200],
['/api/fire-perimeters/inciweb/publication/invalid', 400],
+11
View File
@@ -122,3 +122,14 @@ test('a view that only follows an aircraft is framed where the aircraft is', asy
/not reported now/.test(error.message),
);
});
test('the package exports the panel resource and its runtime together', async () => {
const panel = await import('gods-eye-view/tools/panel');
const resource = panel.createGlobePanelResource({
runtime: panel.panelRuntime,
panelKey: 'key',
});
assert.equal(resource.uri, panel.GLOBE_PANEL_URI);
assert.equal(panel.PANEL_BASE, '/panel/');
assert.match(resource.text, /panelRuntime|function/);
});
+12
View File
@@ -0,0 +1,12 @@
/**
* The MCP Apps globe panel for MCP servers that compose Core's tools: the
* panel resource and the script its page runs. A server serves the panel
* build at PANEL_BASE and passes the same key to the resource and to the
* tool services (see src/tools/services.js).
*/
export {
GLOBE_PANEL_URI,
PANEL_BASE,
createGlobePanelResource,
} from './globePanel.js';
export { panelRuntime } from '../app/globePanelRuntime.js';
+2 -2
View File
@@ -373,10 +373,10 @@ test('aircraft searches say when a feed that answered is stale', async () => {
});
test('the real OpenSky source marks an hour-old snapshot stale for searches', async () => {
const { createOpenSkySource } =
const { createFlightSource } =
await import('../../sources/live/standalone.js');
const now = Date.UTC(2026, 0, 1, 12);
const opensky = createOpenSkySource({
const opensky = createFlightSource({
now: () => now,
fetchImpl: async () =>
Response.json({ time: now / 1000 - 3600, states: [] }),
+24 -6
View File
@@ -6,6 +6,7 @@ import {
AREA_SCHEMA,
areaCenter,
areaContains,
areaRadiusKm,
distanceKm,
resolveArea,
} from '../area.js';
@@ -48,12 +49,29 @@ function vesselRow(record, from) {
}
/**
* Every vessel the source retains. The source's default is the newest few
* thousand, which can leave out a vessel the server still holds; asking for
* more than any server keeps lets the server return all of them.
* Every vessel the source retains, or with `area`, every vessel around it.
* The source's default is the newest few thousand, which can leave out a
* vessel the server still holds; asking for more than any server keeps lets
* the server return all of them. The area lets a server answer for that
* place alone; callers still keep only the vessels inside it.
*/
function readVessels(services, signal) {
return services.vessels.getSnapshot({ maxRows: ALL_VESSELS }, { signal });
function readVessels(services, signal, area) {
const center = area ? areaCenter(area) : null;
return services.vessels.getSnapshot(
{
maxRows: ALL_VESSELS,
...(center
? {
area: {
lat: center.lat,
lon: center.lon,
radiusKm: areaRadiusKm(area),
},
}
: {}),
},
{ signal },
);
}
function snapshotInfo(snapshot) {
@@ -90,7 +108,7 @@ export const vesselsInArea = defineTool({
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const center = areaCenter(area);
const snapshot = await readVessels(services, signal);
const snapshot = await readVessels(services, signal, area);
const wanted = args.type?.toLowerCase();
const rows = snapshot.records
.filter(
+25 -2
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { LiveSourceError } from '../../sources/live/contract.js';
import { createAisStreamSource } from '../../sources/live/standalone.js';
import { createVesselSource } from '../../sources/live/standalone.js';
import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.js';
const vessel = (id, latitude, longitude, extra = {}) => ({
@@ -184,7 +184,7 @@ test('a missing AIS key surfaces as an unavailable feed', async () => {
test('vessel searches ask for every vessel the server retains', async () => {
const requested = [];
const source = createAisStreamSource({
const source = createVesselSource({
origin: () => 'http://localhost',
fetchImpl: async (url) => {
requested.push(new URL(url).searchParams.get('maxRows'));
@@ -201,3 +201,26 @@ test('vessel searches ask for every vessel the server retains', async () => {
for (const maxRows of requested)
assert.ok(Number(maxRows) >= AISSTREAM_CACHE_MAX, maxRows);
});
test('an area search names its area to the server; a vessel lookup asks for every vessel', async () => {
const requested = [];
const source = createVesselSource({
origin: () => 'http://localhost',
fetchImpl: async (url) => {
requested.push(new URL(url).searchParams);
return Response.json({ rows: [], source: 'AISStream', status: 'live' });
},
});
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: source },
});
await catalog.call('vessels_in_area', { area: bay });
await catalog.call('find_vessel', { mmsi: '366999712' });
const [area, lookup] = requested;
assert.equal(area.get('lat'), '37.78000');
assert.equal(area.get('lon'), '-122.40000');
assert.equal(area.get('radius_km'), '20.0');
assert.equal(lookup.has('lat'), false);
assert.equal(lookup.has('radius_km'), false);
});
+6 -6
View File
@@ -31,9 +31,9 @@ import { createApplicationRequestServices } from '../services/requests.js';
import { createEventPackSource } from '../sources/eventPacks.js';
import { createInfrastructureSource } from '../sources/infrastructureData.js';
import {
createAdsbLolSource,
createAisStreamSource,
createOpenSkySource,
createMilitarySource,
createVesselSource,
createFlightSource,
} from '../sources/live/standalone.js';
import {
createGeocodePlaceService,
@@ -51,9 +51,9 @@ export function createToolServices({ fetchImpl, appUrl, panelKey }) {
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
fires: createFirmsSource({ fetchImpl }),
launches: createLaunchSource({ fetchImpl }),
aircraft: createOpenSkySource({ fetchImpl }),
military: createAdsbLolSource({ fetchImpl }),
vessels: createAisStreamSource({
aircraft: createFlightSource({ fetchImpl }),
military: createMilitarySource({ fetchImpl }),
vessels: createVesselSource({
fetchImpl,
origin: () => new URL(appUrl).origin,
}),