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
+1550 -332

No files matched your search

+5 -1
View File
@@ -131,7 +131,7 @@ PORT=4173
# Optional: Live AIS vessels layer # Optional: Live AIS vessels layer
# Browser fetch path. Keep same-origin so the private stream key stays server-side. # 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 # Client render cap. The server can keep a larger AISStream cache, but drawing
# every global vessel at once can overwhelm WebGL on laptops. # every global vessel at once can overwhelm WebGL on laptops.
VITE_AIS_LIVE_MAX_ROWS=12000 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_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_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # 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_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
+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. - 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) ## [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, - 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). 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). 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 - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency
wildfire incident perimeters as ground-clamped polygons with a wildfire incident perimeters as ground-clamped polygons with a
containment-colored fire line, refreshed every 5 minutes from the public 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 ### 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 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. - 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 ## [0.1.0] — 2026-02-09
- Initial project version. - 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" | | **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" | | **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" | | **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` | | **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) | | **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) | | **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`. - **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`. - **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`. - **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. - **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. - **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. - **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.
+2 -2
View File
@@ -286,7 +286,7 @@ _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.) 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 | | 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 | | 🗺️ **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) | | ✈️ **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 | 🟢 | | 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
@@ -294,7 +294,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🛰️ **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 | 🟢 | | 🌍 **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 | | 🚗 **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 | 🟢 | | 📹 **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) | 🟢 | | 📷 **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 | | 📷 **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 | 🟢 | | 📻 **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 | 🟢 |
+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 | | 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 | | `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 | | `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/opensky` | | 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 | | `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 ### 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 > dependency list rather than a fix for one layer, and the dependencies reach
> it by different routes: > it by different routes:
> - **AIS vessels is its reachable producer.** `enable()`/`update()` both > - **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 > lifecycle settles to `enabled` — but until the server-side socket delivers
> a position, `firstConnectPhase` stays `'loading'` and `getStats()` reports > a position, `firstConnectPhase` stays `'loading'` and `getStats()` reports
> `loading: true`, `lastUpdate: null`, count 0, and an UNDEFINED status. > `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. > the share payload; Radio restores only its allowlisted filter and volume.
> - **AIS feed watchdog (2026-08-18):** feed liveness is judged by DATA, not > - **AIS feed watchdog (2026-08-18):** feed liveness is judged by DATA, not
> socket state — AISStream can complete the handshake and then deliver > 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 down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> `silentForMs`, `reconnectAttempt` and `nextAttemptAt`. Silence is REPORTED > `silentForMs`, `reconnectAttempt` and `nextAttemptAt`. Silence is REPORTED
> at 120s and ACTED ON at 300s; recovery walks a 5s/15s/60s/300s ladder and > 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 | | Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/opensky` (OAuth + fallback) | 30s | | Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s | | Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | | Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | | Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | | Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + 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 | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | 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. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. Close selected-pad views add one static 500 m-radius cyan launch-zone 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. - **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. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
### Optional Overpass configuration ### Optional Overpass configuration
@@ -3964,7 +3964,7 @@ and unreachable upstream (502/504) separately from road geometry.
### Live AIS Vessels (June 2026) ### 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). - 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 - The first enable in a session starts one 30-second client grace timer. Until
an accepted vessel position arrives, `live`/`open`/`connecting` transport reports an accepted vessel position arrives, `live`/`open`/`connecting` transport reports
`LOADING`; the timer is not restarted by the 60-second poll. Expiry or a `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). - 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. - 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`. - `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/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/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/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/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. - `/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. - 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. - `/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 # 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=oauth` (default, recommended)
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback) - `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
@@ -54,7 +54,7 @@ OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
Inspect proxy headers: Inspect proxy headers:
```bash ```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: Useful reasons:
+1
View File
@@ -265,6 +265,7 @@
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js", "./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
"./tools": "./src/tools/index.js", "./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js", "./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js", "./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js" "./view": "./src/view/index.js"
} }
+1
View File
@@ -525,6 +525,7 @@
"src/data/cctvEstonia.test.mjs", "src/data/cctvEstonia.test.mjs",
"src/data/cctvWarendorf.test.mjs", "src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs", "src/data/cctvCalgary.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvNswSource.test.mjs", "src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs", "src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs", "src/layers/cctv/source.test.mjs",
+16
View File
@@ -890,6 +890,8 @@
"src/layers/vessels/evidence.js", "src/layers/vessels/evidence.js",
"src/layers/vessels/index.js", "src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js", "src/layers/vessels/ingestion.js",
"src/layers/vessels/cameraArea.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/lifecycle.js", "src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js", "src/layers/vessels/policy.js",
"src/layers/vessels/queries.js", "src/layers/vessels/queries.js",
@@ -1780,7 +1782,9 @@
"src/layers/vessels/cards.js", "src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js", "src/layers/vessels/evidence.js",
"src/layers/vessels/index.js", "src/layers/vessels/index.js",
"src/layers/vessels/cameraArea.js",
"src/layers/vessels/ingestion.js", "src/layers/vessels/ingestion.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/lifecycle.js", "src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js", "src/layers/vessels/policy.js",
"src/layers/vessels/queries.js", "src/layers/vessels/queries.js",
@@ -2481,7 +2485,9 @@
"src/layers/vessels/cards.js", "src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js", "src/layers/vessels/evidence.js",
"src/layers/vessels/index.js", "src/layers/vessels/index.js",
"src/layers/vessels/cameraArea.js",
"src/layers/vessels/ingestion.js", "src/layers/vessels/ingestion.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/lifecycle.js", "src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js", "src/layers/vessels/policy.js",
"src/layers/vessels/queries.js", "src/layers/vessels/queries.js",
@@ -2859,6 +2865,7 @@
"exports": ["./layers/vessels/ingestion"], "exports": ["./layers/vessels/ingestion"],
"modules": [ "modules": [
"src/layers/vessels/ingestion.js", "src/layers/vessels/ingestion.js",
"src/layers/vessels/viewArea.js",
"src/layers/vessels/recordPolicy.js" "src/layers/vessels/recordPolicy.js"
], ],
"external": [] "external": []
@@ -3284,6 +3291,15 @@
], ],
"external": [] "external": []
}, },
"tools-panel": {
"exports": ["./tools/panel"],
"modules": [
"src/tools/panel.js",
"src/tools/globePanel.js",
"src/app/globePanelRuntime.js"
],
"external": []
},
"tools-services": { "tools-services": {
"exports": ["./tools/services"], "exports": ["./tools/services"],
"modules": [ "modules": [
+4 -4
View File
@@ -253,19 +253,19 @@ async function main() {
const url = new URL(request.url()); const url = new URL(request.url());
if (url.origin !== APP_ORIGIN) return void request.continue(); if (url.origin !== APP_ORIGIN) return void request.continue();
// Silence the live feeds: the injected field is the only population. // 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({ return void request.respond(stubJson({
rows: [], source: 'QA', status: 'open', error: null, refreshing: false, rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
newestPositionAt: null, lastMessageAt: null, 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: [] })); 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: [] })); 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: [] })); return void request.respond(stubJson({ path: [] }));
} }
if (url.pathname === '/api/openai/hud-summary') { 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) => { page.on('response', (response) => {
const url = new URL(response.url()); const url = new URL(response.url());
const expectedOptionalTrackMiss = response.status() === 404 const expectedOptionalTrackMiss = response.status() === 404
&& url.pathname === '/api/opensky-track'; && url.pathname === '/api/flights/track';
if ( if (
url.origin === new URL(appUrl).origin url.origin === new URL(appUrl).origin
&& response.status() >= 400 && response.status() >= 400
@@ -108,7 +108,7 @@ try {
}); });
return; 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({ request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
@@ -124,7 +124,7 @@ try {
}); });
return; 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({ request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
@@ -132,7 +132,7 @@ try {
}); });
return; 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({ request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
@@ -832,7 +832,7 @@ try {
await readinessPage.setRequestInterception(true); await readinessPage.setRequestInterception(true);
readinessPage.on('request', (request) => { readinessPage.on('request', (request) => {
const url = new URL(request.url()); 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); const now = Math.floor(Date.now() / 1000);
request.respond({ request.respond({
status: 200, contentType: 'application/json', 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. * least 60 m above the rendered surface under it and under the view centre.
* *
* Contacts: serves two synthetic aircraft through the page's own * 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 * near Fort Cavazos) and an empty military feed, starts from a wide Texas
* view, and presses CONTACTS as a user would. Asserts the Mapped * view, and presses CONTACTS as a user would. Asserts the Mapped
* installations row lists at least one site with a distance within 15 s, * installations row lists at least one site with a distance within 15 s,
@@ -110,7 +110,7 @@ async function openPage({ aircraft = false } = {}) {
} catch { } catch {
return realFetch(input, init); 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 now = Math.floor(Date.now() / 1000);
const seconds = (Date.now() - started) / 1000; const seconds = (Date.now() - started) / 1000;
const lat = 30.29 + (seconds * 120) / 111_320; const lat = 30.29 + (seconds * 120) / 111_320;
@@ -159,7 +159,7 @@ async function openPage({ aircraft = false } = {}) {
}; };
return Promise.resolve(Response.json(json)); 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( return Promise.resolve(
Response.json({ ac: [], now: Date.now(), total: 0 }), Response.json({ ac: [], now: Date.now(), total: 0 }),
); );
+5 -5
View File
@@ -12,7 +12,7 @@
* honoring the politeness bounds. * honoring the politeness bounds.
* *
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs): * 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, * - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
* F16, GLID, B77W, A320, B738, PC12, R44) + one * F16, GLID, B77W, A320, B738, PC12, R44) + one
* found:false miss; responses are HELD until the * 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/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] })); if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) { if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] })); 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: [] })); 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 states = S.planes.map((f) => {
const s = S.stateAt(f, tRel); const s = S.stateAt(f, tRel);
return [ return [
+4 -2
View File
@@ -10,7 +10,7 @@
* upstream failures so the run is deterministic and never depends on live * upstream failures so the run is deterministic and never depends on live
* AISStream / CelesTrak availability: * 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 * {rows:[],status:'error',error:'invalid key'}. Assert the layer's
* getStats().error is set (feed down) — NOT a clean 'just now · 0'. * getStats().error is set (feed down) — NOT a clean 'just now · 0'.
* *
@@ -193,10 +193,12 @@ async function main() {
page.on('request', (req) => { page.on('request', (req) => {
const url = req.url(); const url = req.url();
// (i) AIS invalid-key: the exact payload the spec calls for. // (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({ req.respond({
status: 200, status: 200,
contentType: 'application/json', 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' }), body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
}); });
return; return;
+2 -2
View File
@@ -89,7 +89,7 @@ await page.evaluateOnNewDocument((site, icao) => {
window.__TERRAIN.ok += 1; window.__TERRAIN.ok += 1;
return realFetch(input, init); return realFetch(input, init);
} }
if (app && url.pathname === '/api/opensky') { if (app && url.pathname === '/api/flights') {
const c = window.__CONTACT; const c = window.__CONTACT;
const t = Math.floor(Date.now() / 1000); const t = Math.floor(Date.now() / 1000);
// baro_altitude (7) and geo_altitude (13) both null, on_ground true. // 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]], 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({})); if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
return realFetch(input, init); return realFetch(input, init);
}; };
+4 -4
View File
@@ -268,11 +268,11 @@ async function installSyntheticFetches(page) {
})); }));
window.fetch = (input, init) => { window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || ''; const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/api/opensky-track')) return json({ path: [] }); if (url.includes('/api/flights/track')) return json({ path: [] });
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] }); if (url.includes('/api/military/track')) 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')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
if (url.includes('/api/adsbdb/')) return json({ found: false }); 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); return realFetch(input, init);
}; };
}); });
+4 -4
View File
@@ -525,11 +525,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (T.timeOffsetSec || 0); const nowSec = Date.now() / 1000 + (T.timeOffsetSec || 0);
const tRel = nowSec - T.epochMs / 1000; const tRel = nowSec - T.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] })); if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) { if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] })); 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; T.__hitsGuard = ++window.__TURN_HITS.opensky;
const row = (id, callsign, s, altM, category) => [ const row = (id, callsign, s, altM, category) => [
id, callsign, 'Synthetica', 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)); 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 })); return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
} }
if (url.includes('/api/adsblol/mil')) { if (url.includes('/api/military')) {
window.__TURN_HITS.mil++; window.__TURN_HITS.mil++;
const ac = T.military.map((m) => { const ac = T.military.map((m) => {
const s = T.stateAt(m, tRel); const s = T.stateAt(m, tRel);
+10 -10
View File
@@ -738,9 +738,9 @@ check({
}); });
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 () => { 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)}`); if (!r.ok) return fail(`HTTP ${r.status}: ${r.text.slice(0, 120)}`);
const n = r.json?.states?.length || 0; 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'); 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({ check({
id: 'B3', group: 'B', desc: 'OpenSky credentials are actually in use (not the anonymous/fallback path)', id: 'B3', group: 'B', desc: 'OpenSky credentials are actually in use (not the anonymous/fallback path)',
run: async () => { 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. // Header names are exact: X-OpenSky-Auth-Mode-Used / X-OpenSky-Auth-Reason.
// (An earlier guess at these names made this check pass vacuously.) // (An earlier guess at these names made this check pass vacuously.)
const reason = r.headers.get('X-OpenSky-Auth-Reason') || ''; const reason = r.headers.get('X-OpenSky-Auth-Reason') || '';
@@ -826,9 +826,9 @@ check({
}); });
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 () => { 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 rows = r.json?.rows?.length || 0;
const status = r.json?.status; const status = r.json?.status;
if (!r.ok) return fail(`HTTP ${r.status} status=${status}`); if (!r.ok) return fail(`HTTP ${r.status} status=${status}`);
@@ -864,7 +864,7 @@ check({
const guard = keyGuard('AIS', env.keys.AIS); const guard = keyGuard('AIS', env.keys.AIS);
if (guard) return guard; if (guard) return guard;
if (env.keys.AIS === true) return skip('server HAS an AISStream key', 'N/A'); 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) 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)}"`) ? 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)}`); : fail(`expected 503/missing-key, got ${r.status} ${r.text.slice(0, 120)}`);
@@ -1022,7 +1022,7 @@ check({
check({ check({
id: 'B21', group: 'B', desc: 'No proxy echoes credential material back to the client (P1-5 acceptance #4)', id: 'B21', group: 'B', desc: 'No proxy echoes credential material back to the client (P1-5 acceptance #4)',
run: async () => { 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 leaked = [];
const unscannable = []; const unscannable = [];
for (const p of paths) { for (const p of paths) {
@@ -1036,7 +1036,7 @@ check({
// An error page is not a payload. Scanning five 500s and finding no key // 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 // is trivially true and proves nothing — a broken app must not satisfy a
// negative assertion. The one documented exception is the keyless // 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 const documentedKeyless = r.status === 503
&& (r.json?.status === 'missing-key' || r.json?.error === 'no_key' || /OPENAI_API_KEY is not set/.test(r.text)); && (r.json?.status === 'missing-key' || r.json?.error === 'no_key' || /OPENAI_API_KEY is not set/.test(r.text));
if (!r.ok && !documentedKeyless) { if (!r.ok && !documentedKeyless) {
@@ -2071,13 +2071,13 @@ async function preflight() {
env.keys.FIRMS = await statusKey('/api/firms/status'); env.keys.FIRMS = await statusKey('/api/firms/status');
env.keys.TOMTOM = await statusKey('/api/tomtom/status'); env.keys.TOMTOM = await statusKey('/api/tomtom/status');
try { 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; 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 if (ais.status === 200 && ais.json && Array.isArray(ais.json.rows)) env.keys.AIS = true;
else env.keys.AIS = 'error'; else env.keys.AIS = 'error';
} catch { env.keys.AIS = 'error'; } } catch { env.keys.AIS = 'error'; }
try { 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 reason = os.headers.get('X-OpenSky-Auth-Reason') || '';
const used = os.headers.get('X-OpenSky-Auth-Mode-Used') || os.headers.get('X-OpenSky-Auth') || ''; const used = os.headers.get('X-OpenSky-Auth-Mode-Used') || os.headers.get('X-OpenSky-Auth') || '';
if (!os.ok) env.keys.OPENSKY = 'error'; if (!os.ok) env.keys.OPENSKY = 'error';
+1 -1
View File
@@ -180,7 +180,7 @@ async function installDeterministicDevEndpoints(page) {
}); });
return; return;
} }
if (url.pathname === '/api/ais-live') { if (url.pathname === '/api/vessels') {
await request.respond({ await request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
+1 -1
View File
@@ -219,7 +219,7 @@ async function main() {
}); });
return; return;
} }
if (url.origin === APP_ORIGIN && url.pathname === '/api/ais-live') { if (url.origin === APP_ORIGIN && url.pathname === '/api/vessels') {
request.respond({ request.respond({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
+4 -4
View File
@@ -229,11 +229,11 @@ async function main() {
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0); const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
const tRel = nowSec - S.epochMs / 1000; const tRel = nowSec - S.epochMs / 1000;
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] })); if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
if (url.includes('/api/adsblol/trace')) { if (url.includes('/api/military/track')) {
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] })); return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
} }
if (url.includes('/api/opensky')) { if (url.includes('/api/flights')) {
window.__SPR_HITS.opensky++; window.__SPR_HITS.opensky++;
const states = S.flights.map((f) => { const states = S.flights.map((f) => {
const s = S.stateAt(f, tRel); const s = S.stateAt(f, tRel);
@@ -249,7 +249,7 @@ async function main() {
}); });
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states })); return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
} }
if (url.includes('/api/adsblol/mil')) { if (url.includes('/api/military')) {
window.__SPR_HITS.mil++; window.__SPR_HITS.mil++;
const ac = S.military.map((m) => { const ac = S.military.map((m) => {
const s = S.stateAt(m, tRel); 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 * `fetch` shim in the page that intercepts the poll endpoints and returns
* SYNTHETIC aircraft positioned near the camera, in the EXACT upstream payload * SYNTHETIC aircraft positioned near the camera, in the EXACT upstream payload
* shapes the layers parse: * shapes the layers parse:
* - flights → GET /api/opensky → { states: [ <state-vector[]> ] } * - flights → GET /api/flights → { states: [ <state-vector[]> ] }
* - military → GET /api/adsblol/mil → { ac: [ <adsblol-aircraft{}> ] } * - military → GET /api/military → { ac: [ <adsblol-aircraft{}> ] }
* - vessels → GET /api/ais-live → connected, zero-row snapshot * - vessels → GET /api/vessels → connected, zero-row snapshot
* The shim stays installed so the layers' setInterval pollers keep the * The shim stays installed so the layers' setInterval pollers keep the
* synthetic planes alive (under MISSING_POLL_LIMIT=3 so they're never pruned). * 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 url = new URL(requestUrl, window.location.href);
const isAppRequest = url.origin === appOrigin; const isAppRequest = url.origin === appOrigin;
// OpenSky (commercial flights): { states: [ state-vector[] ] } // OpenSky (commercial flights): { states: [ state-vector[] ] }
if (isAppRequest && url.pathname === '/api/opensky') { if (isAppRequest && url.pathname === '/api/flights') {
window.__SYNTH_HITS.opensky++; window.__SYNTH_HITS.opensky++;
// A withheld contact models the ordinary case where the recipient's // A withheld contact models the ordinary case where the recipient's
// first authoritative refresh does not yet carry the shared aircraft. // 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 // Trail backfill (fires on trackById). Stub with valid-but-empty
// payloads so the deterministic run never 404s against the dev proxy. // payloads so the deterministic run never 404s against the dev proxy.
// flights: { path: [ [time, lat, lon, baroAlt, true_track, on_ground] ] } // 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: [] })); return Promise.resolve(jsonResponse({ path: [] }));
} }
// military: { timestamp: <epochSec>, trace: [ [secAfter, lat, lon, ...] ] } // 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: [] })); return Promise.resolve(jsonResponse({ timestamp: Math.floor(Date.now() / 1000), trace: [] }));
} }
// adsbdb enrichment (fires for tracked/model-eligible planes): empty // 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 // Contacts enables the optional AIS source alongside aircraft. Keep
// this tracking harness independent of local AIS credentials while // this tracking harness independent of local AIS credentials while
// preserving the truthful "awaiting first message" lifecycle. // preserving the truthful "awaiting first message" lifecycle.
if (isAppRequest && url.pathname === '/api/ais-live') { if (isAppRequest && url.pathname === '/api/vessels') {
return Promise.resolve(jsonResponse({ return Promise.resolve(jsonResponse({
status: 'connected', status: 'connected',
rows: [], rows: [],
@@ -450,7 +450,7 @@ async function main() {
return Promise.resolve(jsonResponse({ places: [] })); return Promise.resolve(jsonResponse({ places: [] }));
} }
// adsb.lol military: { ac: [ aircraft{} ] } (must come AFTER /trace check) // 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++; window.__SYNTH_HITS.mil++;
const ac = window.__SYNTH.military.map((m) => ({ const ac = window.__SYNTH.military.map((m) => ({
hex: m.hex, hex: m.hex,
+4 -2
View File
@@ -7,8 +7,10 @@ import {
coreTools, coreTools,
catalogForSurface, catalogForSurface,
} from '../../src/tools/index.js'; } from '../../src/tools/index.js';
import { panelRuntime } from '../../src/app/globePanelRuntime.js'; import {
import { createGlobePanelResource } from '../../src/tools/globePanel.js'; createGlobePanelResource,
panelRuntime,
} from '../../src/tools/panel.js';
import { createMcpServer } from '../../src/tools/mcp/index.js'; import { createMcpServer } from '../../src/tools/mcp/index.js';
import { DEFAULT_API_BASE, createLocalToolServices } from './services.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. * 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 * 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 * 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 * upstream for the Retry-After period (bounded) so a rate limit is never
@@ -55,10 +55,11 @@ export function adsbLolProxy() {
res.writeHead(status, { res.writeHead(status, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
'X-ADS-B-Cache': cacheStatus, 'X-Feed-Source': 'adsb.lol',
'X-Feed-Cache': cacheStatus,
...(['HIT', 'STALE'].includes(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, ...extra,
@@ -67,8 +68,11 @@ export function adsbLolProxy() {
} }
const installMiddleware = (server) => { const installMiddleware = (server) => {
server.middlewares.use('/api/adsblol/mil', async (req, res) => { server.middlewares.use('/api/military', async (req, res, next) => {
try { 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(); const now = Date.now();
if (_cache && now - _cacheAt < CACHE_MS) { if (_cache && now - _cacheAt < CACHE_MS) {
serve(res, 200, _cache, 'HIT'); serve(res, 200, _cache, 'HIT');
+5 -1
View File
@@ -380,8 +380,12 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
*/ */
export function openSkyProxy() { export function openSkyProxy() {
const installMiddleware = (server) => { const installMiddleware = (server) => {
server.middlewares.use('/api/opensky', async (req, res) => { server.middlewares.use('/api/flights', async (req, res, next) => {
try { 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( const requestedMode = normalizeOpenSkyAuthMode(
process.env.OPENSKY_AUTH_MODE, 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). * 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 * 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 * shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
* client can fall back to its accumulated trail silently. * 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 * (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
* of real history per aircraft. Treat as best-effort; data is ODbL — * of real history per aircraft. Treat as best-effort; data is ODbL —
* credit "adsb.lol (ODbL)" in the UI. * credit "adsb.lol (ODbL)" in the UI.
@@ -65,7 +65,7 @@ export function trackBackfillProxies() {
} }
function install(middlewares) { function install(middlewares) {
middlewares.use('/api/opensky-track', async (req, res) => { middlewares.use('/api/flights/track', async (req, res) => {
try { try {
const incoming = new URL(req.url || '', 'http://localhost'); const incoming = new URL(req.url || '', 'http://localhost');
const icao24 = String(incoming.searchParams.get('icao24') || '') 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 { try {
const incoming = new URL(req.url || '', 'http://localhost'); const incoming = new URL(req.url || '', 'http://localhost');
const hex = String(incoming.searchParams.get('hex') || '') const hex = String(incoming.searchParams.get('hex') || '')
+6
View File
@@ -18,6 +18,7 @@ import {
loadNswSourcesFromOpenData, loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData, loadCalgarySourcesFromOpenData,
loadDelDOTSourcesFromOpenData, loadDelDOTSourcesFromOpenData,
loadVegvesenSourcesFromOpenData,
} from './sources.js'; } from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */ /** Env kill switch: unset or anything but "0" means enabled. */
@@ -92,6 +93,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'), enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
load: loadDelDOTSourcesFromOpenData, 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). * 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. */ * body cannot be buffered without limit. */
export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024; 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). */ /** 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 DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
export const DEFAULT_DELDOT_MAX_SOURCES = 300; 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) { export function isLikelyFinlandCoordinate(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32; return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
+162
View File
@@ -59,6 +59,12 @@ import {
DELDOT_CCTV_URL, DELDOT_CCTV_URL,
DEFAULT_DELDOT_MAX_SOURCES, DEFAULT_DELDOT_MAX_SOURCES,
DELDOT_ANCHORS, 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'; } from './constants.js';
import { import {
toFiniteNumber, toFiniteNumber,
@@ -76,6 +82,7 @@ import {
isLikelyTexasCoordinate, isLikelyTexasCoordinate,
isLikelyNswCoordinate, isLikelyNswCoordinate,
isLikelyCalgaryCoordinate, isLikelyCalgaryCoordinate,
isLikelyNorwayCoordinate,
cameraDisplayCode, cameraDisplayCode,
rowArrayToObject, rowArrayToObject,
prioritizeSources, prioritizeSources,
@@ -1701,3 +1708,158 @@ export async function loadDelDOTSourcesFromOpenData() {
return []; 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() { export function aisLiveProxy() {
function install(middlewares) { function install(middlewares) {
middlewares.use('/api/ais-live', async (req, res) => { middlewares.use('/api/vessels', async (req, res) => {
try { try {
res.setHeader('X-Feed-Source', 'AISStream');
ensureAisStreamConnection(); ensureAisStreamConnection();
const incoming = new URL(req.url || '', 'http://localhost'); const incoming = new URL(req.url || '', 'http://localhost');
// Track sub-route MUST be handled before the rows snapshot — this // Track sub-route MUST be handled before the rows snapshot — this
// mount prefix-matches every subpath, so without this branch // 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 ( if (
incoming.pathname === '/track' || incoming.pathname === '/track' ||
incoming.pathname.startsWith('/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. * background interval, so recovery does not depend on browser traffic.
*/ */
function ensureAisStreamConnection() { function ensureAisStreamConnection() {
@@ -307,7 +308,7 @@ function ensureAisStreamConnection() {
keyFingerprint: aisKeyFingerprint(), 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() { function aisStreamStatusSnapshot() {
const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null; const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null;
if (snapshot) return snapshot; if (snapshot) return snapshot;
+3 -1
View File
@@ -19,6 +19,7 @@ import { describeError } from './errors.js';
export async function createApplicationScene({ export async function createApplicationScene({
requestServices, requestServices,
googleApiKey, googleApiKey,
googleTokens = null,
cesiumToken, cesiumToken,
credits, credits,
MapController = MapStackController, MapController = MapStackController,
@@ -59,11 +60,12 @@ export async function createApplicationScene({
registerDataCredits(viewer, credits); registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
loaderStatus.textContent = loaderStatus.textContent =
googleApiKey || cesiumToken googleApiKey || googleTokens || cesiumToken
? 'Loading Google 3D Tiles...' ? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...'; : 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, { const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey, googleApiKey,
googleTokens,
cesiumToken, cesiumToken,
}); });
const tileset = photoreal.tileset; 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, courseDeg: 214.0,
shipType: 'Cargo', shipType: 'Cargo',
destination: 'OAKLAND', 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 { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
import { createAisStreamSource } from '../sources/live/standalone.js'; import { createVesselSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({ const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({ source: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may serve // Resolved against the document's address, which a panel host may serve
// from its own scheme. // from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost', 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 () => { 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 }); _setVesselStateForTest({ enabled: false });
const requests = []; const requests = [];
aisLiveVesselsLayer.setSource({ aisLiveVesselsLayer.setSource({
@@ -1688,6 +1688,6 @@ test('vessel selection passes the opaque source reference to optional history',
} finally { } finally {
harness.cleanup(); harness.cleanup();
_setVesselStateForTest({ enabled: false }); _setVesselStateForTest({ enabled: false });
aisLiveVesselsLayer.setSource(createAisStreamSource()); aisLiveVesselsLayer.setSource(createVesselSource());
} }
}); });
+1 -1
View File
@@ -450,7 +450,7 @@ export function createAisWatchdog(options = {}) {
return generation; return generation;
} }
/** Status metadata for /api/ais-live. */ /** Status metadata for /api/vessels. */
function snapshot() { function snapshot() {
const monoNow = clock.mono(); const monoNow = clock.mono();
const wallNow = clock.wall(); 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 ' + '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>', '<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', key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
+2 -2
View File
@@ -1,12 +1,12 @@
import { defaultSurface } from './surfaceServices.js'; import { defaultSurface } from './surfaceServices.js';
import { createApplicationFlights } from '../app/layers/flights.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'; import * as militaryRegistry from './militaryRegistry.js';
const flightsLayer = createApplicationFlights({ const flightsLayer = createApplicationFlights({
surface: defaultSurface, surface: defaultSurface,
source: createOpenSkySource(), source: createFlightSource(),
militaryRegistry, militaryRegistry,
}); });
export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js'; 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); const nowSec = Math.floor(Date.now() / 1000);
let openskyPoll = 0; let openskyPoll = 0;
globalThis.fetch = async (url) => { 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 { ok: true, status: 200, json: async () => ({ ac: [] }) };
} }
const states = openskyPoll++ === 0 const states = openskyPoll++ === 0
+2 -3
View File
@@ -1,15 +1,14 @@
import { createLocalGeoJsonLayer } from './localGeojsonCore.js'; import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.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. * Create fresh datacenter and dam layers without starting or loading them.
* @param {object} services Caller-owned context, overlay and render operations. * @param {object} services Caller-owned context, overlay and render operations.
* @returns {object[]} Datacenters then dams, with stable standalone identities. * @returns {object[]} Datacenters then dams, with stable standalone identities.
*/ */
export function createInfrastructureLayers(services) { export function createInfrastructureLayers(services) {
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
const datacenters = createLocalGeoJsonLayer( const datacenters = createLocalGeoJsonLayer(
{ {
id: 'local-datacenters', 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: // the dependencies reach it by different routes:
// //
// - ais-live-vessels is its REACHABLE producer. enable()/update() both resolve // - 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 // lifecycle settles to `enabled` — but until the server-side socket delivers
// a position, firstConnectPhase stays 'loading' and the module reports busy, // a position, firstConnectPhase stays 'loading' and the module reports busy,
// no lastUpdate, count 0, and an UNDEFINED status, so the status list alone // 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 { defaultSurface } from './surfaceServices.js';
import { createApplicationMilitary } from '../app/layers/militaryFlights.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'; import * as militaryRegistry from './militaryRegistry.js';
const militaryFlightsLayer = createApplicationMilitary({ const militaryFlightsLayer = createApplicationMilitary({
surface: defaultSurface, surface: defaultSurface,
source: createAdsbLolSource(), source: createMilitarySource(),
militaryRegistry, militaryRegistry,
}); });
export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js'; export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js';
+305 -80
View File
@@ -24,8 +24,12 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () =>
(await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status, (await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status,
'source-unavailable', 'source-unavailable',
); );
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] }); _setMilitaryTrackingRefreshOutcomeForTest({
const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); status: 'accepted',
ids: ['different'],
});
const missing =
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
assert.equal(missing.status, 'missing'); assert.equal(missing.status, 'missing');
assert.equal(missing.reason, 'target-absent-from-snapshot'); assert.equal(missing.reason, 'target-absent-from-snapshot');
}); });
@@ -44,9 +48,9 @@ const FULL_INFO = {
operator: 'United States Air Force', 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(); const stats = militaryFlightsLayer.getStats();
assert.equal(stats.source, 'adsb.lol'); assert.equal(stats.source, 'Military aircraft');
assert.equal(stats.fallback, false); 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', () => { test('military analyst record: no callsign falls back to registration, then icao24', () => {
assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152'); assert.equal(
assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce'); 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', () => { 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); assert.equal(r.onGround, false);
for (const [key, value] of Object.entries(r)) { for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`); 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,11 +114,16 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => {
test('military first update forwards caller cancellation into the feed request', async () => { test('military first update forwards caller cancellation into the feed request', async () => {
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
let observedSignal = null; let observedSignal = null;
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => { globalThis.fetch = (_url, options = {}) =>
new Promise((_resolve, reject) => {
observedSignal = options.signal; observedSignal = options.signal;
options.signal?.addEventListener('abort', () => { options.signal?.addEventListener(
'abort',
() => {
reject(new DOMException('aborted', 'AbortError')); reject(new DOMException('aborted', 'AbortError'));
}, { once: true }); },
{ once: true },
);
}); });
try { try {
const controller = new AbortController(); const controller = new AbortController();
@@ -138,11 +154,22 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target
json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }), json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }),
}); });
try { try {
await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); await militaryFlightsLayer.update({
const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); camera: { positionCartographic: null },
scene: {},
});
const resolution =
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
assert.equal(resolution.status, 'source-unavailable'); assert.equal(resolution.status, 'source-unavailable');
assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/); assert.match(
assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved'); militaryFlightsLayer.getStats().error,
/Malformed adsb\.lol aircraft rows/,
);
assert.equal(
militaryFlightsLayer.getAnalystRecords().length,
1,
'warm aircraft data is preserved',
);
} finally { } finally {
globalThis.fetch = realFetch; globalThis.fetch = realFetch;
} }
@@ -188,7 +215,10 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ json: async () => ({
ac: poll++ === 0 ? [{ ac:
poll++ === 0
? [
{
hex: icao24, hex: icao24,
lon: -96.9, lon: -96.9,
lat: 31.1, lat: 31.1,
@@ -202,7 +232,9 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
t: 'C17', t: 'C17',
r: '05-8152', r: '05-8152',
ownOp: 'United States Air Force', ownOp: 'United States Air Force',
}] : [], },
]
: [],
}), }),
}); });
@@ -227,7 +259,10 @@ test('cached military snapshots retain their observation time and expose stale f
_setTrackedMilitaryRefreshStateForTest({ _setTrackedMilitaryRefreshStateForTest({
icao24: 'ae01ce', icao24: 'ae01ce',
entity: { gevLabelModel: { title: 'RCH451', details: [] } }, 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() {} }, billboardCollection: { show: false, remove() {} },
viewer, viewer,
history, history,
@@ -235,21 +270,48 @@ test('cached military snapshots retain their observation time and expose stale f
}); });
let cache = 'MISS'; let cache = 'MISS';
t.mock.method(Date, 'now', () => now); t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'fetch', async () => Response.json({ t.mock.method(globalThis, 'fetch', async () =>
ac: [{ hex: 'ae01ce', lon: -97, lat: 31, alt_baro: 28000, Response.json(
track: 95, gs: 400, seen: 1, seen_pos: 2, flight: 'RCH451' }], {
}, { headers: { ac: [
'X-ADS-B-Cache': cache, {
...(cache === 'MISS' ? {} : { 'X-ADS-B-Cache-Age-Ms': String(now - receivedAt) }), 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); await militaryFlightsLayer.update(viewer);
assert.equal(history.length, 1); assert.equal(history.length, 1);
assert.equal(history[0].epochMs, receivedAt - 2000); 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; cache = status;
now = receivedAt + age; now = receivedAt + age;
await militaryFlightsLayer.update(viewer); 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().lastUpdate, receivedAt);
assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE'); assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE');
assert.equal(militaryFlightsLayer.getStats().error, null); assert.equal(militaryFlightsLayer.getStats().error, null);
@@ -276,14 +338,18 @@ test('real military track path creates no native label and publishes every cache
trackedEntityChanged, trackedEntityChanged,
clock: { currentTime: now }, clock: { currentTime: now },
camera: { camera: {
cancelFlight() { cancelledFlights += 1; }, cancelFlight() {
cancelledFlights += 1;
},
position: new Cesium.Cartesian3(0, -10_000, 7_000), position: new Cesium.Cartesian3(0, -10_000, 7_000),
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
direction: Cesium.Cartesian3.UNIT_Y, direction: Cesium.Cartesian3.UNIT_Y,
transform: Cesium.Matrix4.IDENTITY, transform: Cesium.Matrix4.IDENTITY,
viewMatrix: Cesium.Matrix4.IDENTITY, viewMatrix: Cesium.Matrix4.IDENTITY,
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
lookAtTransform() { appliedFrames += 1; }, lookAtTransform() {
appliedFrames += 1;
},
}, },
scene: { scene: {
canvas: { clientWidth: 1600, clientHeight: 900 }, 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; const realFetch = globalThis.fetch;
globalThis.window = new EventTarget(); globalThis.window = new EventTarget();
const selectionEvents = []; const selectionEvents = [];
globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => { globalThis.window.addEventListener(
'gev:awareness-subject-selected',
(event) => {
selectionEvents.push(event.detail); selectionEvents.push(event.detail);
}); },
);
globalThis.fetch = async () => ({ ok: false }); globalThis.fetch = async () => ({ ok: false });
_setTrackedOverlayHostForTest(host); _setTrackedOverlayHostForTest(host);
try { try {
@@ -336,13 +405,15 @@ test('real military track path creates no native label and publishes every cache
billboardCollection: { show: true, remove() {} }, billboardCollection: { show: true, remove() {} },
viewer, viewer,
tracked: false, tracked: false,
history: [{ history: [
{
time: now, time: now,
epochMs: Date.now(), epochMs: Date.now(),
position, position,
velocity: 231.5, velocity: 231.5,
track: 92.1, track: 92.1,
}], },
],
meta: { meta: {
...FULL_INFO, ...FULL_INFO,
type: 'C17', 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; 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.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, { assert.deepEqual(entity.gevLabelModel, {
title: 'RCH451', title: 'RCH451',
details: [ details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
'C17 · 05-8152',
'United States Air Force · 28000 ft · 450 kt',
],
accent: '#ffd166', accent: '#ffd166',
}); });
viewer.scene.preUpdate.raiseEvent(); viewer.scene.preUpdate.raiseEvent();
const initialAppliedFrames = appliedFrames; const initialAppliedFrames = appliedFrames;
const initialCancelledFlights = cancelledFlights; const initialCancelledFlights = cancelledFlights;
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); assert.equal(
assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent'); militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority'); 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(entity.gevSelectionOrigin, 'user');
assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false); assert.equal(
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true); militaryFlightsLayer.refocusTrackedById('different-flight'),
assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority'); 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); assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
viewer.scene.preUpdate.raiseEvent(); viewer.scene.preUpdate.raiseEvent();
assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity'); assert.equal(
assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner'); viewer.trackedEntity,
assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights'); 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); const publication = publications.at(-1);
assert.equal(publication.sourceId, 'tracked'); assert.equal(publication.sourceId, 'tracked');
const entry = publication.entries[0]; 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); const display = entity.position.getValue(now);
assert.ok(display, 'tracked position callback must seed its frame cache'); 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( militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late001' }, { selectedMilitaryTrackingId: 'late001' },
{ origin: 'share-restore' }, { origin: 'share-restore' },
); );
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001'); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001');
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); assert.equal(
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore'); militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
true,
);
assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
null,
'new user selection cancels stale restore',
);
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late001', 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 }, meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 },
history: [], history: [],
}); });
_applyPendingMilitaryTrackingRestoreForTest(); _applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
icao24,
);
militaryFlightsLayer.stopTracking(); militaryFlightsLayer.stopTracking();
militaryFlightsLayer.setParams( militaryFlightsLayer.setParams(
@@ -415,15 +541,25 @@ test('real military track path creates no native label and publishes every cache
); );
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002'); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002');
militaryFlightsLayer.stopTracking({ origin: 'user' }); militaryFlightsLayer.stopTracking({ origin: 'user' });
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore'); assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
null,
'explicit clear cancels stale restore',
);
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late002', 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 }, meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 },
history: [], history: [],
}); });
_applyPendingMilitaryTrackingRestoreForTest(); _applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
null,
);
militaryFlightsLayer.setParams( militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late003' }, { selectedMilitaryTrackingId: 'late003' },
@@ -438,12 +574,18 @@ test('real military track path creates no native label and publishes every cache
); );
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late003', 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 }, meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 },
history: [], history: [],
}); });
_applyPendingMilitaryTrackingRestoreForTest(); _applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
null,
);
militaryFlightsLayer.setParams( militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late004' }, { selectedMilitaryTrackingId: 'late004' },
@@ -452,18 +594,27 @@ test('real military track path creates no native label and publishes every cache
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004'); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004');
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late004', 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 }, meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 },
history: [], history: [],
}); });
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004'); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
'late004',
);
militaryFlightsLayer.setParams( militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late005' }, { selectedMilitaryTrackingId: 'late005' },
{ origin: 'local-restore' }, { origin: 'local-restore' },
); );
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true); assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
true,
);
assert.equal( assert.equal(
_pendingMilitaryTrackingRestoreForTest(), _pendingMilitaryTrackingRestoreForTest(),
'late005', 'late005',
@@ -471,12 +622,18 @@ test('real military track path creates no native label and publishes every cache
); );
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'late005', 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 }, meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 },
history: [], history: [],
}); });
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005'); assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
'late005',
);
} finally { } finally {
militaryFlightsLayer.stopTracking(); militaryFlightsLayer.stopTracking();
viewer.scene.preUpdate.raiseEvent(); 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. // The identity analyst_query hands back for this contact.
const analystId = mapAnalystRecord(icao24, { 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; }).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); const found = militaryFlightsLayer.findByQuery(analystId);
assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity'); assert.equal(
assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too'); found?.icao24,
assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright'); 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', () => { test('the analyst record carries the hex key the tracker keys on', () => {
const record = mapAnalystRecord('ae7f01', { 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', () => { 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, show: true,
}); });
const base = { const base = {
type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180, type: 'C17',
track: 90, klass: 'widebody', onGround: false, operator: 'USAF',
altitudeFt: 25000,
speedMps: 180,
track: 90,
klass: 'widebody',
onGround: false,
}; };
// Registered FIRST, so insertion order favours it. // Registered FIRST, so insertion order favours it.
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'ae0aa1', 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), billboard: billboardAt(-97.0, 31.0),
}); });
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'ae0bb2', 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), billboard: billboardAt(-97.2, 31.2),
}); });
@@ -582,24 +782,49 @@ test('two contacts matching at the same strength resolve deterministically', ()
show: true, show: true,
}); });
const base = { const base = {
type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150, type: 'C130',
track: 45, klass: 'turboprop', onGround: false, operator: 'USAF',
altitudeFt: 21000,
speedMps: 150,
track: 45,
klass: 'turboprop',
onGround: false,
}; };
// Both registrations begin with QQ7 — a same-tier prefix tie. Registered // Both registrations begin with QQ7 — a same-tier prefix tie. Registered
// highest-hex first so insertion order would pick the wrong one. // highest-hex first so insertion order would pick the wrong one.
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'aef002', 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), billboard: billboardAt(-98.0, 32.0),
}); });
_addMilitaryTrackingCandidateForTest({ _addMilitaryTrackingCandidateForTest({
icao24: 'aef001', 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), billboard: billboardAt(-98.1, 32.1),
}); });
const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24; const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24;
const second = 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(
assert.equal(second, first, 'and the same query resolves the same way every time'); 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 { 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 isMilitaryLayerActive = registry.isMilitaryLayerActive;
export const setMilitaryLayerActive = registry.setMilitaryLayerActive; export const setMilitaryLayerActive = registry.setMilitaryLayerActive;
export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange; 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 realFetch = globalThis.fetch;
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
globalThis.fetch = async (url) => { 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 { ok: true, status: 200, json: async () => ({ ac: [] }) };
} }
return { 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 }, meta: { callsign: 'OLD1', altitude: 9_000, klass: 'airliner', rawLat: 30.2, rawLon: -97.7 },
}); });
const realFetch = globalThis.fetch; 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, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }) }
: { ok: true, status: 200, json: async () => ({ ac: [] }) }); : { ok: true, status: 200, json: async () => ({ ac: [] }) });
try { try {
+1 -1
View File
@@ -35,7 +35,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
// setInstallationStatus is only ever called with // setInstallationStatus is only ever called with
// loading/zoom-in/ready/stale/empty/unavailable. // loading/zoom-in/ready/stale/empty/unavailable.
// - ais-live-vessels is what the predicate below is FOR. Its enable() and // - 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 // so the lifecycle settles to `enabled` — but until the server-side socket
// delivers a position, firstConnectPhase is 'loading' and getStats() // delivers a position, firstConnectPhase is 'loading' and getStats()
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED // reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
+11 -6
View File
@@ -1,14 +1,19 @@
// This dataset is CC BY-NC-SA 3.0, not the project's MIT license. // 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. // Commercial users must remove it or obtain a TeleGeography license.
// See DATA_SOURCES.md and the bundled dataset's source.json. // See DATA_SOURCES.md and the bundled dataset's source.json.
const cableUrl = new URL( // 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', '../../data/local_data/telegeography_submarine_cables/cable-geo.json',
import.meta.url, import.meta.url,
).href; ).href;
const landingPointUrl = new URL( }
function landingPointUrl() {
return new URL(
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json', '../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
import.meta.url, import.meta.url,
).href; ).href;
}
/** Supply GeoJSON collections without coupling the renderer to asset URLs. */ /** Supply GeoJSON collections without coupling the renderer to asset URLs. */
export function createBundledCableSource({ export function createBundledCableSource({
@@ -33,8 +38,8 @@ export function createBundledCableSource({
label: 'TeleGeography', label: 'TeleGeography',
async fetch(signal) { async fetch(signal) {
const [cables, landingPoints] = await Promise.all([ const [cables, landingPoints] = await Promise.all([
read(cableUrl, signal), read(cableUrl(), signal),
read(landingPointUrl, signal), read(landingPointUrl(), signal),
]); ]);
return { cables, landingPoints }; 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 { createVesselState } from './state.js';
import { createLifecycle } from './lifecycle.js'; import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js'; import { createIngestion } from './ingestion.js';
import { vesselViewArea } from './cameraArea.js';
import { createVesselSnapshotRenderer } from './snapshotRenderer.js'; import { createVesselSnapshotRenderer } from './snapshotRenderer.js';
import { createRendering } from './rendering.js'; import { createRendering } from './rendering.js';
import { createSelection } from './selection.js'; import { createSelection } from './selection.js';
@@ -36,6 +37,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
feed: vesselState.state.feed, feed: vesselState.state.feed,
readSource: () => vesselState._source, readSource: () => vesselState._source,
readViewer: () => vesselState.state.viewer, readViewer: () => vesselState.state.viewer,
readArea: vesselViewArea,
getRowLimit: parts.rendering.renderRowLimit, getRowLimit: parts.rendering.renderRowLimit,
readCount: () => vesselState.state.records.all.length, readCount: () => vesselState.state.records.all.length,
applyRows: parts.snapshots.reconcileVessels, applyRows: parts.snapshots.reconcileVessels,
+20 -1
View File
@@ -1,10 +1,12 @@
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js'; import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
import { viewAreaMovedEnough } from './viewArea.js';
/** Own source requests and classified feed state through explicit operations. */ /** Own source requests and classified feed state through explicit operations. */
export function createIngestion({ export function createIngestion({
feed, feed,
readSource, readSource,
readViewer, readViewer,
readArea = () => null,
getRowLimit, getRowLimit,
readCount, readCount,
applyRows, applyRows,
@@ -34,8 +36,11 @@ export function createIngestion({
AbortSignal.timeout(10000), AbortSignal.timeout(10000),
]) ])
: requestController.signal; : 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( const snapshot = await readSource().getSnapshot(
{ maxRows: getRowLimit() }, { maxRows: getRowLimit(), ...(area ? { area } : {}) },
{ signal }, { signal },
); );
if (!ownsAisRequest(requestController, requestSessionId)) return; if (!ownsAisRequest(requestController, requestSessionId)) return;
@@ -169,6 +174,15 @@ export function createIngestion({
: new Date(record.observedAtMs).toISOString(), : 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 = { const methods = {
update(viewer) { update(viewer) {
if (!feed.enabled) return Promise.resolve(); if (!feed.enabled) return Promise.resolve();
@@ -178,6 +192,7 @@ export function createIngestion({
return { return {
loadLivePositions, loadLivePositions,
refreshIfMoved,
ownsAisRequest, ownsAisRequest,
applyAisFeedSnapshot, applyAisFeedSnapshot,
vesselDisplayRow, vesselDisplayRow,
@@ -209,5 +224,9 @@ export function createVesselFeed() {
firstConnectDeadline: null, firstConnectDeadline: null,
firstConnectTimer: null, firstConnectTimer: null,
abort: 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.loading, false);
assert.equal(probe.feed.abort, null); 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), state.records.byMmsi.has(pickedId),
); );
restoreSpriteOrderOnEnable('ais', activeViewer); 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); return components.ingestion.loadLivePositions(activeViewer);
}, },
disable() { disable() {
removeViewListener();
state.feed.enabled = false; state.feed.enabled = false;
invalidateAisSession(); invalidateAisSession();
releaseContinuousRender('ais-vessels'); releaseContinuousRender('ais-vessels');
@@ -218,6 +226,7 @@ export function createLifecycle({
}, },
destroy(viewer) { destroy(viewer) {
removeViewListener();
const activeViewer = viewer || state.viewer; const activeViewer = viewer || state.viewer;
invalidateAisSession(); invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) 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 { return {
clearFirstConnectTimer, clearFirstConnectTimer,
invalidateAisSession, invalidateAisSession,
+5 -5
View File
@@ -36,7 +36,7 @@ export function createQueries({
/** /**
* Chip text for a feed the server has reported as not delivering. * Chip text for a feed the server has reported as not delivering.
* @param {string} status - 'stale' | 'reconnecting' | 'down' * @param {string} status - 'stale' | 'reconnecting' | 'down'
* @param {Object} payload - Parsed /api/ais-live JSON. * @param {Object} payload - Parsed /api/vessels JSON.
* @returns {string} * @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 * feed has accepted product data. Socket transport, message receipt, and usable
* vessel positions are separate health stages: an open socket with no message * vessel positions are separate health stages: an open socket with no message
* or no accepted positions must not read as a fresh successful update. * 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. * @param {number} acceptedRowCount - Number of rows accepted by vessel normalization.
* @returns {string|null} A short reason for the chip, or null if healthy. * @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. * 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, * @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number, * rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number,
* acceptedRowCount: number, error: string|null}} * acceptedRowCount: number, error: string|null}}
@@ -140,7 +140,7 @@ export function createQueries({
* Map one internal vessel record to a plain JSON-safe analyst record * Map one internal vessel record to a plain JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown * (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. navStatus is always null: the * 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. * cannot be derived client-side.
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry. * @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
* @returns {{id: string|null, mmsi: string|null, name: string|null, * @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 { return {
calls, calls,
Ion: { defaultAccessToken: undefined }, 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) { async createGooglePhotorealistic3DTileset(options) {
calls.push({ options, googleKey: options.key }); calls.push({ options, googleKey: options.key });
return next(); return next();
@@ -29,6 +38,7 @@ function fakeCesium(outcomes = []) {
async fromUrl(resource, options) { async fromUrl(resource, options) {
calls.push({ calls.push({
options, options,
resource,
ionToken: resource.accessToken, ionToken: resource.accessToken,
assetId: resource.assetId, 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.Ion.defaultAccessToken, 'untouched-ion');
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google'); 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 * Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the * configured, otherwise through Cesium ion's hosted Google asset. Direct
* direct request fails and an ion token is available, ion is the recovery path. * 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 {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials * @param {{googleApiKey?: string, cesiumToken?: string, googleTokens?: {token: Function}|null}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>} * @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-token'|'google-ion'|'osm', errors: Error[]}>}
*/ */
export async function loadPhotorealisticTileset( export async function loadPhotorealisticTileset(
Cesium, Cesium,
{ googleApiKey = '', cesiumToken = '' } = {}, { googleApiKey = '', cesiumToken = '', googleTokens = null } = {},
) { ) {
const googleKey = clean(googleApiKey); const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken); const ionToken = clean(cesiumToken);
const errors = []; const errors = [];
const attempts = []; const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey }); if (googleKey)
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined }); 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) { for (const attempt of attempts) {
try { try {
const tileset = attempt.googleKey const tileset = await attempt.create();
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
: await createGoogleIonTileset(Cesium, ionToken);
return { tileset, route: attempt.route, errors }; return { tileset, route: attempt.route, errors };
} catch (error) { } catch (error) {
errors.push(error instanceof Error ? error : new Error(String(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( export async function createGoogleIonTileset(
Cesium, Cesium,
accessToken, 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);
}
});
+10 -7
View File
@@ -3,16 +3,19 @@
* data files, line-delimited GeoJSON parsing and analyst records. * 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({ export const INFRASTRUCTURE_DATA_URLS = Object.freeze({
'local-datacenters': new URL( get 'local-datacenters'() {
return new URL(
'../data/local_data/datacenters/datacenters.geojsonl', '../data/local_data/datacenters/datacenters.geojsonl',
import.meta.url, import.meta.url,
).href, ).href;
'local-dams': new URL( },
'../data/local_data/dams/dams.geojsonl', get 'local-dams'() {
import.meta.url, return new URL('../data/local_data/dams/dams.geojsonl', import.meta.url)
).href, .href;
},
}); });
/** Parse GeoJSON Lines: one Feature per non-empty line. */ /** Parse GeoJSON Lines: one Feature per non-empty line. */
+18 -18
View File
@@ -1,9 +1,9 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
createOpenSkySource, createFlightSource,
createAdsbLolSource, createMilitarySource,
createAisStreamSource, createVesselSource,
normalizeReadsbAircraft, normalizeReadsbAircraft,
normalizeVesselObservation, normalizeVesselObservation,
openSkySnapshot, openSkySnapshot,
@@ -133,7 +133,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
const requests = []; const requests = [];
const fetchImpl = async (url, init) => { const fetchImpl = async (url, init) => {
requests.push({ url, init }); requests.push({ url, init });
if (url.startsWith('/api/opensky?')) if (url.startsWith('/api/flights?'))
return response( return response(
{ time: now / 1000, states: [aircraft] }, { 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', '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]] }); return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
if (url === '/api/adsblol/mil') if (url === '/api/military')
return response( return response(
{ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] }, { 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({ return response({
timestamp: now / 1000 - 60, timestamp: now / 1000 - 60,
trace: [[10, 30, -97, 1000]], trace: [[10, 30, -97, 1000]],
}); });
}; };
const civil = createOpenSkySource({ fetchImpl, now: () => now }); const civil = createFlightSource({ fetchImpl, now: () => now });
const military = createAdsbLolSource({ fetchImpl, now: () => now }); const military = createMilitarySource({ fetchImpl, now: () => now });
assert.equal(requests.length, 0); assert.equal(requests.length, 0);
const signal = new AbortController().signal; const signal = new AbortController().signal;
const snapshot = await civil.getSnapshot( const snapshot = await civil.getSnapshot(
{ latitude: 30.123456, longitude: -97 }, { latitude: 30.123456, longitude: -97 },
{ signal }, { 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(requests[0].init.signal, signal);
assert.equal(snapshot.source, 'adsb.lol'); assert.equal(snapshot.source, 'adsb.lol');
assert.equal(snapshot.coverage, '250 nm regional'); 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 () => { test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
let release; let release;
const controller = new AbortController(); const controller = new AbortController();
const source = createOpenSkySource({ const source = createFlightSource({
fetchImpl: async () => ({ fetchImpl: async () => ({
ok: true, ok: true,
json: () => 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 () => { test('denials and outages never start another source and do not echo arbitrary response bodies', async () => {
for (const status of [401, 403, 429, 502]) { for (const status of [401, 403, 429, 502]) {
let calls = 0; let calls = 0;
const source = createOpenSkySource({ const source = createFlightSource({
fetchImpl: async () => { fetchImpl: async () => {
calls++; calls++;
return response({ error: 'sensitive upstream detail' }, {}, status); 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 () => { test('AIS reports limited received coverage and keeps connection state separate from positions', async () => {
const source = createAisStreamSource({ const source = createVesselSource({
origin: () => 'http://example.test', origin: () => 'http://example.test',
fetchImpl: async (url) => { fetchImpl: async (url) => {
if (url.includes('/track?')) if (url.includes('/track?'))
return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] }); 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({ return response({
status: 'reconnecting', status: 'reconnecting',
refreshing: true, 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 () => { test('a malformed vessel row cannot prevent admission of valid positions', async () => {
const source = createAisStreamSource({ const source = createVesselSource({
fetchImpl: async () => fetchImpl: async () =>
response({ response({
status: 'live', 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 () => { test('classification identities include positionless aircraft without admitting them to rendering', async () => {
const source = createAdsbLolSource({ const source = createMilitarySource({
fetchImpl: async () => fetchImpl: async () =>
response({ response({
ac: [ 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 () => { test('identity lookup validates its response and honors body-parse cancellation', async () => {
const malformed = createAdsbLolSource({ const malformed = createMilitarySource({
fetchImpl: async () => response({ ac: [{}] }), fetchImpl: async () => response({ ac: [{}] }),
}); });
await assert.rejects(malformed.getIdentities(), /Malformed/); await assert.rejects(malformed.getIdentities(), /Malformed/);
const abort = new AbortController(); const abort = new AbortController();
const source = createAdsbLolSource({ const source = createMilitarySource({
fetchImpl: async () => ({ fetchImpl: async () => ({
ok: true, ok: true,
status: 200, status: 200,
+3 -3
View File
@@ -13,7 +13,7 @@ export {
vesselSnapshot, vesselSnapshot,
} from './vessels.js'; } from './vessels.js';
export { export {
createOpenSkySource, createFlightSource,
createAdsbLolSource, createMilitarySource,
createAisStreamSource, createVesselSource,
} from './standalone.js'; } 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); const header = (response, name) => response.headers?.get?.(name);
function openSkyError(response) { function openSkyError(response) {
const error = httpError(response, 'OpenSky'); const error = httpError(response, 'Flights');
const mode = String( const mode = String(
header(response, 'x-opensky-auth-mode-used') || header(response, 'x-opensky-auth-mode-used') ||
header(response, 'x-opensky-auth') || header(response, 'x-opensky-auth') ||
@@ -49,13 +49,24 @@ function openSkyError(response) {
return error; 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, fetchImpl = defaultFetch,
now = () => Date.now(), now = () => Date.now(),
} = {}) { } = {}) {
return { return {
label: 'OpenSky Network', label: 'Flights',
async getSnapshot(query = {}, { signal } = {}) { async getSnapshot(query = {}, { signal } = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) { if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
@@ -64,14 +75,17 @@ export function createOpenSkySource({
} }
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
`/api/opensky${params.size ? '?' + params : ''}`, `/api/flights${params.size ? '?' + params : ''}`,
{ signal }, { signal },
'OpenSky', 'Flights',
); );
if (!response.ok) throw openSkyError(response); if (!response.ok) throw openSkyError(response);
return { return {
...openSkySnapshot(payload, { ...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: coverage:
header(response, 'x-flight-coverage') || header(response, 'x-flight-coverage') ||
'worldwide upstream snapshot', 'worldwide upstream snapshot',
@@ -83,11 +97,11 @@ export function createOpenSkySource({
async getTrack(reference, { signal } = {}) { async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/opensky-track?icao24=' + encodeURIComponent(reference), '/api/flights/track?icao24=' + encodeURIComponent(reference),
{ signal }, { signal },
'OpenSky', 'Flights',
); );
if (!response.ok) throw httpError(response, 'OpenSky'); if (!response.ok) throw httpError(response, 'Flights');
return { return {
records: normalizeAircraftTrack(payload?.path), records: normalizeAircraftTrack(payload?.path),
complete: false, complete: false,
@@ -108,36 +122,37 @@ export function createOpenSkySource({
}; };
} }
export function createAdsbLolSource({ export function createMilitarySource({
fetchImpl = defaultFetch, fetchImpl = defaultFetch,
now = () => Date.now(), now = () => Date.now(),
} = {}) { } = {}) {
return { return {
label: 'adsb.lol', label: 'Military aircraft',
async getIdentities(_query = {}, { signal } = {}) { async getIdentities(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/adsblol/mil', '/api/military',
{ signal }, { signal },
'adsb.lol', 'Military aircraft',
); );
if (!response.ok) throw httpError(response, 'adsb.lol'); if (!response.ok) throw httpError(response, 'Military aircraft');
return readsbIdentities(payload); return readsbIdentities(payload);
}, },
async getSnapshot(_query = {}, { signal } = {}) { async getSnapshot(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/adsblol/mil', '/api/military',
{ signal }, { signal },
'adsb.lol', 'Military aircraft',
); );
if (!response.ok) throw httpError(response, 'adsb.lol'); if (!response.ok) throw httpError(response, 'Military aircraft');
const age = finite(header(response, 'x-ads-b-cache-age-ms')); const age = finite(header(response, 'x-feed-age-ms'));
return { return {
...readsbSnapshot(payload, { ...readsbSnapshot(payload, {
source: feedSource(response, 'Military aircraft'),
observedAtMs: now() - (age != null && age > 0 ? age : 0), observedAtMs: now() - (age != null && age > 0 ? age : 0),
now: now(), now: now(),
stale: header(response, 'x-ads-b-cache') === 'STALE', stale: header(response, 'x-feed-cache') === 'STALE',
}), }),
status: response.status, status: response.status,
}; };
@@ -145,11 +160,11 @@ export function createAdsbLolSource({
async getTrack(reference, { signal } = {}) { async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/adsblol/trace?hex=' + encodeURIComponent(reference), '/api/military/track?hex=' + encodeURIComponent(reference),
{ signal }, { 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); const baseTimeMs = epoch(payload?.timestamp, 1000);
return { return {
records: records:
@@ -165,24 +180,35 @@ export function createAdsbLolSource({
}; };
} }
export function createAisStreamSource({ export function createVesselSource({
fetchImpl = defaultFetch, fetchImpl = defaultFetch,
apiUrl = '/api/ais-live', apiUrl = '/api/vessels',
origin = () => globalThis.location?.origin || 'http://localhost', origin = () => globalThis.location?.origin || 'http://localhost',
} = {}) { } = {}) {
return { return {
label: 'AISStream', label: 'Vessels',
async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) { async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) {
const url = new URL(apiUrl, origin()); const url = new URL(apiUrl, origin());
url.searchParams.set('maxRows', String(maxRows)); 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( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
url.toString(), url.toString(),
{ signal, cache: 'no-store' }, { signal, cache: 'no-store' },
'AIS live', 'Vessels',
); );
if (!response.ok) { if (!response.ok) {
const error = httpError(response, 'AIS live'); const error = httpError(response, 'Vessels');
const reasons = { const reasons = {
'missing-key': 'AISSTREAM_API_KEY not set', 'missing-key': 'AISSTREAM_API_KEY not set',
'auth-failed': 'API key rejected — check AISSTREAM_API_KEY', 'auth-failed': 'API key rejected — check AISSTREAM_API_KEY',
@@ -193,16 +219,19 @@ export function createAisStreamSource({
error.message = reasons[payload?.status] || error.message; error.message = reasons[payload?.status] || error.message;
throw error; throw error;
} }
return { ...vesselSnapshot(payload), status: response.status }; return {
...vesselSnapshot(payload, { source: feedSource(response, 'Vessels') }),
status: response.status,
};
}, },
async getTrack(reference, { signal } = {}) { async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
'/api/ais-live/track?mmsi=' + encodeURIComponent(reference), '/api/vessels/track?mmsi=' + encodeURIComponent(reference),
{ signal }, { signal },
'AIS live', 'Vessels',
); );
if (!response.ok) throw httpError(response, 'AIS live'); if (!response.ok) throw httpError(response, 'Vessels');
return { return {
records: normalizeVesselTrack(payload?.samples), records: normalizeVesselTrack(payload?.samples),
complete: false, complete: false,
+4
View File
@@ -6,6 +6,7 @@ import { createStandaloneScene } from './scene.js';
import { createStandaloneControls } from './controls.js'; import { createStandaloneControls } from './controls.js';
import { createStandaloneData } from './data.js'; import { createStandaloneData } from './data.js';
import { createStandaloneTools } from './tools.js'; import { createStandaloneTools } from './tools.js';
import { createGoogleTokenSource } from '../maps/googleTokens.js';
// The existing controls and layer catalog contain page-scoped state. // The existing controls and layer catalog contain page-scoped state.
let constructed = false; let constructed = false;
@@ -39,6 +40,9 @@ export function createStandaloneApplication({
const scene = await createStandaloneScene({ const scene = await createStandaloneScene({
...context, ...context,
googleApiKey, 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, cesiumToken,
loaderStatus, loaderStatus,
}); });
+7 -7
View File
@@ -1,8 +1,8 @@
import { createOpenFreeMapSource } from '../sources/openFreeMap.js'; import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
import { import {
createOpenSkySource, createFlightSource,
createAdsbLolSource, createMilitarySource,
createAisStreamSource, createVesselSource,
} from '../sources/live/standalone.js'; } from '../sources/live/standalone.js';
import { createCctvSource } from '../layers/cctv/source.js'; import { createCctvSource } from '../layers/cctv/source.js';
import { createRadioSource } from '../layers/radio/source.js'; import { createRadioSource } from '../layers/radio/source.js';
@@ -26,10 +26,10 @@ export function createStandaloneLayerSources() {
const mapTiles = createOpenFreeMapSource(); const mapTiles = createOpenFreeMapSource();
return { return {
...createReferenceSources(), ...createReferenceSources(),
flights: createOpenSkySource(), flights: createFlightSource(),
military: createAdsbLolSource(), military: createMilitarySource(),
vessels: createAisStreamSource({ vessels: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may // Resolved against the document's address, which a panel host may
// serve from its own scheme. // serve from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost', 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()); const states = install(providers.openSkyProxy());
assert.equal( assert.equal(
(await states('/api/opensky', '?lat=30&lon=-97')).statusCode, (await states('/api/flights', '?lat=30&lon=-97')).statusCode,
200, 200,
); );
assert.equal( assert.equal(
(await states('/api/opensky', '?lat=30&lon=-97')).headers[ (await states('/api/flights', '?lat=30&lon=-97')).headers[
'x-opensky-cache' 'x-opensky-cache'
], ],
'HIT', 'HIT',
@@ -105,14 +105,14 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
assert.equal(calls.length, 2); assert.equal(calls.length, 2);
const tracks = install(providers.trackBackfillProxies(), true); const tracks = install(providers.trackBackfillProxies(), true);
assert.equal( assert.equal(
(await tracks('/api/opensky-track', '?icao24=ABC123')).statusCode, (await tracks('/api/flights/track', '?icao24=ABC123')).statusCode,
200, 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('/token')).length, 1);
assert.equal(calls.filter((call) => call.url.includes('/tracks/')).length, 1); assert.equal(calls.filter((call) => call.url.includes('/tracks/')).length, 1);
assert.equal( assert.equal(
(await tracks('/api/adsblol/trace', '?hex=invalid')).statusCode, (await tracks('/api/military/track', '?hex=invalid')).statusCode,
400, 400,
); );
now += 130_000; 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'; process.env.OPENSKY_AUTH_MODE = 'anon';
const fallback = await install(fresh.openSkyProxy())( const fallback = await install(fresh.openSkyProxy())(
'/api/opensky', '/api/flights',
'?lat=30&lon=-97', '?lat=30&lon=-97',
); );
assert.equal(fallback.statusCode, 200); 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()}` `../../server/providers/aircraft/opensky.js?timeout=${Math.random()}`
); );
const response = await install(fresh.openSkyProxy())( const response = await install(fresh.openSkyProxy())(
'/api/opensky', '/api/flights',
'?lat=30&lon=-97', '?lat=30&lon=-97',
); );
return { response, attempts }; 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' }] }); return Response.json({ ac: [{ hex: 'abc123' }] });
}); });
const request = install(providers.adsbLolProxy()); const request = install(providers.adsbLolProxy());
const first = await request('/api/adsblol/mil'); const first = await request('/api/military');
assert.equal((await request('/api/adsblol/mil')).body, first.body); assert.equal((await request('/api/military')).body, first.body);
assert.equal(calls, 1); assert.equal(calls, 1);
now += 13_000; 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); 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); const request = install(plugin, true);
let res; let res;
for (let i = 0; i < 100; i++) { 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; if (JSON.parse(res.body).rows.length) break;
await delay(10); 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.status, 'live');
assert.equal(data.rows[0].mmsi, '123456789'); assert.equal(data.rows[0].mmsi, '123456789');
assert.equal(data.rows[0].heading, null); 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(JSON.parse(history.body).samples.length, 2);
assert.equal( assert.equal(
(await request('/api/ais-live', '/track?mmsi=bad')).statusCode, (await request('/api/vessels', '/track?mmsi=bad')).statusCode,
400, 400,
); );
assert.equal(sockets.length, 1); 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); for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10);
assert.equal(sockets[0].readyState, 3); assert.equal(sockets[0].readyState, 3);
const restarted = install(plugin); 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); for (let i = 0; i < 100 && sockets.length < 2; i++) await delay(10);
assert.equal(sockets.length, 2); 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 request = install(providers.adsbLolProxy());
const first = await request('/api/adsblol/mil'); const first = await request('/api/military');
assert.equal(first.statusCode, 200); assert.equal(first.statusCode, 200);
now += 13_000; now += 13_000;
const limited = await request('/api/adsblol/mil'); const limited = await request('/api/military');
assert.equal(calls, 2); assert.equal(calls, 2);
assert.equal( assert.equal(
limited.statusCode, 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', 'a 429 with a cached body is never relayed',
); );
assert.equal(limited.body, first.body); 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-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; 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(calls, 2, 'no upstream call inside the Retry-After window');
assert.equal(cooling.headers['x-ads-b-cache'], 'STALE'); assert.equal(cooling.headers['x-feed-cache'], 'STALE');
assert.equal(cooling.headers['x-ads-b-cache-age-ms'], '18000'); assert.equal(cooling.headers['x-feed-age-ms'], '18000');
now += 16_000; now += 16_000;
await request('/api/adsblol/mil'); await request('/api/military');
assert.equal(calls, 3, 'upstream is retried once Retry-After elapses'); 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 }); return new Response('{"error":"rate limited"}', { status: 429 });
}); });
const request = install(providers.adsbLolProxy()); const request = install(providers.adsbLolProxy());
const limited = await request('/api/adsblol/mil'); const limited = await request('/api/military');
assert.equal(limited.statusCode, 429); assert.equal(limited.statusCode, 429);
assert.ok(limited.headers['retry-after']); 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(again.statusCode, 429);
assert.equal( assert.equal(
calls, calls,
@@ -369,21 +369,21 @@ test('military fallback cancels a stalled 5xx body and starts cooldown at receip
); );
}); });
const request = install(providers.adsbLolProxy()); const request = install(providers.adsbLolProxy());
await request('/api/adsblol/mil'); await request('/api/military');
now += 1000; now += 1000;
const hit = await request('/api/adsblol/mil'); const hit = await request('/api/military');
assert.equal(hit.headers['x-ads-b-cache-age-ms'], '1000'); assert.equal(hit.headers['x-feed-age-ms'], '1000');
now += 12000; now += 12000;
const fallback = await request('/api/adsblol/mil'); const fallback = await request('/api/military');
assert.equal(fallback.statusCode, 200); assert.equal(fallback.statusCode, 200);
assert.equal(cancelled, true); 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'); assert.equal(fallback.headers['x-ads-b-retry-after-seconds'], '20');
now += 19000; now += 19000;
await request('/api/adsblol/mil'); await request('/api/military');
assert.equal(calls, 2); assert.equal(calls, 2);
now += 2000; now += 2000;
await request('/api/adsblol/mil'); await request('/api/military');
assert.equal(calls, 3); assert.equal(calls, 3);
}); });
@@ -403,7 +403,7 @@ test('military cooldown bounds untrusted Retry-After and defaults server errors'
headers: { 'Retry-After': raw }, 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); 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.equal(res1.statusCode, 502);
assert.deepEqual(JSON.parse(res1.body), { assert.deepEqual(JSON.parse(res1.body), {
error: 'Upstream track response too large', 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 // 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. // 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(res2.statusCode, 502);
assert.equal(callCount, 1); 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}`; const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
try { try {
for (const [route, status] of [ for (const [route, status] of [
['/api/opensky', 200], ['/api/flights', 200],
['/api/adsblol/mil', 200], ['/api/military', 200],
['/api/adsbdb/type/invalid', 400], ['/api/adsbdb/type/invalid', 400],
['/api/firms/status', 200], ['/api/firms/status', 200],
['/api/fire-perimeters/inciweb/publication/invalid', 400], ['/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), /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 () => { test('the real OpenSky source marks an hour-old snapshot stale for searches', async () => {
const { createOpenSkySource } = const { createFlightSource } =
await import('../../sources/live/standalone.js'); await import('../../sources/live/standalone.js');
const now = Date.UTC(2026, 0, 1, 12); const now = Date.UTC(2026, 0, 1, 12);
const opensky = createOpenSkySource({ const opensky = createFlightSource({
now: () => now, now: () => now,
fetchImpl: async () => fetchImpl: async () =>
Response.json({ time: now / 1000 - 3600, states: [] }), Response.json({ time: now / 1000 - 3600, states: [] }),
+24 -6
View File
@@ -6,6 +6,7 @@ import {
AREA_SCHEMA, AREA_SCHEMA,
areaCenter, areaCenter,
areaContains, areaContains,
areaRadiusKm,
distanceKm, distanceKm,
resolveArea, resolveArea,
} from '../area.js'; } from '../area.js';
@@ -48,12 +49,29 @@ function vesselRow(record, from) {
} }
/** /**
* Every vessel the source retains. The source's default is the newest few * Every vessel the source retains, or with `area`, every vessel around it.
* thousand, which can leave out a vessel the server still holds; asking for * The source's default is the newest few thousand, which can leave out a
* more than any server keeps lets the server return all of them. * 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) { function readVessels(services, signal, area) {
return services.vessels.getSnapshot({ maxRows: ALL_VESSELS }, { signal }); 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) { function snapshotInfo(snapshot) {
@@ -90,7 +108,7 @@ export const vesselsInArea = defineTool({
async run(args, { services, signal }) { async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal }); const area = await resolveArea(args.area, { services, signal });
const center = areaCenter(area); const center = areaCenter(area);
const snapshot = await readVessels(services, signal); const snapshot = await readVessels(services, signal, area);
const wanted = args.type?.toLowerCase(); const wanted = args.type?.toLowerCase();
const rows = snapshot.records const rows = snapshot.records
.filter( .filter(
+25 -2
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js'; import { composeCatalog, coreTools } from '../index.js';
import { LiveSourceError } from '../../sources/live/contract.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'; import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.js';
const vessel = (id, latitude, longitude, extra = {}) => ({ 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 () => { test('vessel searches ask for every vessel the server retains', async () => {
const requested = []; const requested = [];
const source = createAisStreamSource({ const source = createVesselSource({
origin: () => 'http://localhost', origin: () => 'http://localhost',
fetchImpl: async (url) => { fetchImpl: async (url) => {
requested.push(new URL(url).searchParams.get('maxRows')); 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) for (const maxRows of requested)
assert.ok(Number(maxRows) >= AISSTREAM_CACHE_MAX, maxRows); 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 { createEventPackSource } from '../sources/eventPacks.js';
import { createInfrastructureSource } from '../sources/infrastructureData.js'; import { createInfrastructureSource } from '../sources/infrastructureData.js';
import { import {
createAdsbLolSource, createMilitarySource,
createAisStreamSource, createVesselSource,
createOpenSkySource, createFlightSource,
} from '../sources/live/standalone.js'; } from '../sources/live/standalone.js';
import { import {
createGeocodePlaceService, createGeocodePlaceService,
@@ -51,9 +51,9 @@ export function createToolServices({ fetchImpl, appUrl, panelKey }) {
earthquakes: createUsgsEarthquakeSource({ fetchImpl }), earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
fires: createFirmsSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }),
launches: createLaunchSource({ fetchImpl }), launches: createLaunchSource({ fetchImpl }),
aircraft: createOpenSkySource({ fetchImpl }), aircraft: createFlightSource({ fetchImpl }),
military: createAdsbLolSource({ fetchImpl }), military: createMilitarySource({ fetchImpl }),
vessels: createAisStreamSource({ vessels: createVesselSource({
fetchImpl, fetchImpl,
origin: () => new URL(appUrl).origin, origin: () => new URL(appUrl).origin,
}), }),