mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge upstream main for typed-send compatibility
This commit is contained in:
commit
5fd5d688dc
98 files changed
+4575
-611
No files matched your search
+4
-2
@@ -118,8 +118,8 @@ OPENSKY_AUTH_MODE=oauth
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
|
||||
# Optional: higher Launch Library 2 request allowance. Public access works
|
||||
# without a token.
|
||||
# Launch Library 2: leave blank for standard public access (no key needed).
|
||||
# Optional paid Patreon tokens raise request limits; there is no free API key.
|
||||
LL2_API_TOKEN=
|
||||
|
||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
||||
@@ -237,6 +237,8 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
|
||||
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
|
||||
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
|
||||
# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md)
|
||||
# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first)
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
+9
-7
@@ -54,8 +54,9 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
|
||||
Runnable gate entrypoints document what they assert and how to invoke them.
|
||||
Most need a dev server; some also need a specific provider key or port. Follow
|
||||
the entrypoint's header rather than assuming every matching file accepts the
|
||||
same arguments. Only a couple (`qa:map-source-tray`, `qa:transit`) have an
|
||||
`npm run` alias.
|
||||
same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
|
||||
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
|
||||
`qa:transit`.
|
||||
|
||||
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
|
||||
for the feature: it names the gate for many of them, and it's the authoritative
|
||||
@@ -63,12 +64,13 @@ runtime reference either way. `scripts/qa-l9-matrix.mjs` aggregates the broader
|
||||
release-candidate checks and selected harnesses; it does not replace the focused
|
||||
gate for the feature you changed.
|
||||
|
||||
> **CI does not cover this for you.** The workflow runs the setup policy
|
||||
> **CI covers only two of these for you.** The workflow runs the setup policy
|
||||
> checks, the formatting and package-boundary checks, the unit suite and the
|
||||
> production build, plus a Windows onboarding job. It runs neither
|
||||
> `npm run test:track` nor any `qa-*.mjs` gate — both need a live dev server
|
||||
> and a browser. Include the applicable local run in your PR's validation
|
||||
> evidence.
|
||||
> production build, plus a Windows onboarding job, and against the built preview
|
||||
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
|
||||
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
|
||||
> server and a browser. Include the applicable local run in your PR's
|
||||
> validation evidence.
|
||||
|
||||
## Share-link layer tokens
|
||||
|
||||
|
||||
+5
-1
@@ -48,6 +48,7 @@ How to read this:
|
||||
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
|
||||
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
|
||||
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
|
||||
| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) |
|
||||
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
|
||||
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
|
||||
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
|
||||
@@ -69,10 +70,12 @@ How to read this:
|
||||
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
|
||||
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
|
||||
| **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" |
|
||||
| **BiciMAD** (`madrid.publicbikesystem.net`) | Bikeshare station availability, Madrid | Operator GBFS feed (PBSC), keyless. Madrid's open-data catalogue publishes this feed (`datos.madrid.es`, dataset `900021-0-bicimad-gbfs`, owner EMT Madrid) under [EMT's open-data terms](https://mobilitylabs.emtmadrid.es/sip/terms-of-use): commercial and non-commercial reuse, with attribution REQUIRED (section 5.2). | "Powered by EMT de Madrid", linking to `https://www.emtmadrid.es` |
|
||||
| **Spanish city bikes on PBSC** (`*.publicbikesystem.net`: Zaragoza, Valladolid, A Coruña, Donostia-San Sebastián, Bilbao, Rivas-Vaciamadrid) | Bikeshare station availability: Bizi Zaragoza, BIKI, BiciCoruña, Dbizi, Bilbao Bizi, BicinRivas | Operator GBFS feeds, keyless. The feeds' `system_information.json` carries no `license_url`, and no operator or city publishes a licence for them; MobilityData's catalogue lists CC BY 4.0 without naming a source. Attribution is given to the operator. | "Bizi Zaragoza"; "BIKI, AUVASA (Valladolid)"; "BiciCoruña"; "Dbizi (Donostia-San Sebastián)"; "Bilbao Bizi, Bilbao City Council"; "BicinRivas, Rivas-Vaciamadrid City Council" |
|
||||
| **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" |
|
||||
| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" |
|
||||
| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" |
|
||||
| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" |
|
||||
| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability: Bysykkel Lillestrøm, Valenbisi (Valencia) and Sevici (Seville). Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (each feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm, Valenbisi, Sevici: JCDecaux, Licence Ouverte" |
|
||||
| **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) |
|
||||
| **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) |
|
||||
@@ -160,6 +163,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
|
||||
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
|
||||
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
||||
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
|
||||
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
|
||||
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
|
||||
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
|
||||
|
||||
@@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📻 **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 | 🟢 |
|
||||
@@ -385,7 +385,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
||||
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
||||
@@ -527,9 +527,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di
|
||||
| | Key | Why | Get it |
|
||||
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
|
||||
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
|
||||
| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) |
|
||||
|
||||
Add these if you need higher polling allowances.
|
||||
Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional
|
||||
paid Patreon upgrades for higher limits; there is no free API-key signup.
|
||||
|
||||
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
|
||||
where each configured provider was found without printing credential values.
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an
|
||||
MCP client can ask which airlines, countries, aircraft types or ship types
|
||||
dominate an area, or the average altitude or speed, over every match instead
|
||||
of estimating from the returned rows. Airlines come from the ICAO designator
|
||||
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
|
||||
that report no operator; operator and type are counted only where the feed
|
||||
reports them. Ship types count by family, so AIS codes 70 to 79 are all
|
||||
cargo; type 0 ("not available") and the reserved codes 100 to 255 count as
|
||||
unknown. Altitude and speed stats cover airborne aircraft only, so parked
|
||||
aircraft do not pull the means down. Every vessel answer, `find_vessel`
|
||||
included, now reads AIS ship-type codes as their family and a destination
|
||||
with no letters as unknown (quanticsoul4772, #1056).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for
|
||||
GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and
|
||||
GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK,
|
||||
#1094).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a
|
||||
spliced catalog cannot pair one satellite's elements with another's; the next
|
||||
valid set still parses (VG31OP, #1101).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Keep missing launch-pad coordinates unknown so launches use the location
|
||||
fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage
|
||||
flight numbers no longer show "FLIGHT 0"
|
||||
(Rudy Celekli, @rudycelekli, #1186).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD
|
||||
(Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI
|
||||
(Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and
|
||||
BicinRivas, about 1,840 stations. Station names read underscores as word
|
||||
breaks, and station capacity counts docks rather than PBSC's geofenced area
|
||||
(ulihhhh, #1190).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Share Launch Library request cache keys across installations refreshing in the
|
||||
same UTC minute, identify the app to the provider, and explain that standard
|
||||
space-missions access needs no key. Optional higher-rate LL2 tokens are paid
|
||||
upgrades; existing local cache and server-only token handling are unchanged
|
||||
(rudycelekli, #1197).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep Space Missions marked STALE when Launch Library 2 falls back to cached
|
||||
launch data, and return to NOMINAL after a fresh response without discarding
|
||||
retained missions (rudycelekli, #1199).
|
||||
@@ -0,0 +1,5 @@
|
||||
- The client's great-circle distances now come from one shared
|
||||
`src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}`
|
||||
points). The Earth radius is standardized on 6371.0088 km, so distances shift
|
||||
by about 1.4 ppm and region areas by about 2.8 ppm, and missing or
|
||||
out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack:
|
||||
the statewide ArcGIS catalog, read a page at a time under a byte cap with
|
||||
redirects refused, and frames pinned to FL511's two image hosts over HTTPS.
|
||||
FL511's terms limit its content to individual, personal use, and any other
|
||||
use (a public deployment included) needs FDOT's express written consent, so
|
||||
the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the
|
||||
catalog's direction codes, and camera ids come from the image channel, since
|
||||
the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES`
|
||||
sets the cap (Danielslay86, #605).
|
||||
@@ -0,0 +1,9 @@
|
||||
- Bound what a caller can grow through `/api/terrain/heights`. Every distinct
|
||||
5-decimal point was a permanent in-memory entry, a permanent line in an
|
||||
uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with
|
||||
nothing limiting the rate. The point cache now holds at most 50,000 entries
|
||||
and evicts oldest-first, a cache file above
|
||||
24 MB is not read back, and the route is limited to 90 requests/minute per
|
||||
client (270 global). Evicting a point costs one refetch, never a wrong
|
||||
answer, since terrain does not move. Ordinary use is unaffected: a full
|
||||
`test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686).
|
||||
@@ -0,0 +1,5 @@
|
||||
- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write
|
||||
cache files and logs there while the app runs, and none of it is source, so
|
||||
the watcher now skips it; this guards against the DNS-lookup delays heavy
|
||||
cache writes have caused, though no difference was measured on current main
|
||||
(monwiss, #761).
|
||||
@@ -0,0 +1,11 @@
|
||||
- Read satellite catalog numbers written in Alpha-5, the TLE spelling of
|
||||
numbers above 99999: a letter for the first two digits, skipping I and O
|
||||
(`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a
|
||||
field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all
|
||||
but the first were dropped as duplicates; the satellite tools read the same
|
||||
field as no number, so such a satellite could not be found by it. The layer
|
||||
and the tools now decode the field in `src/sources/tle.js`, and reject a
|
||||
malformed one (a sign, decimal point, hex, embedded space or stray letter)
|
||||
instead of reading part of it as a number. This is Alpha-5 TLE
|
||||
compatibility: CelesTrak's TLE feed carries no satellite above 99999, which
|
||||
needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Extract a reusable model-free World Packet from the existing voice
|
||||
`get_current_view_state` owner without restoring disabled layers to the
|
||||
model context. Keep the enabled-only payload, disabled-layer count, source
|
||||
provenance, action result and view-state contract; read the layer roster once
|
||||
per packet; voice retains the speech/card envelope at its own boundary.
|
||||
Credits: original GEV voice/layer contributors and #880 implementation.
|
||||
@@ -0,0 +1,8 @@
|
||||
- Cross-site pages can no longer fill the terrain-height cache or spend the
|
||||
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
|
||||
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
|
||||
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
|
||||
the cost-bearing routes it still answers same-origin requests that arrive
|
||||
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
|
||||
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
|
||||
#975).
|
||||
+86
-9
@@ -1,5 +1,20 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
|
||||
non-finite, and out-of-range points (latitude outside ±90, longitude outside
|
||||
±180) return `Infinity`, so threshold and nearest-item consumers exclude invalid
|
||||
positions. Older exported `haversineKm` entry points remain as compatibility
|
||||
wrappers only. `src/data/approxDistance.js`, the equirectangular
|
||||
viewer-proximity approximation, is deliberately separate, as are intentional
|
||||
local angular, projection, ellipsoidal prefilter, and small-viewport
|
||||
approximations. Four great-circle copies remain for a follow-up:
|
||||
`src/data/adminBoundaries.js` and `src/tools/area.js` pass unwrapped longitudes
|
||||
across the antimeridian, so they cannot use the strict ±180 check as-is, and
|
||||
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
|
||||
implementations.
|
||||
|
||||
Vessel sources can report healthy empty coverage using a current, complete
|
||||
snapshot with zero raw rows, a healthy transport and a positive last-message
|
||||
time. This clears obsolete contacts and settles startup without manufacturing
|
||||
@@ -97,7 +112,14 @@ perimeters, terrain height, military installations and map features, plus a
|
||||
combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that
|
||||
opens the app over an area. Tools reuse the layers' portable source factories, take a
|
||||
shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||
lists at 25 rows by default. Voice offers the same queries next to its app
|
||||
lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also
|
||||
count every match by a field (`group_by`: airline, read from the ICAO
|
||||
designator in the callsign, or operator, country or type for aircraft; type or
|
||||
destination for ships; the ten largest groups, then other and unknown counts;
|
||||
the summary says when a feed reports none of the field) and summarize airborne
|
||||
altitude and speed (`stats`: count, min, median, mean, max).
|
||||
Both cover all matches, not just the rows returned; voice leaves these two
|
||||
tools out, so they reach MCP clients only. Voice offers the same queries next to its app
|
||||
actions: the session lists them, and the browser runs them through the same
|
||||
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
||||
Aircraft altitude-band queries require a reported barometric or geometric altitude
|
||||
@@ -474,6 +496,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
||||
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||
share one deterministic count/scope headline; lower bounds use "At least" and
|
||||
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||
The model-free `gods-eye-view/agent/world-packet` export exposes
|
||||
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
||||
non-voice consumers. It reads the same enabled-only state and feed
|
||||
provenance before voice adds speech/card fields, with no new network requests
|
||||
or mutations.
|
||||
The voice card nested in the mic control (`voiceCard.js`) shows captions, plan
|
||||
steps and the result. `resultDisplay.js` is the one adapter from a result to
|
||||
the card: builder results show as built, and analyst results show a title
|
||||
@@ -791,8 +818,17 @@ and `./layers/launches`. Each owns its catalog, render state, tracking, interact
|
||||
and teardown. Source adapters retain the existing CelesTrak and Launch Library
|
||||
endpoints; standalone entries provide scene services and the satellite dependency.
|
||||
The mission factory separates paths, replay, camera, panel, overlays and placement.
|
||||
Launch Library pad coordinates retain unknown values: missing fields use the
|
||||
reported location coordinates when available, and launches without a known pad
|
||||
position stay off the map. Genuine zero coordinates remain valid. Undisclosed
|
||||
stage coordinates, downrange distance and flight numbers remain unknown; the
|
||||
mission panel omits the flight-number label when no number was reported.
|
||||
Pending mission requests abort on disable/destruction and malformed launch
|
||||
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
|
||||
snapshots preserve the last accepted display. A Launch Library stale-cache fallback
|
||||
retains its missions but reports STALE in Data Layers; a fresh snapshot clears that
|
||||
provenance. Hiding retained missions does not relabel them as fresh, and teardown
|
||||
clears the instance state. Injected getLaunches-only sources remain supported.
|
||||
Catalog groups, propagation cadence,
|
||||
tracking intent, replay timing, controls and source attribution remain unchanged.
|
||||
|
||||
The fire layer now exposes `./layers/firms`: an instance factory with separate
|
||||
@@ -1250,7 +1286,13 @@ String arrays regardless of the reported feed version. Nonempty English text
|
||||
(including regional `en-*` tags, case-insensitive) takes precedence, then the first
|
||||
usable translation. An unusable `name` falls back to `short_name` with the same
|
||||
rules; if neither supplies text, the station remains available with an empty name
|
||||
(the globe uses its station-ID label and the tool returns `null`). Malformed
|
||||
(the globe uses its station-ID label and the tool returns `null`). A run of underscores,
|
||||
with the spaces around it, reads as one word break and the ends are trimmed:
|
||||
JCDecaux's Spanish contracts publish `_PLAZA_MUSICO_LOPEZ_CHAVARRI`. Other spacing
|
||||
stays as published. Capacity counts docks: PBSC adds the bikes a station accepts
|
||||
in its geofenced area to `capacity` and reports that part as `geofenced_capacity`
|
||||
(Bilbao Bizi: 839 = 39 docks + 800), so the layer, its colour ratio and
|
||||
`get_bike_share` read `capacity − geofenced_capacity` when the extension is present. Malformed
|
||||
translation entries do not interrupt loading of the other stations. This covers
|
||||
station names, not complete GBFS v3 support.
|
||||
`scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer,
|
||||
@@ -1415,6 +1457,18 @@ an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||
|
||||
## Terrain height cache bound
|
||||
|
||||
The `/api/terrain/heights` route refuses cross-site browser requests before
|
||||
rate limiting, cache access or upstream work: a `Sec-Fetch-Site` other than
|
||||
`same-origin`/`none` (an `<img>` on another page), or a foreign or opaque
|
||||
`Origin`, gets `403`. The per-client limiter keys on the socket address, which
|
||||
for such a request is the visitor's own browser, so without the gate any page
|
||||
could spend the visitor's 90/min quota and leave the app's own lookups on
|
||||
`429`. Unlike the cost-bearing routes, the terrain gate does not refuse
|
||||
reverse-proxy forwarding headers: terrain is keyless and needed for rendering,
|
||||
and a `403` there would send every chunk to the geoid fallback for an instance
|
||||
served behind the documented reverse proxy. Same-origin app requests,
|
||||
non-browser local callers and proxied same-origin requests keep working.
|
||||
|
||||
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
|
||||
latitudes outside [-90, 90] for every client before upstream resolution. One
|
||||
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
|
||||
@@ -1452,9 +1506,20 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
|
||||
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
||||
access. Callers retain validation, transport and response policy.
|
||||
|
||||
LL2 recent-launch requests use UTC minute-aligned bounds over the same 30-day
|
||||
window, so installations refreshing in the same minute share an upstream cache
|
||||
key. The upper bound trails the current time by less than one minute. Requests
|
||||
identify the app and repository through User-Agent; the existing 15-minute local
|
||||
cache, stale fallback and optional server-only Authorization remain unchanged.
|
||||
Provider Settings identifies LL2 tokens as optional paid higher-rate upgrades:
|
||||
standard public access needs no key, and its field can remain blank.
|
||||
|
||||
The shared TLE text parser skips incomplete named sets and resumes at the next
|
||||
complete set. A missing orbital line does not discard the later satellites in
|
||||
the catalog; satellite layers and next-pass tools use the same parser.
|
||||
the catalog. A set whose two lines carry different catalog numbers, or a
|
||||
malformed catalog number, is skipped the same way, so a spliced catalog cannot
|
||||
pair one satellite's elements with another's. Satellite layers and next-pass
|
||||
tools use the same parser.
|
||||
|
||||
## Build configuration and local provider boundaries
|
||||
|
||||
@@ -2635,6 +2700,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> tunings. Ambient overlap includes each sprite's own rendered extent. The
|
||||
> gated AIS, CCTV, and satellite passes retain an active-emphasis count so a
|
||||
> settled dim contact always completes its release after tracking ends.
|
||||
> - **Terrain-height bounds:** because every distinct 5-decimal point is its own
|
||||
> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own
|
||||
> upstream call, four limits bound what a caller can grow. Coordinates outside
|
||||
> WGS-84 range are rejected with `400` at parse (finite is not the same as on
|
||||
> Earth); the point cache holds at most 50,000 entries, evicting oldest-first
|
||||
> — terrain does not move, so an evicted point costs one refetch and never a
|
||||
> wrong answer; a cache file above 24 MB is not read back into memory (the
|
||||
> ceiling `rocketLaunchesProxy` already applies); and the route carries an
|
||||
> always-on limiter of 90 requests/minute/client, 270 global. That cap is an
|
||||
> order of magnitude above measured demand — a full `npm run test:track` run
|
||||
> drove 10 requests, peaking at 8/min.
|
||||
> - **Terrain-height resilience:** `/api/terrain/heights` caches canonical
|
||||
> 5-decimal points individually, reconstructs reordered/overlapping batches
|
||||
> in exact request order, and refreshes only missing or stale points. Upstream
|
||||
@@ -3366,10 +3442,10 @@ its criteria cannot be silently ignored.
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
@@ -4319,6 +4395,7 @@ silently demoting every later lookup for the session.
|
||||
- **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale.
|
||||
- **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert.
|
||||
- The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe.
|
||||
- **Catalog numbers above 99999** can be written in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. CelesTrak's TLE feed carries no satellite above 99999 (that needs the OMM feed, #1191), so this is compatibility with Alpha-5 TLEs. `src/sources/tle.js` decodes the field through one shared helper: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. Plain digits read as before (`noradIdFromSatnum()` accepts any length, as an OMM satrec carries the number unpadded); a malformed field — a sign, decimal point, hex, exponent, embedded space, stray or lowercase letter, or blank — is rejected rather than partly read, as `NaN` in the layer and `null` in the tools.
|
||||
- **CCTV estimated bearings:** a camera whose pack marks `headingConfidence: 'low'` (the id-hash
|
||||
`fallbackHeadingFromId` bearing) shows `HDG n° (ESTIMATED)` in the HUD and draws its coverage
|
||||
wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration
|
||||
@@ -5454,9 +5531,9 @@ When runtime behavior or architecture changes, update this file in the same chan
|
||||
|
||||
## Dependency security baseline
|
||||
|
||||
The lockfile uses DOMPurify 3.4.15, protobufjs 8.8.0, PostCSS 8.5.28, and
|
||||
nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0;
|
||||
image-processing tools use Sharp 0.35.4. QA scripts await Puppeteer's asynchronous
|
||||
The lockfile uses DOMPurify 3.4.17, protobufjs 8.8.0, PostCSS 8.5.28,
|
||||
source-map-js 1.2.2 and nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA
|
||||
uses Puppeteer 25.10.0; image-processing tools use Sharp 0.35.5. QA scripts await Puppeteer's asynchronous
|
||||
executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain
|
||||
24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree.
|
||||
|
||||
|
||||
+2
-2
@@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
|
||||
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
|
||||
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
|
||||
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match |
|
||||
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
|
||||
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
|
||||
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
|
||||
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||
|
||||
Generated
+124
-124
@@ -548,9 +548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -567,13 +567,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -590,13 +590,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
@@ -604,7 +604,7 @@
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -614,9 +614,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -631,9 +631,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -648,9 +648,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -665,9 +665,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -682,9 +682,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -699,9 +699,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -716,9 +716,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -733,9 +733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -750,9 +750,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -767,9 +767,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -784,9 +784,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -803,13 +803,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -826,13 +826,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -849,13 +849,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -872,13 +872,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -895,13 +895,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -918,13 +918,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -941,13 +941,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -964,13 +964,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
@@ -985,9 +985,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -995,7 +995,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
"@img/sharp-wasm32": "0.35.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -1005,9 +1005,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1025,9 +1025,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1045,9 +1045,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1833,9 +1833,9 @@
|
||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.15",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
|
||||
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
|
||||
"version": "3.4.17",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
|
||||
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -2782,9 +2782,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2799,31 +2799,31 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
"@img/sharp-darwin-arm64": "0.35.5",
|
||||
"@img/sharp-darwin-x64": "0.35.5",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||
"@img/sharp-linux-arm": "0.35.5",
|
||||
"@img/sharp-linux-arm64": "0.35.5",
|
||||
"@img/sharp-linux-ppc64": "0.35.5",
|
||||
"@img/sharp-linux-riscv64": "0.35.5",
|
||||
"@img/sharp-linux-s390x": "0.35.5",
|
||||
"@img/sharp-linux-x64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||
"@img/sharp-win32-arm64": "0.35.5",
|
||||
"@img/sharp-win32-ia32": "0.35.5",
|
||||
"@img/sharp-win32-x64": "0.35.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
@@ -2832,9 +2832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
|
||||
+2
-1
@@ -277,6 +277,7 @@
|
||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
"./tools/services": "./src/tools/services.js",
|
||||
"./view": "./src/view/index.js"
|
||||
"./view": "./src/view/index.js",
|
||||
"./agent/world-packet": "./src/agent/worldPacket.js"
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@
|
||||
"server/providers/space/launch-library.js",
|
||||
"src/data/spaceProviderRequests.js",
|
||||
"src/tooling/spaceProviders.test.mjs",
|
||||
"src/tooling/ll2Integration.test.mjs",
|
||||
"server/providers/terrain.js",
|
||||
"server/providers/traffic.js",
|
||||
"server/providers/firms.js",
|
||||
@@ -446,6 +447,7 @@
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
@@ -635,6 +637,7 @@
|
||||
"src/voice/control.js",
|
||||
"src/voice/realtimeBackend.js",
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/agent/worldPacket.test.mjs",
|
||||
"src/voice/layerSummary.js",
|
||||
"build/application-html.js",
|
||||
"src/app/sourceSlot.js",
|
||||
@@ -732,6 +735,7 @@
|
||||
"src/voice/session.test.mjs",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/layers/earthquakes/records.test.mjs",
|
||||
"src/sources/sourceSlot.js",
|
||||
"src/data/feedState.js",
|
||||
"src/app/localGeojsonServices.js",
|
||||
@@ -1025,6 +1029,7 @@
|
||||
"scripts/qa-context-arrivals.mjs",
|
||||
"src/tools/catalog.test.mjs",
|
||||
"src/tools/area.test.mjs",
|
||||
"src/tools/results.test.mjs",
|
||||
"src/tools/queries/queries.test.mjs",
|
||||
"src/tools/mcp/mcp.test.mjs",
|
||||
"src/tools/localServer.test.mjs",
|
||||
@@ -1061,5 +1066,6 @@
|
||||
"src/ui/flyToDataSource.test.mjs",
|
||||
"scripts/assemble-changelog.mjs",
|
||||
"src/tooling/changelogFragments.test.mjs",
|
||||
"src/data/gbfsProxyRateLimit.test.mjs"
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs"
|
||||
]
|
||||
@@ -114,7 +114,11 @@
|
||||
"exports": ["./server/providers/terrain"],
|
||||
"modules": [
|
||||
"server/providers/terrain.js",
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
"src/data/terrainHeightsProxy.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"src/sources/rateLimit.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -301,6 +305,7 @@
|
||||
"server/providers/regional/weather-effects.js",
|
||||
"server/providers/regional/weather.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/geoDistance.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
@@ -377,6 +382,7 @@
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
@@ -915,6 +921,7 @@
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/layers/flights/controller.js",
|
||||
@@ -1009,6 +1016,7 @@
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
@@ -1036,6 +1044,7 @@
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
@@ -1249,6 +1258,7 @@
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/trafficBounds.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/trafficFlowStyle.js",
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
@@ -1297,6 +1307,7 @@
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/geoDistance.js",
|
||||
"src/sources/gbfsStations.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
@@ -1340,6 +1351,7 @@
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/geoDistance.js",
|
||||
"src/layers/cctv/modelSpecs.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
@@ -1527,6 +1539,7 @@
|
||||
"exports": ["./geography/natural-earth", "./geography/neighborhoods"],
|
||||
"modules": [
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
@@ -1661,6 +1674,7 @@
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/data/bhoteKoshiShotPaths.js",
|
||||
"src/annotations/annotationEngine.js",
|
||||
"src/geoDistance.js",
|
||||
"src/annotations/annotationResolver.js",
|
||||
"src/annotations/hybridAnnotationRenderer.js",
|
||||
"src/annotations/index.js",
|
||||
@@ -1832,6 +1846,7 @@
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/firstRunExperience.js",
|
||||
"src/hud.js",
|
||||
"src/hudLocality.js",
|
||||
@@ -2206,6 +2221,7 @@
|
||||
"src/voice/commands.js",
|
||||
"src/voice/control.js",
|
||||
"src/voice/gevActions.js",
|
||||
"src/agent/worldPacket.js",
|
||||
"src/voice/speech.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/voice/earcon.js",
|
||||
@@ -2513,6 +2529,7 @@
|
||||
"src/data/bhoteKoshiIncidentPlaces.js",
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/data/bhoteKoshiShotPaths.js",
|
||||
"src/geoDistance.js",
|
||||
"src/annotations/annotationResolver.js",
|
||||
"src/annotations/resolver.js",
|
||||
"src/app/catalog.js",
|
||||
@@ -2619,6 +2636,7 @@
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/alpr/index.js",
|
||||
@@ -3075,6 +3093,7 @@
|
||||
"src/data/approxDistance.js",
|
||||
"src/data/dataCredits.js",
|
||||
"src/data/geoid.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/local_data/natural_earth/marine.json",
|
||||
"src/data/local_data/natural_earth/regions.json",
|
||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||
@@ -3328,7 +3347,7 @@
|
||||
},
|
||||
"regional-source": {
|
||||
"exports": ["./sources/regional"],
|
||||
"modules": ["src/data/regionalModel.js"],
|
||||
"modules": ["src/data/regionalModel.js", "src/geoDistance.js"],
|
||||
"external": []
|
||||
},
|
||||
"voice-action-schemas": {
|
||||
@@ -3491,6 +3510,7 @@
|
||||
"src/search/google.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/geoDistance.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/nominatim.js"
|
||||
@@ -3514,6 +3534,7 @@
|
||||
"src/nominatimGeocode.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/geoDistance.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
@@ -3529,6 +3550,7 @@
|
||||
"server/providers/transitHistory.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
@@ -3544,6 +3566,7 @@
|
||||
"modules": [
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
@@ -3565,6 +3588,7 @@
|
||||
"src/data/geoid.js",
|
||||
"src/data/groundFloor.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/labelArbiter.js",
|
||||
@@ -3622,6 +3646,7 @@
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
@@ -3669,6 +3694,7 @@
|
||||
"exports": ["./sources/transit-service"],
|
||||
"modules": [
|
||||
"src/data/transitFeeds.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/sources/httpBody.js",
|
||||
@@ -3764,6 +3790,7 @@
|
||||
"src/tools/queries/maritime.js",
|
||||
"src/tools/queries/share.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/sources/gbfsStations.js",
|
||||
@@ -3793,7 +3820,8 @@
|
||||
"src/view/index.js",
|
||||
"src/tools/views.js",
|
||||
"src/tools/globePanel.js",
|
||||
"src/tools/queries/panelRequest.js"
|
||||
"src/tools/queries/panelRequest.js",
|
||||
"src/geoDistance.js"
|
||||
],
|
||||
"external": ["satellite.js"]
|
||||
},
|
||||
@@ -3924,5 +3952,18 @@
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"agent-world-packet": {
|
||||
"exports": ["./agent/world-packet"],
|
||||
"modules": [
|
||||
"src/agent/worldPacket.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/data/feedState.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ const ADSBDB_MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
/**
|
||||
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
|
||||
* airports) and hex → aircraft type/registration. Free community API — cached
|
||||
* aggressively: ONE upstream request per new key ever (404s negative-cached),
|
||||
* persisted to disk so restarts don't re-hammer it. Adapted from skylight
|
||||
* aggressively: successes and 404s are kept per key for 24 hours (TTL_MS) and
|
||||
* persisted to disk so restarts don't re-hammer it; other failures are not
|
||||
* cached. Adapted from skylight
|
||||
* (MIT) server/src/enrich/routes.ts.
|
||||
*/
|
||||
export function adsbdbProxy() {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
loadNycSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -144,6 +145,14 @@ const LIVE_PACKS = [
|
||||
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
|
||||
load: loadLithuaniaSources,
|
||||
},
|
||||
{
|
||||
// FL511's terms limit its content to individual, personal use (other use
|
||||
// needs FDOT's written consent), so the pack is off unless the operator
|
||||
// opts in (see DATA_SOURCES.md).
|
||||
name: 'florida',
|
||||
enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'),
|
||||
load: loadFloridaSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -113,6 +113,27 @@ export const CALTRANS_ANCHORS = [
|
||||
{ lat: 32.7157, lon: -117.1611 }, // San Diego
|
||||
{ lat: 38.5816, lon: -121.4944 }, // Sacramento
|
||||
];
|
||||
export const FLORIDA_CCTV_URL =
|
||||
'https://services.arcgis.com/3wFbqsFPLeKqOlIK/arcgis/rest/services/FL511_Traffic_Cameras/FeatureServer/0/query';
|
||||
export const FLORIDA_IMAGE_HOSTS = [
|
||||
'images-dis.divas.cloud',
|
||||
'snapshots.divas.cloud',
|
||||
];
|
||||
export const DEFAULT_FLORIDA_MAX_SOURCES = 500;
|
||||
/** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */
|
||||
export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Catalog page ceiling: 40 pages covers 4,000+ cameras even at 100 rows a page. */
|
||||
export const FLORIDA_MAX_CATALOG_PAGES = 40;
|
||||
export const FLORIDA_ANCHORS = [
|
||||
{ lat: 25.7617, lon: -80.1918 }, // Miami
|
||||
{ lat: 28.5384, lon: -81.3789 }, // Orlando
|
||||
{ lat: 27.9506, lon: -82.4572 }, // Tampa
|
||||
{ lat: 30.3322, lon: -81.6557 }, // Jacksonville
|
||||
{ lat: 30.4383, lon: -84.2807 }, // Tallahassee
|
||||
{ lat: 26.1224, lon: -80.1373 }, // Fort Lauderdale
|
||||
{ lat: 26.7153, lon: -80.0534 }, // West Palm Beach
|
||||
{ lat: 30.4213, lon: -87.2169 }, // Pensacola (panhandle)
|
||||
];
|
||||
/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */
|
||||
export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
|
||||
export const TFL_IMAGE_ORIGIN =
|
||||
|
||||
@@ -377,6 +377,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Florida statewide, Key West to Georgia state line, with slack at each edge */
|
||||
export function isLikelyFloridaCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 24.3 &&
|
||||
lat <= 31.1 &&
|
||||
lon >= -87.7 &&
|
||||
lon <= -79.8
|
||||
);
|
||||
}
|
||||
|
||||
/** New York City's five boroughs, with slack for the harbor and city line. */
|
||||
export function isLikelyNycCoordinate(lat, lon) {
|
||||
return (
|
||||
|
||||
@@ -55,6 +55,12 @@ import {
|
||||
DEFAULT_CALGARY_MAX_SOURCES,
|
||||
CALGARY_DOWNTOWN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
FLORIDA_CCTV_URL,
|
||||
FLORIDA_IMAGE_HOSTS,
|
||||
DEFAULT_FLORIDA_MAX_SOURCES,
|
||||
FLORIDA_ANCHORS,
|
||||
FLORIDA_MAX_CATALOG_BYTES,
|
||||
FLORIDA_MAX_CATALOG_PAGES,
|
||||
CATALONIA_CAMERAS_URL,
|
||||
CATALONIA_MAX_CATALOG_BYTES,
|
||||
CATALONIA_IMAGE_HOSTS,
|
||||
@@ -113,6 +119,7 @@ import {
|
||||
isLikelyTexasCoordinate,
|
||||
isLikelyNswCoordinate,
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyFloridaCoordinate,
|
||||
isLikelyCataloniaCoordinate,
|
||||
decodeNumericEntities,
|
||||
isLikelyNycCoordinate,
|
||||
@@ -2784,3 +2791,203 @@ export async function loadTorontoSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** FL511 DIRECTION codes as headings; "NOT DIRECTIONAL" has none. */
|
||||
const FLORIDA_DIRECTION_HEADINGS = Object.freeze({
|
||||
N: 0,
|
||||
E: 90,
|
||||
S: 180,
|
||||
W: 270,
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable camera id from a frame URL. FL511's ID column repeats across regions
|
||||
* (ID 1220 is both an I-75 and an I-95 camera), so the image channel is the
|
||||
* key, as Calgary keys on its frame filename. Falls back to a path slug.
|
||||
*
|
||||
* @param {string} imageUrl - A host-pinned FL511 frame URL.
|
||||
* @returns {?string} Provider-stable id, or null when underivable.
|
||||
*/
|
||||
export function floridaCameraId(imageUrl) {
|
||||
let path;
|
||||
try {
|
||||
path = new URL(String(imageUrl ?? '')).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const channel = /\/chan-(\d+)_[a-z]+\.jpg$/i.exec(path);
|
||||
if (channel) return `fl-${channel[1]}`;
|
||||
const slug = path
|
||||
.replace(/^\/+|\.[a-z0-9]+$/gi, '')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.toLowerCase();
|
||||
return slug ? `fl-${slug}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One FL511 ArcGIS feature -> one catalog source, or null. DIRECTION is a
|
||||
* one-letter code the free-text parser won't read, so a table maps it.
|
||||
*
|
||||
* @param {object} feature - ArcGIS feature from the FL511 camera layer.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function floridaCameraToSource(feature) {
|
||||
const a = feature?.attributes;
|
||||
if (!a || typeof a !== 'object') return null;
|
||||
const lat = toFiniteNumber(a.LATITUDE);
|
||||
const lon = toFiniteNumber(a.LONGITUDE);
|
||||
if (!isLikelyFloridaCoordinate(lat, lon)) return null;
|
||||
|
||||
const imageUrl = String(a.IMAGE || '');
|
||||
let image;
|
||||
try {
|
||||
image = new URL(imageUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Official image hosts only, HTTPS, no embedded credentials.
|
||||
if (
|
||||
image.protocol !== 'https:' ||
|
||||
!FLORIDA_IMAGE_HOSTS.includes(image.host) ||
|
||||
image.username ||
|
||||
image.password
|
||||
)
|
||||
return null;
|
||||
|
||||
const cameraId = floridaCameraId(imageUrl);
|
||||
if (!cameraId) return null;
|
||||
|
||||
const heading =
|
||||
FLORIDA_DIRECTION_HEADINGS[
|
||||
String(a.DIRECTION || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
];
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: String(a.DESCRIPT || '').trim() || `FL511 ${cameraId.slice(3)}`,
|
||||
city: String(a.COUNTY ? `${a.COUNTY} County` : 'Florida'),
|
||||
cityId: 'florida',
|
||||
provider: 'FL511',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: 5, // Coastal prior; the client's ground snap corrects.
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'fl511-open-data',
|
||||
license:
|
||||
'FL511 (FDOT), individual personal use only; other use needs FDOT written consent',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch FL511 (Florida DOT) cameras, keyless: an ArcGIS FeatureServer paged
|
||||
* up to 2,000 rows at a time, ordered by the layer's object id so offsets are
|
||||
* stable. Paging follows `exceededTransferLimit` and advances by the rows each
|
||||
* page returned, so a lower server-side max record count still reads the whole
|
||||
* layer. Frames are stills on the Divas Cloud hosts. A redirect on any page
|
||||
* drops the pack; any other failed page keeps the rows already read.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadFloridaSourcesFromOpenData() {
|
||||
try {
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
const pageSize = 2000;
|
||||
let offset = 0;
|
||||
// Page ceiling, in case the service ignores resultOffset.
|
||||
for (let page = 0; ; page += 1) {
|
||||
if (page >= FLORIDA_MAX_CATALOG_PAGES) {
|
||||
console.warn(
|
||||
`[CCTV] Florida catalog stopped at the ${FLORIDA_MAX_CATALOG_PAGES}-page ceiling; the catalog may be incomplete`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// OBJECTID_1 is this layer's object id field (a plain OBJECTID is a 400).
|
||||
const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&orderByFields=OBJECTID_1&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog page failed:',
|
||||
error?.message || error,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Release the body on every path that will not read it.
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog redirected; redirects are not followed',
|
||||
);
|
||||
await discard();
|
||||
return [];
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Florida camera download failed:', resp.status);
|
||||
await discard();
|
||||
break;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await readResponseJsonCapped(resp, FLORIDA_MAX_CATALOG_BYTES);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog page unreadable:',
|
||||
error?.message || error,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const feats = Array.isArray(body?.features) ? body.features : [];
|
||||
for (const feature of feats) {
|
||||
const camera = floridaCameraToSource(feature);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
// ArcGIS sets exceededTransferLimit while more rows remain. A page may
|
||||
// be shorter than pageSize when the layer's max record count is lower.
|
||||
if (body?.exceededTransferLimit !== true || feats.length === 0) break;
|
||||
offset += feats.length;
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_FLORIDA_MAX_SOURCES || DEFAULT_FLORIDA_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(4200, Math.floor(maxRaw)))
|
||||
: DEFAULT_FLORIDA_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, FLORIDA_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Florida camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -41,3 +41,48 @@ export function sameSiteGated(handler) {
|
||||
return handler(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy header values arrive client-controlled; read one only when it is a
|
||||
* single scheme token, never a comma-separated chain.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {'http:'|'https:'}
|
||||
*/
|
||||
function forwardedProtocol(req) {
|
||||
if (req.socket?.encrypted) return 'https:';
|
||||
const proto = String(req.headers?.['x-forwarded-proto'] || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return proto === 'https' ? 'https:' : 'http:';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-site gate for keyless routes the map needs in order to render
|
||||
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
|
||||
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
|
||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||
* those protect credentials and spend, which a keyless route has neither of,
|
||||
* and refusing them would break rendering for an instance served behind the
|
||||
* reverse proxy the README documents. A single `X-Forwarded-Proto` supplies
|
||||
* the scheme an `Origin` is compared against. That header cannot be attached
|
||||
* to a cross-site browser request without a CORS preflight this server never
|
||||
* answers, and `Sec-Fetch-Site` is still enforced regardless.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @param {import('node:http').ServerResponse} res
|
||||
* @returns {boolean} True when a 403 was sent.
|
||||
*/
|
||||
export function admitSameSiteKeyless(req, res) {
|
||||
const verdict = admitSameSiteRequest({
|
||||
hostHeader: req.headers?.host,
|
||||
protocol: forwardedProtocol(req),
|
||||
origin: req.headers?.origin,
|
||||
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||
proxyHeaders: {},
|
||||
});
|
||||
if (verdict.ok) return false;
|
||||
res.statusCode = verdict.status;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify({ error: verdict.error }));
|
||||
return true;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) {
|
||||
const normalized = String(token || '').trim();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'User-Agent':
|
||||
'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
...(normalized ? { Authorization: `Token ${normalized}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
terrainPointKey,
|
||||
validTerrainResult,
|
||||
} from '../../src/data/terrainHeightsProxy.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
|
||||
/**
|
||||
* Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height
|
||||
@@ -20,11 +22,31 @@ import {
|
||||
* Only missing/stale points go upstream; the response is rebuilt in exact
|
||||
* request order. Larger requests are chunked sequentially, and one failing
|
||||
* chunk does not discard the chunks that resolved.
|
||||
*
|
||||
* Bounds, because every distinct 5dp point is its own key: coordinates must be
|
||||
* in WGS-84 range (rejected at parse), the point cache has an entry ceiling
|
||||
* with oldest-first eviction, an over-large cache file is not read back, and
|
||||
* the route carries an always-on per-client limiter. The route also refuses
|
||||
* cross-site browser requests, so another web page cannot use the visitor's
|
||||
* browser to fill the cache or spend the visitor's own rate-limit quota.
|
||||
* Reverse-proxied same-origin requests are admitted: terrain is keyless and
|
||||
* needed for rendering, and a 403 would send every chunk to the geoid fallback.
|
||||
*/
|
||||
export function terrainHeightsProxy() {
|
||||
export function terrainHeightsProxy(options = {}) {
|
||||
// Destructured in the body, not the signature: `proxyErrorResponses.test.mjs`
|
||||
// extracts these proxies by slicing to the first closing brace in column
|
||||
// zero, so a multi-line parameter list would cut the function in half.
|
||||
// Overridable so the ceilings can be exercised as behaviour rather than
|
||||
// asserted as constants; production constructs this with no arguments.
|
||||
const {
|
||||
cacheMaxEntries = 50_000,
|
||||
cacheMaxDiskBytes = 24 * 1024 * 1024,
|
||||
cachePath,
|
||||
} = options;
|
||||
const TTL_MS = 30 * 24 * 3600_000;
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||
const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json');
|
||||
const CACHE_PATH =
|
||||
cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json');
|
||||
const CACHE_DIR = path.dirname(CACHE_PATH);
|
||||
// Sized against measured upstream latency, not the documented page cap.
|
||||
// Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12,
|
||||
// a 2x swing within one hour). 256 points therefore costs 22-48 s and blows
|
||||
@@ -34,9 +56,50 @@ export function terrainHeightsProxy() {
|
||||
// that range.
|
||||
const UPSTREAM_CHUNK = 64;
|
||||
const MAX_POINTS = 2000;
|
||||
/**
|
||||
* Entry ceiling for the point cache, and with it the file written from it.
|
||||
*
|
||||
* Every distinct 5dp point is a separate key, so without a ceiling the map
|
||||
* — and the JSON dumped from it verbatim — grow for as long as a caller
|
||||
* supplies new coordinates. Measured against real use rather than guessed:
|
||||
* this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in
|
||||
* 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is
|
||||
* eight times that working set and ~8 MB on disk, comfortably inside the
|
||||
* 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file.
|
||||
*/
|
||||
const CACHE_MAX_ENTRIES = cacheMaxEntries;
|
||||
/** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */
|
||||
const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes;
|
||||
/**
|
||||
* Requests/min/client. Terrain is keyless and free, so this guards the point
|
||||
* cache and the upstream community service rather than a bill. Sized from
|
||||
* measurement: across a full `npm run test:track` run — the heaviest
|
||||
* exercise this app has — the route served 10 requests, peaking at 8/min.
|
||||
* 90/min is an order of magnitude above that, and the band `/api/overpass`
|
||||
* and `/api/military-installations` already use.
|
||||
*/
|
||||
const RATE_PER_MIN = 90;
|
||||
const allow = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: RATE_PER_MIN,
|
||||
globalMax: RATE_PER_MIN * 3,
|
||||
});
|
||||
|
||||
/** @type {Map<string, {at:number, result:object}>} keyed by canonical 5dp lon/lat. */
|
||||
const mem = new Map();
|
||||
|
||||
/**
|
||||
* Drop the oldest entries until the map is back inside its ceiling.
|
||||
* Oldest-first by fetch time, the same shape `trackBackfillProxies` uses.
|
||||
* Terrain does not move, so evicting an old point costs one refetch, never
|
||||
* a wrong answer.
|
||||
*/
|
||||
function evictOverflow() {
|
||||
if (mem.size <= CACHE_MAX_ENTRIES) return;
|
||||
const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at);
|
||||
for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1)
|
||||
mem.delete(byAge[i][0]);
|
||||
}
|
||||
/** @type {Map<string, Promise<Array<object>>>} single-flight per missing-point subset. */
|
||||
const inflight = new Map();
|
||||
let diskLoaded = false;
|
||||
@@ -47,6 +110,21 @@ export function terrainHeightsProxy() {
|
||||
if (diskLoaded) return;
|
||||
diskLoaded = true;
|
||||
try {
|
||||
// A cache file past the ceiling is not read back into memory: loading it
|
||||
// would reinstate exactly the unbounded map the ceiling exists to stop.
|
||||
// The next flush rewrites a bounded one.
|
||||
//
|
||||
// "Cannot measure it" is not "too large": if stat is unavailable, fall
|
||||
// through and let readFile decide, so an absent file behaves exactly as
|
||||
// it did before this guard existed.
|
||||
let sizeBytes = null;
|
||||
try {
|
||||
sizeBytes = (await fsp.stat(CACHE_PATH)).size;
|
||||
} catch {
|
||||
sizeBytes = null;
|
||||
}
|
||||
if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES)
|
||||
throw new Error('cache file too large');
|
||||
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
|
||||
const pointEntries =
|
||||
parsed?.version === 2 &&
|
||||
@@ -87,6 +165,7 @@ export function terrainHeightsProxy() {
|
||||
}
|
||||
diskDirty = mem.size > 0;
|
||||
}
|
||||
evictOverflow();
|
||||
} catch {
|
||||
/* no disk cache yet */
|
||||
}
|
||||
@@ -157,12 +236,25 @@ export function terrainHeightsProxy() {
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/terrain/heights', async (req, res) => {
|
||||
// Refuse cross-site browser requests (an <img> or fetch from another
|
||||
// site) before the limiter, cache or upstream see them. Same-origin app
|
||||
// requests, non-browser loopback callers (MCP panel_request) and the app
|
||||
// served behind a reverse proxy pass.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
const send = (status, bodyObj) => {
|
||||
if (res.headersSent) return;
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(bodyObj));
|
||||
};
|
||||
try {
|
||||
if (!allow(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '10',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
await loadDiskOnce();
|
||||
const parsedUrl = new URL(req.url || '', 'http://internal');
|
||||
const rawPoints = parsedUrl.searchParams.get('points');
|
||||
@@ -187,7 +279,10 @@ export function terrainHeightsProxy() {
|
||||
fetchMissing: fetchMissingSingleFlight,
|
||||
ttlMs: TTL_MS,
|
||||
});
|
||||
if (outcome.cacheChanged) diskDirty = true;
|
||||
if (outcome.cacheChanged) {
|
||||
evictOverflow();
|
||||
diskDirty = true;
|
||||
}
|
||||
if (outcome.upstreamError) {
|
||||
console.warn(
|
||||
'[terrain-heights-proxy] refresh incomplete' +
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Directories the app writes to while it runs. None is source, and none
|
||||
* should reload anything.
|
||||
*/
|
||||
const RUNTIME_WRITE_GLOBS = Object.freeze([
|
||||
'**/.gev-cache/**',
|
||||
'**/.gev-logs/**',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Keep the directories the app writes to out of the dev server's file watcher.
|
||||
*
|
||||
* Disk-cached providers write a file per response into `.gev-cache`, and the
|
||||
* voice debug log appends to `.gev-logs`. Left watched, each write costs the
|
||||
* watcher stat calls on libuv's thread pool, which `getaddrinfo` shares; heavy
|
||||
* cache writes have been seen to delay upstream DNS lookups.
|
||||
*
|
||||
* @returns {import('vite').Plugin} Config-only plugin; no server hooks.
|
||||
*/
|
||||
export function unwatchedRuntimeDirsPlugin() {
|
||||
return {
|
||||
name: 'gev-unwatched-runtime-dirs',
|
||||
apply: 'serve',
|
||||
config: () => ({
|
||||
server: { watch: { ignored: [...RUNTIME_WRITE_GLOBS] } },
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { localProviderPlugins } from '../providers/local.js';
|
||||
import { localMcpPlugin } from '../mcp/plugin.js';
|
||||
import { apiNotFoundPlugin } from './api-not-found.js';
|
||||
import { standaloneVoiceTools } from './voiceTools.js';
|
||||
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
@@ -20,6 +21,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
|
||||
localMcpPlugin(),
|
||||
apiNotFoundPlugin(),
|
||||
unwatchedRuntimeDirsPlugin(),
|
||||
],
|
||||
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
} from '../data/layerSnapshot.js';
|
||||
import { contextModeWord } from '../contextModePolicy.js';
|
||||
|
||||
export const TRACKABLE_FAMILIES = Object.freeze([
|
||||
{ layerId: 'flights', kind: 'aircraft' },
|
||||
{ layerId: 'military', kind: 'aircraft' },
|
||||
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
||||
{ layerId: 'satellites', kind: 'satellite' },
|
||||
]);
|
||||
|
||||
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
|
||||
{ field: 'mode', emptyAs: 'off' },
|
||||
{ field: 'entering', emptyAs: null },
|
||||
{ field: 'priorMode', emptyAs: null },
|
||||
]);
|
||||
|
||||
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
||||
'context',
|
||||
'contextRollback',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Translate context-mode payloads into the public tool vocabulary while
|
||||
* retaining the internal id beside each translated field.
|
||||
*/
|
||||
export function withContextModeVocabulary(state) {
|
||||
if (!state || typeof state !== 'object') return state;
|
||||
let out = state;
|
||||
const mutable = () => {
|
||||
if (out === state) out = { ...state };
|
||||
return out;
|
||||
};
|
||||
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
|
||||
if (!(field in state)) continue;
|
||||
const internal = state[field] ?? null;
|
||||
const target = mutable();
|
||||
target[field] = contextModeWord(internal, { emptyAs });
|
||||
target[`${field}Internal`] = internal;
|
||||
}
|
||||
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
|
||||
const nested = state[field];
|
||||
if (!nested || typeof nested !== 'object') continue;
|
||||
const translated = withContextModeVocabulary(nested);
|
||||
if (translated !== nested) mutable()[field] = translated;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read tracked entities from their canonical layer owners. */
|
||||
export function collectTrackedEntities(dataManager) {
|
||||
const tracked = [];
|
||||
for (const family of TRACKABLE_FAMILIES) {
|
||||
const module = dataManager?.layers?.get(family.layerId)?.module;
|
||||
if (!module) continue;
|
||||
try {
|
||||
const info =
|
||||
family.kind === 'vessel'
|
||||
? module.getSelectedInfo?.()
|
||||
: module.getTrackedInfo?.();
|
||||
if (info)
|
||||
tracked.push({
|
||||
kind: family.kind,
|
||||
layerId: family.layerId,
|
||||
...info,
|
||||
});
|
||||
} catch {
|
||||
// A not-yet-ready layer contributes no tracked observation.
|
||||
}
|
||||
}
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/** Read the active Contacts window from its owning awareness layer. */
|
||||
export function activeContactsWindow(dataManager) {
|
||||
try {
|
||||
const layer = dataManager?.layers?.get('military-awareness')?.module;
|
||||
return (
|
||||
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the model-free fast World Packet from existing application owners.
|
||||
*
|
||||
* This performs no network requests and intentionally mirrors the historical
|
||||
* get_current_view_state payload so voice can delegate without behavior drift.
|
||||
*/
|
||||
export function getWorldPacket(options = {}) {
|
||||
const {
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager,
|
||||
sceneDirector = null,
|
||||
now: requestedNow,
|
||||
} = options ?? {};
|
||||
// A default parameter only covers undefined; null or NaN would age every
|
||||
// feed against the epoch and report stale data as "just now".
|
||||
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
|
||||
if (!viewer?.camera?.positionWC)
|
||||
throw new TypeError('World Packet requires a viewer camera');
|
||||
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||
throw new TypeError('World Packet requires a data manager');
|
||||
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
||||
// current enabled-only view keeps the model's context budget bounded.
|
||||
const rows = dataManager.getAll();
|
||||
const enabledRows = rows.filter((layer) => layer.enabled);
|
||||
const contactsWindow = activeContactsWindow(dataManager);
|
||||
const contextState =
|
||||
typeof styleManager?.getContextModeState === 'function'
|
||||
? withContextModeVocabulary(styleManager.getContextModeState())
|
||||
: null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
heightM: cartographic.height,
|
||||
},
|
||||
style: styleManager?.activeStyle || 'normal',
|
||||
context: contextState
|
||||
? {
|
||||
...contextState,
|
||||
...(contactsWindow ? { contactsWindow } : {}),
|
||||
}
|
||||
: null,
|
||||
cockpit:
|
||||
typeof styleManager?.getCockpitState === 'function'
|
||||
? styleManager.getCockpitState()
|
||||
: null,
|
||||
controls:
|
||||
typeof styleManager?.getControlState === 'function'
|
||||
? styleManager.getControlState()
|
||||
: null,
|
||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||
tracked: collectTrackedEntities(dataManager),
|
||||
disabledLayerCount: rows.length - enabledRows.length,
|
||||
// Preserve get_current_view_state's existing compact enabled-only shape.
|
||||
layers: enabledRows.map((layer) => {
|
||||
const snapshot = layerSnapshot(layer, { now });
|
||||
return {
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
enabled: layer.enabled,
|
||||
count: layer.stats?.count || 0,
|
||||
error: layer.stats?.error || null,
|
||||
feedState: snapshot.feedState,
|
||||
source: snapshot.source,
|
||||
lastUpdate: snapshot.lastUpdate,
|
||||
};
|
||||
}),
|
||||
feedProvenance: feedProvenanceEnvelope(
|
||||
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
|
||||
{ now },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
import { getWorldPacket } from './worldPacket.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
|
||||
function withoutVoicePresentation(result) {
|
||||
const packet = { ...result };
|
||||
delete packet.say;
|
||||
delete packet.display;
|
||||
delete packet.schedule;
|
||||
return packet;
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
|
||||
const viewer = {
|
||||
trackedEntity: undefined,
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: {
|
||||
canvas: {
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
globe: { getHeight: () => 0 },
|
||||
tweens: [],
|
||||
},
|
||||
camera: {
|
||||
positionWC: position,
|
||||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(-45),
|
||||
moveEnd: { addEventListener: () => () => {} },
|
||||
cancelFlight() {},
|
||||
lookAtTransform() {},
|
||||
},
|
||||
};
|
||||
const rows = [
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 3,
|
||||
source: 'fixture',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
const dataManager = {
|
||||
layers: new Map([['traffic', { module: null }]]),
|
||||
getAll: () => rows,
|
||||
};
|
||||
const styleManager = {
|
||||
activeStyle: 'normal',
|
||||
getContextModeState: () => ({ mode: 'flights' }),
|
||||
getCockpitState: () => null,
|
||||
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||
};
|
||||
return { viewer, dataManager, styleManager };
|
||||
}
|
||||
|
||||
test('World Packet preserves get_current_view_state parity', async () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||
const voice = await runner('get_current_view_state');
|
||||
|
||||
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||
assert.equal(packet.context.mode, 'contacts');
|
||||
assert.equal(packet.context.modeInternal, 'flights');
|
||||
assert.equal(packet.layers[0].feedState, 'nominal');
|
||||
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||
});
|
||||
|
||||
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
dataManager.getAll = () => [
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
source: 'fixture',
|
||||
error: 'fixture unavailable',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
assert.equal(packet.layers[0].count, 0);
|
||||
assert.notEqual(packet.layers[0].feedState, 'nominal');
|
||||
assert.notEqual(packet.feedProvenance.overall, 'nominal');
|
||||
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
|
||||
});
|
||||
|
||||
// The 2026-10 main voice contract omits disabled layers from the model payload.
|
||||
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
|
||||
const { viewer, dataManager, styleManager } = harness();
|
||||
const rows = dataManager.getAll();
|
||||
rows.push({
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes',
|
||||
enabled: false,
|
||||
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||
});
|
||||
let reads = 0;
|
||||
dataManager.getAll = () => {
|
||||
reads += 1;
|
||||
return rows;
|
||||
};
|
||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
|
||||
assert.equal(packet.disabledLayerCount, 1);
|
||||
assert.deepEqual(
|
||||
packet.layers.map((layer) => layer.id),
|
||||
['traffic'],
|
||||
);
|
||||
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||
|
||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||
const voice = await runner('get_current_view_state');
|
||||
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||
});
|
||||
|
||||
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
|
||||
|
||||
// Exercises every packet field: context vocabulary and Contacts window,
|
||||
// cockpit, controls, scene playback, tracked families (one not ready),
|
||||
// enabled and disabled layers, and an aged and an unavailable feed.
|
||||
function pinnedHarness() {
|
||||
const { viewer } = harness();
|
||||
const rows = [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 12,
|
||||
source: 'fixture-adsb',
|
||||
lastUpdate: PINNED_NOW - 90_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
source: 'fixture',
|
||||
error: 'fixture unavailable',
|
||||
lastUpdate: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes',
|
||||
enabled: false,
|
||||
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||
},
|
||||
];
|
||||
const module = (methods) => ({ module: methods });
|
||||
const dataManager = {
|
||||
layers: new Map([
|
||||
[
|
||||
'flights',
|
||||
module({
|
||||
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
|
||||
}),
|
||||
],
|
||||
[
|
||||
'ais-live-vessels',
|
||||
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
|
||||
],
|
||||
[
|
||||
'satellites',
|
||||
module({
|
||||
getTrackedInfo: () => {
|
||||
throw new Error('not ready');
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'military-awareness',
|
||||
module({
|
||||
getContextSnapshot: () => ({ snapshot: true }),
|
||||
contactsWindowFromSnapshot: (snapshot) =>
|
||||
snapshot ? { windowMin: 30, count: 4 } : null,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
getAll: () => rows,
|
||||
};
|
||||
const styleManager = {
|
||||
activeStyle: 'night-vision',
|
||||
getContextModeState: () => ({
|
||||
mode: 'flights',
|
||||
entering: null,
|
||||
priorMode: null,
|
||||
}),
|
||||
getCockpitState: () => ({ active: false }),
|
||||
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||
};
|
||||
const sceneDirector = {
|
||||
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
|
||||
};
|
||||
return { viewer, dataManager, styleManager, sceneDirector };
|
||||
}
|
||||
|
||||
// Captured from main's get_current_view_state before the extraction
|
||||
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
|
||||
// minus the speech envelope. Key order is part of the contract.
|
||||
const PRE_EXTRACTION_PAYLOAD = {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: 30.259999999999994,
|
||||
longitude: -97.74,
|
||||
heightM: 499.99999999901877,
|
||||
},
|
||||
style: 'night-vision',
|
||||
context: {
|
||||
mode: 'contacts',
|
||||
entering: null,
|
||||
priorMode: null,
|
||||
modeInternal: 'flights',
|
||||
enteringInternal: null,
|
||||
priorModeInternal: null,
|
||||
contactsWindow: { windowMin: 30, count: 4 },
|
||||
},
|
||||
cockpit: { active: false },
|
||||
controls: { hud: { visible: 'auto' } },
|
||||
scenePlayback: { playing: false, sceneId: 'fixture' },
|
||||
tracked: [
|
||||
{
|
||||
kind: 'aircraft',
|
||||
layerId: 'flights',
|
||||
icao24: 'abc123',
|
||||
callsign: 'TEST1',
|
||||
},
|
||||
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
|
||||
],
|
||||
disabledLayerCount: 1,
|
||||
layers: [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
count: 12,
|
||||
error: null,
|
||||
feedState: 'nominal',
|
||||
source: 'fixture-adsb',
|
||||
lastUpdate: 1792238310000,
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
count: 0,
|
||||
error: 'fixture unavailable',
|
||||
feedState: 'unavailable',
|
||||
source: 'fixture',
|
||||
lastUpdate: 0,
|
||||
},
|
||||
],
|
||||
feedProvenance: {
|
||||
overall: 'unavailable',
|
||||
layers: [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: true,
|
||||
feedState: 'nominal',
|
||||
source: 'fixture-adsb',
|
||||
count: 12,
|
||||
lastUpdate: 1792238310000,
|
||||
ageSec: 90,
|
||||
ageLabel: '1m ago',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: 'traffic',
|
||||
name: 'Traffic',
|
||||
enabled: true,
|
||||
feedState: 'unavailable',
|
||||
source: 'fixture',
|
||||
count: 0,
|
||||
lastUpdate: 0,
|
||||
ageSec: null,
|
||||
ageLabel: null,
|
||||
error: 'fixture unavailable',
|
||||
},
|
||||
],
|
||||
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
|
||||
},
|
||||
};
|
||||
|
||||
function assertPinnedPayload(actual) {
|
||||
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
|
||||
// deepEqual ignores key order; the serialized form does not.
|
||||
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
|
||||
}
|
||||
|
||||
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
|
||||
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
|
||||
assertPinnedPayload(packet);
|
||||
});
|
||||
|
||||
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
|
||||
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||
const runner = createGevActionRunner(pinnedHarness());
|
||||
const voice = await runner('get_current_view_state');
|
||||
assertPinnedPayload(withoutVoicePresentation(voice));
|
||||
});
|
||||
|
||||
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
|
||||
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||
for (const now of [null, Number.NaN, '2026-10-17']) {
|
||||
const packet = getWorldPacket({ ...pinnedHarness(), now });
|
||||
const flights = packet.feedProvenance.layers.find(
|
||||
(layer) => layer.id === 'flights',
|
||||
);
|
||||
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
|
||||
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('World Packet rejects null options with the camera requirement', () => {
|
||||
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isUnavailableCapability } from '../sources/capability.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
@@ -1567,16 +1568,7 @@ async function fetchRoute(coordPairs, mode, signal, service) {
|
||||
}
|
||||
|
||||
function greatCircleM(a, b) {
|
||||
const R = 6371000;
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLon = toRad(b.lon - a.lon);
|
||||
const la1 = toRad(a.lat);
|
||||
const la2 = toRad(b.lat);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
return greatCircleMeters(a, b);
|
||||
}
|
||||
|
||||
function formatDistance(m) {
|
||||
|
||||
@@ -20,6 +20,8 @@ import { t, tn } from '../i18n/index.js';
|
||||
* `drawTool.js`.
|
||||
*/
|
||||
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
|
||||
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
|
||||
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
|
||||
/**
|
||||
@@ -49,14 +51,7 @@ export function createDrawSession(shape = 'area') {
|
||||
|
||||
/** Great-circle distance in metres between two {lon, lat} points. */
|
||||
export function greatCircleM(a, b) {
|
||||
const R = 6371000;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLon = toRad(b.lon - a.lon);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
|
||||
return greatCircleMeters(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { applicationServices } from './services/application.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleMeters } from './geoDistance.js';
|
||||
import {
|
||||
deriveWeatherEffectProfile,
|
||||
weatherAltitudeFactors,
|
||||
@@ -127,23 +128,9 @@ function compileShader(gl, type, source) {
|
||||
}
|
||||
|
||||
function greatCircleM(a, b) {
|
||||
if (
|
||||
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
|
||||
Number.isFinite,
|
||||
)
|
||||
)
|
||||
return Infinity;
|
||||
const latitudeA = Cesium.Math.toRadians(a.latitude);
|
||||
const latitudeB = Cesium.Math.toRadians(b.latitude);
|
||||
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
|
||||
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
|
||||
const haversine =
|
||||
Math.sin(latitudeDelta / 2) ** 2 +
|
||||
Math.cos(latitudeA) *
|
||||
Math.cos(latitudeB) *
|
||||
Math.sin(longitudeDelta / 2) ** 2;
|
||||
return (
|
||||
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
|
||||
return greatCircleMeters(
|
||||
{ lat: a?.latitude, lon: a?.longitude },
|
||||
{ lat: b?.latitude, lon: b?.longitude },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { feedProvenanceEnvelope } from './layerSnapshot.js';
|
||||
import { pointInRing } from './naturalEarthRegions.js';
|
||||
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
/**
|
||||
* Layers the engine understands, with the fields queries may reference,
|
||||
@@ -98,17 +99,9 @@ function toEpochMs(value) {
|
||||
return Date.parse(text);
|
||||
}
|
||||
|
||||
const EARTH_R_KM = 6371;
|
||||
|
||||
/** Great-circle distance in km. */
|
||||
/** @deprecated Use geoDistance.greatCircleKm with `{ lat, lon }` points. */
|
||||
export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const d2r = Math.PI / 180;
|
||||
const dLat = (lat2 - lat1) * d2r;
|
||||
const dLon = (lon2 - lon1) * d2r;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
floridaCameraId,
|
||||
floridaCameraToSource,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
FLORIDA_CCTV_URL,
|
||||
FLORIDA_MAX_CATALOG_BYTES,
|
||||
FLORIDA_MAX_CATALOG_PAGES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
isLikelyFloridaCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/';
|
||||
|
||||
/** A response with a live body, plus a flag that flips when it is cancelled. */
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One FL511 feature, shaped like the live FeatureServer payload. */
|
||||
const feature = (overrides = {}) => ({
|
||||
attributes: {
|
||||
OBJECTID_1: 6,
|
||||
ID: '1220',
|
||||
DESCRIPT: 'I-75 @ MM 346.5 SB',
|
||||
COUNTY: 'Marion',
|
||||
HIGHWAY: 'I-75',
|
||||
DIRECTION: 'S',
|
||||
LATITUDE: 29.107363,
|
||||
LONGITUDE: -82.188334,
|
||||
IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
/** One catalog page of `count` distinct cameras, channels from `start`. */
|
||||
const page = (start, count, extra = {}) => ({
|
||||
features: Array.from({ length: count }, (_, i) =>
|
||||
feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }),
|
||||
),
|
||||
...extra,
|
||||
});
|
||||
|
||||
const offsetOf = (url) =>
|
||||
Number(new URL(String(url)).searchParams.get('resultOffset'));
|
||||
|
||||
/** Lift the pack cap for one test so every loaded camera comes back. */
|
||||
const uncapped = (t) => {
|
||||
const previous = process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
process.env.CCTV_FLORIDA_MAX_SOURCES = '4200';
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
else process.env.CCTV_FLORIDA_MAX_SOURCES = previous;
|
||||
});
|
||||
};
|
||||
|
||||
test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => {
|
||||
const source = floridaCameraToSource(feature());
|
||||
assert.equal(source.id, 'fl-9431');
|
||||
assert.equal(source.name, 'I-75 @ MM 346.5 SB');
|
||||
assert.equal(source.city, 'Marion County');
|
||||
assert.equal(source.cityId, 'florida');
|
||||
assert.equal(source.provider, 'FL511');
|
||||
assert.equal(source.lat, 29.107363);
|
||||
assert.equal(source.lon, -82.188334);
|
||||
assert.equal(source.headingDeg, 180);
|
||||
assert.equal(
|
||||
floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg,
|
||||
90,
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.equal(source.sourceKind, 'fl511-open-data');
|
||||
assert.match(source.license, /personal use only/);
|
||||
assert.match(source.license, /written consent/);
|
||||
});
|
||||
|
||||
test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => {
|
||||
const source = floridaCameraToSource(
|
||||
feature({ DIRECTION: 'NOT DIRECTIONAL' }),
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431'));
|
||||
});
|
||||
|
||||
test('rows outside Florida, off-host frames and unusable rows are dropped', () => {
|
||||
const dropped = [
|
||||
feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta
|
||||
feature({ LATITUDE: 0, LONGITUDE: 0 }),
|
||||
feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped
|
||||
feature({ LATITUDE: '', LONGITUDE: '' }),
|
||||
feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }),
|
||||
feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }),
|
||||
feature({
|
||||
IMAGE: 'https://user:[email protected]/DGI/chan-9431_h.jpg',
|
||||
}),
|
||||
feature({ IMAGE: 'not a url' }),
|
||||
{ attributes: null },
|
||||
null,
|
||||
];
|
||||
for (const row of dropped) assert.equal(floridaCameraToSource(row), null);
|
||||
});
|
||||
|
||||
test('camera ids come from the image channel, not the repeating ID column', () => {
|
||||
// FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera.
|
||||
const marion = floridaCameraToSource(feature());
|
||||
const broward = floridaCameraToSource(
|
||||
feature({
|
||||
DESCRIPT: 'I-95 SB before E Cypress Rd',
|
||||
COUNTY: 'Broward',
|
||||
LATITUDE: 26.22006,
|
||||
LONGITUDE: -80.13676,
|
||||
IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`,
|
||||
}),
|
||||
);
|
||||
assert.equal(marion.id, 'fl-9431');
|
||||
assert.equal(broward.id, 'fl-6351');
|
||||
// Any resolution suffix keys the same way.
|
||||
assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77');
|
||||
// A filename without a channel falls back to a stable path slug.
|
||||
assert.equal(
|
||||
floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'),
|
||||
'fl-us-1-20at-20sr-5',
|
||||
);
|
||||
assert.equal(floridaCameraId('not a url'), null);
|
||||
});
|
||||
|
||||
test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => {
|
||||
const inside = [
|
||||
[24.5551, -81.78], // Key West
|
||||
[30.4213, -87.2169], // Pensacola
|
||||
[30.99, -84.0], // Georgia line
|
||||
[25.7617, -80.1918], // Miami
|
||||
];
|
||||
const outside = [
|
||||
[33.749, -84.388], // Atlanta
|
||||
[25.0443, -77.3504], // Nassau
|
||||
[0, 0],
|
||||
[-81.78, 24.5551], // swapped
|
||||
[NaN, -81],
|
||||
];
|
||||
for (const [lat, lon] of inside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), true);
|
||||
for (const [lat, lon] of outside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), false);
|
||||
});
|
||||
|
||||
test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
assert.ok(String(url).startsWith(FLORIDA_CCTV_URL));
|
||||
calls.push([offsetOf(url), init.redirect]);
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
// Last page (no exceededTransferLimit): two new cameras and a repeat.
|
||||
return Response.json(page(1999, 3));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(calls, [
|
||||
[0, 'manual'],
|
||||
[2000, 'manual'],
|
||||
]);
|
||||
assert.equal(cameras.length, 2002);
|
||||
assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002);
|
||||
});
|
||||
|
||||
test('pages shorter than requested keep paging while the server says more remain', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const offsets = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const params = new URL(String(url)).searchParams;
|
||||
// Offset paging is only stable over a fixed order on the object id.
|
||||
assert.equal(params.get('orderByFields'), 'OBJECTID_1');
|
||||
const offset = offsetOf(url);
|
||||
offsets.push(offset);
|
||||
// A layer whose max record count was lowered to 1,000.
|
||||
return Response.json(
|
||||
page(offset, offset < 2000 ? 1000 : 500, {
|
||||
exceededTransferLimit: offset < 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(offsets, [0, 1000, 2000]);
|
||||
assert.equal(cameras.length, 2500);
|
||||
});
|
||||
|
||||
test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
uncapped(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES);
|
||||
assert.equal(cameras.length, 2000);
|
||||
assert.ok(warnings.some((line) => /page ceiling/.test(line)));
|
||||
});
|
||||
|
||||
test('a redirect on any page drops the whole pack and releases it', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: redirected.response,
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed later page keeps the pages already read', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
uncapped(t);
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: failed.response,
|
||||
);
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
// A network error on a later page keeps them too.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
});
|
||||
|
||||
test('an oversized catalog page is refused', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(JSON.stringify(page(0, 1)), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body that only turns out too long while streaming.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(FLORIDA_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve a two-camera FL511 page to the Florida endpoint and an empty payload
|
||||
* to every other pack, so one catalog refresh exercises the registration
|
||||
* without reaching the network. Returns the URLs that were requested.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = 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.startsWith(FLORIDA_CCTV_URL)) return Response.json(page(0, 2));
|
||||
return Response.json([]);
|
||||
});
|
||||
// No curated catalogs or ground-height sidecar, so only live lanes are in play.
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Put process.env back exactly as it was. */
|
||||
const restoreEnv = (saved) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key in saved) continue;
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
};
|
||||
|
||||
test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_FLORIDA_ENABLED = '1';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
'the catalog refresh invokes the Florida loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources
|
||||
.filter((s) => s.cityId === 'florida')
|
||||
.map((s) => s.id)
|
||||
.sort(),
|
||||
['fl-0', 'fl-1'],
|
||||
'Florida cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_FLORIDA_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'florida'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
+13
-2
@@ -263,6 +263,14 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'florida-cctv',
|
||||
html:
|
||||
'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' +
|
||||
'Individual, personal use only under the ' +
|
||||
'<a href="https://fl511.com/privacy#disclaimer" target="_blank" rel="noopener">FL511 terms</a>; ' +
|
||||
"any other use needs FDOT's express written consent",
|
||||
},
|
||||
{
|
||||
key: 'catalonia-cctv',
|
||||
html:
|
||||
@@ -305,7 +313,10 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'Bikeshare availability: GBFS operator feeds ' +
|
||||
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
|
||||
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
|
||||
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' +
|
||||
'BiciMAD, <a href="https://www.emtmadrid.es" target="_blank" rel="noopener">Powered by EMT de Madrid</a>; ' +
|
||||
'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' +
|
||||
'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)',
|
||||
},
|
||||
{
|
||||
key: 'urbansharing-gbfs',
|
||||
@@ -324,7 +335,7 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'jcdecaux-gbfs',
|
||||
html:
|
||||
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
|
||||
'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
|
||||
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
import { EARTH_RADIUS_KM, greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
/** Spherical-excess ring area (km²) — same family as turf/geojson-area. */
|
||||
@@ -36,15 +36,6 @@ function ringAreaKm2(ring) {
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
function haversineKm(lon1, lat1, lon2, lat2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a query/name for matching: lowercase, strip diacritics and
|
||||
* punctuation, collapse whitespace, strip a leading "the ".
|
||||
@@ -157,7 +148,10 @@ function buildEntries(pack, kind) {
|
||||
polygons,
|
||||
areaKm2,
|
||||
bbox: [minLon, minLat, maxLon, maxLat],
|
||||
bboxDiagonalKm: haversineKm(minLon, minLat, maxLon, maxLat),
|
||||
bboxDiagonalKm: greatCircleKm(
|
||||
{ lat: minLat, lon: minLon },
|
||||
{ lat: maxLat, lon: maxLon },
|
||||
),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { statSync, readFileSync } from 'node:fs';
|
||||
import { findNaturalRegion, listRegions, lookupNaturalRegionOutline, pointInRing } from './naturalEarthRegions.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
const PACK_DIR = new URL('./local_data/natural_earth/', import.meta.url);
|
||||
|
||||
@@ -19,6 +20,16 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
|
||||
assert.ok(rockies.areaKm2 >= 700_000 && rockies.areaKm2 < 1_500_000,
|
||||
`Rockies area sane (got ${Math.round(rockies.areaKm2)} km²)`);
|
||||
assert.ok(rockies.bboxDiagonalKm > 2000, 'Rockies span thousands of km');
|
||||
const coordinates = rockies.polygons.flat();
|
||||
const minLon = Math.min(...coordinates.map(([lon]) => lon));
|
||||
const minLat = Math.min(...coordinates.map(([, lat]) => lat));
|
||||
const maxLon = Math.max(...coordinates.map(([lon]) => lon));
|
||||
const maxLat = Math.max(...coordinates.map(([, lat]) => lat));
|
||||
assert.equal(
|
||||
rockies.bboxDiagonalKm,
|
||||
greatCircleKm({ lat: minLat, lon: minLon }, { lat: maxLat, lon: maxLon }),
|
||||
'Natural Earth bbox conversion maps [lon, lat] data into { lat, lon } points',
|
||||
);
|
||||
});
|
||||
|
||||
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
|
||||
const MAX_ARTICLES = 5;
|
||||
|
||||
function cleanText(value, maxLength = 180) {
|
||||
@@ -153,19 +155,8 @@ export function weatherCodeLabel(code) {
|
||||
|
||||
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
|
||||
export function regionalDistanceM(from, to) {
|
||||
if (
|
||||
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
|
||||
Number.isFinite,
|
||||
)
|
||||
) {
|
||||
return Infinity;
|
||||
}
|
||||
const phi1 = (from.latitude * Math.PI) / 180;
|
||||
const phi2 = (to.latitude * Math.PI) / 180;
|
||||
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
|
||||
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) ** 2 +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
||||
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return greatCircleMeters(
|
||||
{ lat: from?.latitude, lon: from?.longitude },
|
||||
{ lat: to?.latitude, lon: to?.longitude },
|
||||
);
|
||||
}
|
||||
@@ -59,9 +59,13 @@ test('new data proxies install the same routes in dev and preview servers', () =
|
||||
|
||||
test('Launch Library uses a 15-minute cache and optional server-side token header', () => {
|
||||
assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), { Accept: 'application/json' });
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
};
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), headers);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
...headers,
|
||||
Authorization: 'Token secret',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -677,6 +677,37 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => {
|
||||
assert.equal(launches[0].lon, -120.611);
|
||||
});
|
||||
|
||||
test('null or blank pad coordinates stay unknown instead of becoming 0', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
for (const blank of [null, '', ' ', false]) {
|
||||
assert.deepEqual(
|
||||
normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW),
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('null pad coordinates use the location fallback instead of 0,0', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback',
|
||||
pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW);
|
||||
assert.equal(fallback.lat, 28.6);
|
||||
assert.equal(fallback.lon, -80.6);
|
||||
});
|
||||
|
||||
test('an undisclosed landing keeps top-level position and stage numbers unknown', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 },
|
||||
rocket: { launcher_stage: [{ launcher_flight_number: null,
|
||||
landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null,
|
||||
landing_location: { latitude: null, longitude: null } } }] } }], NOW);
|
||||
const [stage] = launch.recoveryStages;
|
||||
assert.equal(stage.lat, null);
|
||||
assert.equal(stage.lon, null);
|
||||
assert.equal(stage.downrangeKm, null);
|
||||
assert.equal(stage.flightNumber, null);
|
||||
});
|
||||
|
||||
test('normalizes detailed payload and stage recovery records', () => {
|
||||
const launches = normalizeRocketLaunches({ results: [{
|
||||
id: 'recovery-details',
|
||||
|
||||
+12
-28
@@ -10,19 +10,9 @@
|
||||
* low → its destination should be nearby).
|
||||
*/
|
||||
|
||||
const D2R = Math.PI / 180;
|
||||
const R_KM = 6371;
|
||||
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
|
||||
|
||||
/** Haversine great-circle distance in km. */
|
||||
export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
||||
const p1 = lat1 * D2R;
|
||||
const p2 = lat2 * D2R;
|
||||
const dp = (lat2 - lat1) * D2R;
|
||||
const dl = (lon2 - lon1) * D2R;
|
||||
const a =
|
||||
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
const D2R = Math.PI / 180;
|
||||
|
||||
function bearingRad(lat1, lon1, lat2, lon2) {
|
||||
const p1 = lat1 * D2R;
|
||||
@@ -36,10 +26,11 @@ function bearingRad(lat1, lon1, lat2, lon2) {
|
||||
|
||||
/** Signed cross-track distance (km) of point from the great circle p1→p2. */
|
||||
export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
||||
const d13 = greatCircleKm(lat1, lon1, lat, lon) / R_KM;
|
||||
const d13 =
|
||||
greatCircleKm({ lat: lat1, lon: lon1 }, { lat, lon }) / EARTH_RADIUS_KM;
|
||||
const b13 = bearingRad(lat1, lon1, lat, lon);
|
||||
const b12 = bearingRad(lat1, lon1, lat2, lon2);
|
||||
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * R_KM;
|
||||
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
|
||||
}
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
@@ -65,14 +56,14 @@ export function routePlausible({
|
||||
origin = null,
|
||||
destination = null,
|
||||
}) {
|
||||
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
|
||||
const haveD =
|
||||
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
const position = { lat: latDeg, lon: lonDeg };
|
||||
if (!isGeoPoint(position)) return true;
|
||||
const haveO = isGeoPoint(origin);
|
||||
const haveD = isGeoPoint(destination);
|
||||
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
|
||||
|
||||
// (a) Geographic consistency: near an endpoint, or roughly on the path.
|
||||
const near = (pt) =>
|
||||
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
||||
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
|
||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||
if (!geomOk && haveO && haveD) {
|
||||
geomOk =
|
||||
@@ -99,17 +90,10 @@ export function routePlausible({
|
||||
Math.abs(verticalRateMps) > VERT_TREND_MPS
|
||||
) {
|
||||
if (verticalRateMps > 0) {
|
||||
if (
|
||||
haveO &&
|
||||
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
|
||||
)
|
||||
if (haveO && greatCircleKm(position, origin) > LOCAL_AIRPORT_KM)
|
||||
return false; // departing — origin should be local
|
||||
} else {
|
||||
if (
|
||||
haveD &&
|
||||
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
|
||||
LOCAL_AIRPORT_KM
|
||||
)
|
||||
if (haveD && greatCircleKm(position, destination) > LOCAL_AIRPORT_KM)
|
||||
return false; // arriving — destination should be local
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// src/data/routePlausible.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { routePlausible, greatCircleKm } from './routePlausible.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
import { routePlausible } from './routePlausible.js';
|
||||
|
||||
const SFO = { lat: 37.6188, lon: -122.3754 };
|
||||
const LAX = { lat: 33.9416, lon: -118.4085 };
|
||||
const JFK = { lat: 40.6413, lon: -73.7781 };
|
||||
|
||||
test('greatCircleKm sanity: SFO→LAX ≈ 543 km', () => {
|
||||
const d = greatCircleKm(SFO.lat, SFO.lon, LAX.lat, LAX.lon);
|
||||
const d = greatCircleKm(SFO, LAX);
|
||||
assert.ok(Math.abs(d - 543) < 15, `got ${d}`);
|
||||
});
|
||||
|
||||
@@ -41,4 +42,8 @@ test('missing data never hides a route (cannot judge → allow)', () => {
|
||||
latDeg: 51.5, lonDeg: -0.12,
|
||||
origin: { lat: null, lon: null }, destination: null,
|
||||
}), true);
|
||||
assert.equal(routePlausible({
|
||||
latDeg: 95, lonDeg: 0,
|
||||
origin: SFO, destination: LAX,
|
||||
}), true);
|
||||
});
|
||||
@@ -7,10 +7,12 @@ export function celestrakTleUrl(group) {
|
||||
}
|
||||
|
||||
export function launchLibraryRecentUrl(end) {
|
||||
const start = new Date(end.getTime() - 30 * 86400000);
|
||||
// Stable minute boundaries let different installations share LL2's cache.
|
||||
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
|
||||
const start = new Date(roundedEnd.getTime() - 30 * 86400000);
|
||||
const url = new URL('https://ll.thespacedevs.com/2.3.0/launches/');
|
||||
url.searchParams.set('net__gte', start.toISOString());
|
||||
url.searchParams.set('net__lte', end.toISOString());
|
||||
url.searchParams.set('net__lte', roundedEnd.toISOString());
|
||||
url.searchParams.set('limit', '100');
|
||||
url.searchParams.set('mode', 'detailed');
|
||||
return url;
|
||||
|
||||
@@ -13,31 +13,11 @@
|
||||
* @module data/trafficBounds
|
||||
*/
|
||||
|
||||
/** @const {number} Mean Earth radius in km (spherical approximation). */
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
|
||||
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const toDeg = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* Great-circle distance between two lat/lon points (haversine).
|
||||
*
|
||||
* @param {number} lat1 - First point latitude (degrees).
|
||||
* @param {number} lon1 - First point longitude (degrees).
|
||||
* @param {number} lat2 - Second point latitude (degrees).
|
||||
* @param {number} lon2 - Second point longitude (degrees).
|
||||
* @returns {number} Distance in kilometres.
|
||||
*/
|
||||
export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
||||
const p1 = toRad(lat1);
|
||||
const p2 = toRad(lat2);
|
||||
const dp = toRad(lat2 - lat1);
|
||||
const dl = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial bearing (radians) from point 1 toward point 2 along the great circle.
|
||||
*
|
||||
@@ -111,10 +91,13 @@ export function deriveFetchCenter({
|
||||
hitLon,
|
||||
maxPullKm = 12,
|
||||
}) {
|
||||
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
|
||||
if (!isGeoPoint({ lat: hitLat, lon: hitLon })) {
|
||||
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
|
||||
}
|
||||
const distKm = greatCircleKm(nadirLat, nadirLon, hitLat, hitLon);
|
||||
const distKm = greatCircleKm(
|
||||
{ lat: nadirLat, lon: nadirLon },
|
||||
{ lat: hitLat, lon: hitLon },
|
||||
);
|
||||
if (distKm <= maxPullKm) {
|
||||
return { lat: hitLat, lon: hitLon, source: 'hit' };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/data/trafficBounds.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
import {
|
||||
greatCircleKm,
|
||||
deriveFetchCenter,
|
||||
clampBoundsAroundCenter,
|
||||
} from './trafficBounds.js';
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
const NADIR = { lat: 30.2672, lon: -97.7431 };
|
||||
|
||||
test('greatCircleKm sanity: Austin -> ~5 km north', () => {
|
||||
const d = greatCircleKm(NADIR.lat, NADIR.lon, NADIR.lat + 0.045, NADIR.lon);
|
||||
const d = greatCircleKm(NADIR, { lat: NADIR.lat + 0.045, lon: NADIR.lon });
|
||||
assert.ok(Math.abs(d - 5.0) < 0.1, `got ${d}`);
|
||||
});
|
||||
|
||||
@@ -41,14 +41,14 @@ test('oblique look within 12 km: uses the hit point verbatim', () => {
|
||||
test('horizon gaze: far hit is pulled back to 12 km along the bearing', () => {
|
||||
// Hit ~100 km due east of nadir.
|
||||
const hit = { lat: NADIR.lat, lon: NADIR.lon + 1.041 };
|
||||
assert.ok(greatCircleKm(NADIR.lat, NADIR.lon, hit.lat, hit.lon) > 90, 'precondition: far hit');
|
||||
assert.ok(greatCircleKm(NADIR, hit) > 90, 'precondition: far hit');
|
||||
const c = deriveFetchCenter({
|
||||
nadirLat: NADIR.lat, nadirLon: NADIR.lon,
|
||||
hitLat: hit.lat, hitLon: hit.lon,
|
||||
maxPullKm: 12,
|
||||
});
|
||||
assert.equal(c.source, 'pulled');
|
||||
const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon);
|
||||
const d = greatCircleKm(NADIR, c);
|
||||
assert.ok(Math.abs(d - 12) < 0.05, `pulled distance ${d} km, expected ~12`);
|
||||
// Due-east bearing: latitude stays ~constant, longitude moves east but well
|
||||
// short of the hit.
|
||||
@@ -64,12 +64,12 @@ test('pull cap is honored for other maxPullKm values', () => {
|
||||
maxPullKm: 5,
|
||||
});
|
||||
assert.equal(c.source, 'pulled');
|
||||
const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon);
|
||||
const d = greatCircleKm(NADIR, c);
|
||||
assert.ok(Math.abs(d - 5) < 0.05, `pulled distance ${d} km, expected ~5`);
|
||||
});
|
||||
|
||||
test('pickEllipsoid failure (non-finite hit): falls back to the camera nadir', () => {
|
||||
for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined]]) {
|
||||
test('invalid ground hit falls back to the camera nadir', () => {
|
||||
for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined], [91, -97], [30, 181]]) {
|
||||
const c = deriveFetchCenter({
|
||||
nadirLat: NADIR.lat, nadirLon: NADIR.lon,
|
||||
hitLat, hitLon,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
/**
|
||||
* @module transitFeeds
|
||||
* @description Registry of keyless, openly licensed GTFS-Realtime
|
||||
@@ -773,7 +775,8 @@ export function getRegisteredTransitFeed(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance in km.
|
||||
* Great-circle distance in km. Retained for compatibility with existing
|
||||
* transit consumers; new shared spatial code should use `greatCircleKm`.
|
||||
* @param {number} aLat
|
||||
* @param {number} aLon
|
||||
* @param {number} bLat
|
||||
@@ -781,13 +784,7 @@ export function getRegisteredTransitFeed(id) {
|
||||
* @returns {number}
|
||||
*/
|
||||
export function haversineKm(aLat, aLon, bLat, bLon) {
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(bLat - aLat);
|
||||
const dLon = toRad(bLon - aLon);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
*/
|
||||
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { normalizeVesselType } from './vesselTypes.js';
|
||||
|
||||
export { normalizeVesselType };
|
||||
|
||||
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
|
||||
/** Existing selector grid size; one ambient winner is retained per cell. */
|
||||
@@ -43,31 +46,6 @@ const TYPE_STYLES = [
|
||||
];
|
||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||
|
||||
const NUMERIC_TYPE_SPECIALS = {
|
||||
30: 'FISHING',
|
||||
31: 'TOWING',
|
||||
32: 'TOWING',
|
||||
33: 'DREDGER',
|
||||
34: 'DIVE OPS',
|
||||
35: 'MILITARY',
|
||||
36: 'SAILING',
|
||||
37: 'PLEASURE',
|
||||
50: 'PILOT',
|
||||
51: 'SAR',
|
||||
52: 'TUG',
|
||||
53: 'PORT TENDER',
|
||||
54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE',
|
||||
58: 'MEDICAL',
|
||||
};
|
||||
const NUMERIC_TYPE_FAMILIES = {
|
||||
4: 'HIGH-SPEED',
|
||||
6: 'PASSENGER',
|
||||
7: 'CARGO',
|
||||
8: 'TANKER',
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
||||
// and tests match on them.
|
||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||
@@ -104,21 +82,6 @@ export function displayVesselType(type) {
|
||||
return key ? t(key) : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an AIS type to display text: bare numeric ship-type codes map to
|
||||
* family names ("71" → "CARGO"); text types pass through unchanged.
|
||||
* @param {string} type Raw AIS type.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeVesselType(type) {
|
||||
const text = String(type || '').trim();
|
||||
if (!text || !/^\d{1,2}$/.test(text)) return text;
|
||||
const code = Number(text);
|
||||
if (code <= 0) return '';
|
||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||
}
|
||||
|
||||
/** AIS ship type → CSS hex hue for the billboard chevron. */
|
||||
export function vesselTypeCss(type) {
|
||||
return styleForType(type).css;
|
||||
|
||||
@@ -29,6 +29,8 @@ test('normalizeVesselType preserves text and degrades unknown codes', () => {
|
||||
assert.equal(normalizeVesselType('Crude Oil Tanker'), 'Crude Oil Tanker');
|
||||
assert.equal(normalizeVesselType('0'), '');
|
||||
assert.equal(normalizeVesselType('25'), 'OTHER');
|
||||
assert.equal(normalizeVesselType('100'), '');
|
||||
assert.equal(normalizeVesselType('255'), '');
|
||||
assert.equal(normalizeVesselType(undefined), '');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @module vesselTypes
|
||||
* @description AIS ship-type codes (ITU-R M.1371) to family names, with no
|
||||
* browser or i18n dependencies so the server-side query tools can use it.
|
||||
*/
|
||||
|
||||
const NUMERIC_TYPE_SPECIALS = {
|
||||
30: 'FISHING',
|
||||
31: 'TOWING',
|
||||
32: 'TOWING',
|
||||
33: 'DREDGER',
|
||||
34: 'DIVE OPS',
|
||||
35: 'MILITARY',
|
||||
36: 'SAILING',
|
||||
37: 'PLEASURE',
|
||||
50: 'PILOT',
|
||||
51: 'SAR',
|
||||
52: 'TUG',
|
||||
53: 'PORT TENDER',
|
||||
54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE',
|
||||
58: 'MEDICAL',
|
||||
};
|
||||
const NUMERIC_TYPE_FAMILIES = {
|
||||
4: 'HIGH-SPEED',
|
||||
6: 'PASSENGER',
|
||||
7: 'CARGO',
|
||||
8: 'TANKER',
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve an AIS type to display text: bare numeric ship-type codes map to
|
||||
* family names ("71" → "CARGO"); text types pass through unchanged. Code 0
|
||||
* ("not available") and the reserved codes 100 to 255 name no type and
|
||||
* resolve to "".
|
||||
* @param {string} type Raw AIS type.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeVesselType(type) {
|
||||
const text = String(type || '').trim();
|
||||
if (!text || !/^\d+$/.test(text)) return text;
|
||||
const code = Number(text);
|
||||
if (code <= 0 || code >= 100) return '';
|
||||
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
|
||||
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Canonical geographic point used by shared spatial utilities.
|
||||
*
|
||||
* @typedef {{lat: number, lon: number}} GeoPoint
|
||||
*/
|
||||
|
||||
/** IUGG mean spherical Earth radius, in kilometres. */
|
||||
export const EARTH_RADIUS_KM = 6371.0088;
|
||||
|
||||
const DEGREES_TO_RADIANS = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* Whether a value is a canonical, finite WGS84-style point in degrees.
|
||||
* Longitudes are not implicitly wrapped because an out-of-range value usually
|
||||
* indicates a broken coordinate contract at the caller.
|
||||
*
|
||||
* @param {*} point
|
||||
* @returns {point is GeoPoint}
|
||||
*/
|
||||
export function isGeoPoint(point) {
|
||||
return (
|
||||
Number.isFinite(point?.lat) &&
|
||||
point.lat >= -90 &&
|
||||
point.lat <= 90 &&
|
||||
Number.isFinite(point?.lon) &&
|
||||
point.lon >= -180 &&
|
||||
point.lon <= 180
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance in kilometres between two `{ lat, lon }` points.
|
||||
*
|
||||
* `{ lat, lon }` is the canonical shared coordinate representation. Invalid,
|
||||
* missing, non-finite, or out-of-range coordinates return `Infinity`, which
|
||||
* keeps invalid points out of threshold and nearest-neighbour selections.
|
||||
*
|
||||
* @param {GeoPoint} from
|
||||
* @param {GeoPoint} to
|
||||
* @returns {number}
|
||||
*/
|
||||
export function greatCircleKm(from, to) {
|
||||
if (!isGeoPoint(from) || !isGeoPoint(to)) return Number.POSITIVE_INFINITY;
|
||||
|
||||
const latitude1 = from.lat * DEGREES_TO_RADIANS;
|
||||
const latitude2 = to.lat * DEGREES_TO_RADIANS;
|
||||
const latitudeDelta = (to.lat - from.lat) * DEGREES_TO_RADIANS;
|
||||
const longitudeDelta = (to.lon - from.lon) * DEGREES_TO_RADIANS;
|
||||
const haversine =
|
||||
Math.sin(latitudeDelta / 2) ** 2 +
|
||||
Math.cos(latitude1) *
|
||||
Math.cos(latitude2) *
|
||||
Math.sin(longitudeDelta / 2) ** 2;
|
||||
const bounded = Math.min(1, Math.max(0, haversine));
|
||||
return (
|
||||
2 * EARTH_RADIUS_KM * Math.atan2(Math.sqrt(bounded), Math.sqrt(1 - bounded))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance in metres, with the same point and invalid-input
|
||||
* contract as {@link greatCircleKm}.
|
||||
*
|
||||
* @param {GeoPoint} from
|
||||
* @param {GeoPoint} to
|
||||
* @returns {number}
|
||||
*/
|
||||
export function greatCircleMeters(from, to) {
|
||||
return greatCircleKm(from, to) * 1000;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
EARTH_RADIUS_KM,
|
||||
greatCircleKm,
|
||||
greatCircleMeters,
|
||||
isGeoPoint,
|
||||
} from './geoDistance.js';
|
||||
|
||||
const AUSTIN = Object.freeze({ lat: 30.2672, lon: -97.7431 });
|
||||
const HOUSTON = Object.freeze({ lat: 29.7604, lon: -95.3698 });
|
||||
|
||||
test('great-circle identity and symmetry', () => {
|
||||
assert.equal(greatCircleKm(AUSTIN, AUSTIN), 0);
|
||||
assert.equal(greatCircleKm(AUSTIN, HOUSTON), greatCircleKm(HOUSTON, AUSTIN));
|
||||
});
|
||||
|
||||
test('great-circle one-degree calibration and canonical units', () => {
|
||||
const kilometres = greatCircleKm({ lat: 0, lon: 0 }, { lat: 1, lon: 0 });
|
||||
assert.ok(Math.abs(kilometres - 111.195) < 0.001);
|
||||
assert.equal(
|
||||
greatCircleMeters({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }),
|
||||
kilometres * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
test('great-circle handles dateline crossing and antipodal points', () => {
|
||||
const acrossDateline = greatCircleKm(
|
||||
{ lat: 0, lon: 179 },
|
||||
{ lat: 0, lon: -179 },
|
||||
);
|
||||
assert.ok(Math.abs(acrossDateline - 222.39) < 0.01);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
greatCircleKm({ lat: 0, lon: 0 }, { lat: 0, lon: 180 }) -
|
||||
Math.PI * EARTH_RADIUS_KM,
|
||||
) < 1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonical points require explicit latitude and longitude keys', () => {
|
||||
assert.equal(isGeoPoint({ lat: 10, lon: 80 }), true);
|
||||
assert.equal(isGeoPoint({ latitude: 10, longitude: 80 }), false);
|
||||
assert.equal(isGeoPoint([10, 80]), false);
|
||||
assert.equal(
|
||||
greatCircleKm(
|
||||
{ latitude: 10, longitude: 80 },
|
||||
{ latitude: 20, longitude: 70 },
|
||||
),
|
||||
Infinity,
|
||||
);
|
||||
});
|
||||
|
||||
test('latitude-before-longitude regression rejects a swapped Austin point', () => {
|
||||
assert.ok(Number.isFinite(greatCircleKm(AUSTIN, HOUSTON)));
|
||||
assert.equal(
|
||||
greatCircleKm(
|
||||
{ lat: AUSTIN.lon, lon: AUSTIN.lat },
|
||||
{ lat: HOUSTON.lon, lon: HOUSTON.lat },
|
||||
),
|
||||
Infinity,
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid, missing, non-finite, and out-of-range points return Infinity', () => {
|
||||
for (const point of [
|
||||
null,
|
||||
{},
|
||||
{ lat: 0 },
|
||||
{ lat: NaN, lon: 0 },
|
||||
{ lat: 0, lon: Infinity },
|
||||
{ lat: 90.0001, lon: 0 },
|
||||
{ lat: 0, lon: -180.0001 },
|
||||
]) {
|
||||
assert.equal(greatCircleKm(point, AUSTIN), Infinity);
|
||||
assert.equal(greatCircleMeters(AUSTIN, point), Infinity);
|
||||
}
|
||||
});
|
||||
+2
-7
@@ -15,6 +15,7 @@ import { applicationServices } from './services/application.js';
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from './geoDistance.js';
|
||||
import { forward as toMGRS } from 'mgrs';
|
||||
import { CITY_POIS } from './cityPois.js';
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
@@ -683,13 +684,7 @@ export class IntelHUD {
|
||||
* @returns {number} Distance in kilometers.
|
||||
*/
|
||||
_haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const toRad = (deg) => Cesium.Math.toRadians(deg);
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -842,7 +842,8 @@
|
||||
"Real live traffic (keyless runs a simulation)": "Tráfico real en vivo (sin clave se usa una simulación)",
|
||||
"Bing imagery map stacks + world terrain": "Imágenes de Bing + terreno mundial",
|
||||
"More flight-polling credits (anonymous works without)": "Más créditos de consulta de vuelos (sin clave funciona el modo anónimo)",
|
||||
"Higher space-missions request allowance": "Mayor límite de peticiones para misiones espaciales",
|
||||
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "El acceso público a misiones espaciales no necesita clave. Déjalo en blanco para el uso estándar; los tokens de pago solo aumentan los límites de solicitudes.",
|
||||
"Optional paid token — public access works without one": "Token opcional de pago — el acceso público funciona sin él",
|
||||
"Scene action failed": "La acción de escena falló",
|
||||
"Project exported": "Proyecto exportado",
|
||||
"Next session: {pendingId} — this session stays on {modelId}": "Sesión siguiente: {pendingId} — esta sesión sigue en {modelId}",
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.",
|
||||
"More flight-polling credits (anonymous works without)": "Повышенный лимит запросов рейсов (анонимно работает и без ключа)",
|
||||
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его клиентский токен (Client Token).",
|
||||
"Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий",
|
||||
"Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "Общедоступные данные космических миссий не требуют ключа. Для обычного использования оставьте поле пустым; платные токены только повышают лимиты запросов.",
|
||||
"Optional paid token — public access works without one": "Необязательный платный токен — публичный доступ работает без него",
|
||||
"Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Просмотр улиц; отдельный ключ — по желанию",
|
||||
"Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth",
|
||||
"Live ships, worldwide": "Суда в реальном времени по всему миру",
|
||||
|
||||
+4
-2
@@ -215,7 +215,7 @@ export function stripKeylessBasemapFromHash(hash) {
|
||||
}
|
||||
}
|
||||
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' });
|
||||
|
||||
function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||
const mode = readStoredCloudVoiceAuthMode();
|
||||
@@ -259,7 +259,9 @@ function buildRow(documentRef, key) {
|
||||
tier.title =
|
||||
key.tier === 'metered'
|
||||
? t('Metered — a billing-enabled account')
|
||||
: t('Free key — register, paste, done');
|
||||
: key.tier === 'paid'
|
||||
? t('Optional paid token — public access works without one')
|
||||
: t('Free key — register, paste, done');
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
|
||||
@@ -29,7 +29,8 @@ export const KEY_SETUP_APPEND_HEADER =
|
||||
/**
|
||||
* Provider credentials, in display order — most magic per
|
||||
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
|
||||
* billing-enabled account, 'free' (🟡) is a register-and-paste key.
|
||||
* billing-enabled account, 'free' (🟡) is a register-and-paste key, and
|
||||
* 'paid' (🔴) is an optional paid higher-rate token.
|
||||
* `clientExposed` marks the keys that are injected into the browser
|
||||
* bundle by design (restrict them at the provider, per SECURITY.md).
|
||||
* `hidden` keeps advanced configuration out of the panel and missing-key count.
|
||||
@@ -119,10 +120,12 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'launch-library',
|
||||
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
||||
unlocks: msg('Higher space-missions request allowance'),
|
||||
getUrl: 'https://thespacedevs.com',
|
||||
unlocks: msg(
|
||||
'Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.',
|
||||
),
|
||||
getUrl: 'https://thespacedevs.com/supportus',
|
||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||
tier: 'free',
|
||||
tier: 'paid',
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseStationStatus,
|
||||
toNonNegativeInteger,
|
||||
} from '../../sources/gbfsStations.js';
|
||||
import { greatCircleKm } from '../../geoDistance.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
@@ -28,15 +29,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function haversineKm(aLat, aLon, bLat, bLon) {
|
||||
const toRad = (value) => (value * Math.PI) / 180;
|
||||
const dLat = toRad(bLat - aLat);
|
||||
const dLon = toRad(bLon - aLon);
|
||||
const p1 = toRad(aLat);
|
||||
const p2 = toRad(bLat);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -604,6 +604,114 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json',
|
||||
provider: 'AMBici',
|
||||
},
|
||||
{
|
||||
id: 'madrid-bicimad',
|
||||
city: 'Madrid, Spain',
|
||||
centerLat: 40.4241,
|
||||
centerLon: -3.6825,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BiciMAD',
|
||||
},
|
||||
{
|
||||
id: 'valencia-valenbisi',
|
||||
city: 'Valencia, Spain',
|
||||
centerLat: 39.4706,
|
||||
centerLon: -0.3749,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://api.cyclocity.fr/contracts/valence/gbfs/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://api.cyclocity.fr/contracts/valence/gbfs/station_status.json',
|
||||
provider: 'Valenbisi',
|
||||
},
|
||||
{
|
||||
id: 'zaragoza-bizi',
|
||||
city: 'Zaragoza, Spain',
|
||||
centerLat: 41.6481,
|
||||
centerLon: -0.8927,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bizi Zaragoza',
|
||||
},
|
||||
{
|
||||
id: 'sevilla-sevici',
|
||||
city: 'Seville, Spain',
|
||||
centerLat: 37.3726,
|
||||
centerLon: -5.9607,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://api.cyclocity.fr/contracts/seville/gbfs/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://api.cyclocity.fr/contracts/seville/gbfs/station_status.json',
|
||||
provider: 'Sevici',
|
||||
},
|
||||
{
|
||||
id: 'valladolid-biki',
|
||||
city: 'Valladolid, Spain',
|
||||
centerLat: 41.6233,
|
||||
centerLon: -4.7382,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BIKI',
|
||||
},
|
||||
{
|
||||
id: 'a-coruna-bicicoruna',
|
||||
city: 'A Coruña, Spain',
|
||||
centerLat: 43.3534,
|
||||
centerLon: -8.4156,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BiciCoruña',
|
||||
},
|
||||
{
|
||||
id: 'donostia-dbizi',
|
||||
city: 'Donostia-San Sebastián, Spain',
|
||||
centerLat: 43.3075,
|
||||
centerLon: -1.9696,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Dbizi',
|
||||
},
|
||||
{
|
||||
id: 'bilbao-bilbaobizi',
|
||||
city: 'Bilbao, Spain',
|
||||
centerLat: 43.2624,
|
||||
centerLon: -2.9367,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bilbao Bizi',
|
||||
},
|
||||
{
|
||||
id: 'rivas-bicinrivas',
|
||||
city: 'Rivas-Vaciamadrid, Spain',
|
||||
centerLat: 40.3535,
|
||||
centerLon: -3.5319,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BicinRivas',
|
||||
},
|
||||
];
|
||||
function normalizeRegistryEntry(entry) {
|
||||
const id = String(entry?.id || '')
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each system's host, its stations farthest from the registered centre in
|
||||
// opposite directions, and the stations its live feed lists away from the
|
||||
// city: a workshop at (0,0), a lab in Madrid, planned sites with no position
|
||||
// yet (checked 2026-10-17). JCDecaux names keep their feed spelling here.
|
||||
const SYSTEMS = {
|
||||
'madrid-bicimad': {
|
||||
host: 'madrid.publicbikesystem.net',
|
||||
provider: 'BiciMAD',
|
||||
stations: [
|
||||
{
|
||||
name: '627 - Alcalde Andrés Madrid Dávila, 92',
|
||||
lat: 40.398303,
|
||||
lon: -3.548534,
|
||||
},
|
||||
{
|
||||
name: '672 - Estación de tren de El Barrial-Pozuelo',
|
||||
lat: 40.464908,
|
||||
lon: -3.807764,
|
||||
},
|
||||
],
|
||||
},
|
||||
'valencia-valenbisi': {
|
||||
host: 'api.cyclocity.fr',
|
||||
provider: 'Valenbisi',
|
||||
stations: [
|
||||
{ name: '_CAMPAMENTO_81', lat: 39.499875, lon: -0.426354 },
|
||||
{ name: '_EDIFICIO_VELES_E_VENTS', lat: 39.461943, lon: -0.323856 },
|
||||
],
|
||||
},
|
||||
'zaragoza-bizi': {
|
||||
host: 'zaragoza.publicbikesystem.net',
|
||||
provider: 'Bizi Zaragoza',
|
||||
stations: [
|
||||
{ name: '231- Heroínas Sitios: Gómara', lat: 41.627549, lon: -0.962297 },
|
||||
{ name: '263- Avda. Real Zaragoza', lat: 41.666994, lon: -0.823078 },
|
||||
],
|
||||
},
|
||||
'sevilla-sevici': {
|
||||
host: 'api.cyclocity.fr',
|
||||
provider: 'Sevici',
|
||||
stations: [
|
||||
{ name: '_ESTADIO OLIMPICO', lat: 37.415898, lon: -6.005849 },
|
||||
{ name: '_HOSPITAL VALME', lat: 37.320325, lon: -5.969204 },
|
||||
],
|
||||
strays: [
|
||||
{
|
||||
station_id: '298',
|
||||
name: '_LAB MADRID',
|
||||
lat: 40.448779,
|
||||
lon: -3.584983,
|
||||
},
|
||||
{ station_id: '300', name: '_TALLER SEVILLA', lat: 0, lon: 0 },
|
||||
],
|
||||
},
|
||||
'valladolid-biki': {
|
||||
host: 'valladolid.publicbikesystem.net',
|
||||
provider: 'BIKI',
|
||||
stations: [
|
||||
{ name: '1002 Puente Duero', lat: 41.55413, lon: -4.79122 },
|
||||
{ name: '1005 Santovenia Centro Civico', lat: 41.692504, lon: -4.688387 },
|
||||
],
|
||||
},
|
||||
'a-coruna-bicicoruna': {
|
||||
host: 'acoruna.publicbikesystem.net',
|
||||
provider: 'BiciCoruña',
|
||||
stations: [
|
||||
{ name: 'Novo Mesoiro III', lat: 43.323367, lon: -8.443926 },
|
||||
{ name: 'Adormideras', lat: 43.3833748, lon: -8.3956482 },
|
||||
],
|
||||
strays: [
|
||||
{ station_id: '24', name: 'Playa de Oza', lat: 0, lon: 0 },
|
||||
{ station_id: '37', name: 'Campus Elviña 2', lat: 0, lon: 0 },
|
||||
{ station_id: '38', name: 'Campus Zapateira', lat: 0, lon: 0 },
|
||||
],
|
||||
},
|
||||
'donostia-dbizi': {
|
||||
host: 'sansebastian.publicbikesystem.net',
|
||||
provider: 'Dbizi',
|
||||
stations: [
|
||||
{ name: '59 - BUENAVISTA', lat: 43.321211, lon: -1.922239 },
|
||||
{ name: '54 - BERIO 42', lat: 43.305158, lon: -2.016997 },
|
||||
],
|
||||
},
|
||||
'bilbao-bilbaobizi': {
|
||||
host: 'bilbao.publicbikesystem.net',
|
||||
provider: 'Bilbao Bizi',
|
||||
stations: [
|
||||
{ name: 'ZORROTZAKO KIROLDEGIA', lat: 43.2778696, lon: -2.9735575 },
|
||||
{ name: 'OTXARKOAGA', lat: 43.258515, lon: -2.899282 },
|
||||
],
|
||||
},
|
||||
'rivas-bicinrivas': {
|
||||
host: 'rivas.publicbikesystem.net',
|
||||
provider: 'BicinRivas',
|
||||
stations: [
|
||||
{
|
||||
name: 'Polideportivo Parque Sureste',
|
||||
lat: 40.3236695,
|
||||
lon: -3.5127191,
|
||||
},
|
||||
{ name: 'OCÉANO ÁRTICO', lat: 40.383292, lon: -3.5343627 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// The production range check, with no viewer or fetches behind it.
|
||||
const parts = {
|
||||
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
|
||||
};
|
||||
const { computeInRangeCities } = createViewport({
|
||||
state: {},
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
});
|
||||
|
||||
/** Station information for `id` through the real ingestion path. */
|
||||
async function loadThroughIngestion(id, stations) {
|
||||
const city = CITY_BY_ID.get(id);
|
||||
const state = {
|
||||
_stationInfoCache: new Map(),
|
||||
_inFlightInfo: new Map(),
|
||||
_inFlightStatus: new Map(),
|
||||
_loadingOps: 0,
|
||||
_loading: false,
|
||||
};
|
||||
const ingestion = createIngestion({
|
||||
state,
|
||||
services: {},
|
||||
parts,
|
||||
source: {
|
||||
async getStations(url) {
|
||||
assert.equal(url, city.stationInformationUrl);
|
||||
return { data: { stations } };
|
||||
},
|
||||
},
|
||||
});
|
||||
return ingestion.loadCityStationInfo(id, 1);
|
||||
}
|
||||
|
||||
test('the Spanish city systems are registered and readable through the proxy', () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
const system = CITY_BY_ID.get(id);
|
||||
assert.ok(system, `${id} is registered`);
|
||||
assert.equal(system.provider, expected.provider);
|
||||
assert.deepEqual(system.hosts, [expected.host]);
|
||||
for (const feed of [
|
||||
system.stationInformationUrl,
|
||||
system.stationStatusUrl,
|
||||
]) {
|
||||
const url = new URL(feed);
|
||||
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
|
||||
assert.ok(
|
||||
isAllowedGbfsPath(url.pathname, url.hostname),
|
||||
`${id} path ${url.pathname}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera over each outermost station activates its system', () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
for (const station of expected.stations) {
|
||||
assert.ok(
|
||||
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
|
||||
`${station.name} activates ${id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('each load radius reaches its outermost stations', () => {
|
||||
// The range check floors every radius at 100 km, but station information is
|
||||
// trimmed to the radius as registered, so it has to reach every station.
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
const system = CITY_BY_ID.get(id);
|
||||
for (const station of expected.stations) {
|
||||
const distance = parts.model.haversineKm(
|
||||
system.centerLat,
|
||||
system.centerLon,
|
||||
station.lat,
|
||||
station.lon,
|
||||
);
|
||||
assert.ok(
|
||||
distance <= system.loadRadiusKm,
|
||||
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera over central Madrid activates BiciMAD and BicinRivas', () => {
|
||||
// Puerta del Sol.
|
||||
const active = computeInRangeCities({ lat: 40.4169, lon: -3.7035 });
|
||||
assert.ok(active.has('madrid-bicimad'));
|
||||
assert.ok(active.has('rivas-bicinrivas'));
|
||||
});
|
||||
|
||||
test('stations a feed lists away from its city are not loaded', async () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
if (!expected.strays) continue;
|
||||
const local = expected.stations.map((station, index) => ({
|
||||
station_id: `local-${index}`,
|
||||
...station,
|
||||
}));
|
||||
const stations = await loadThroughIngestion(id, [
|
||||
...local,
|
||||
...expected.strays,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[...stations.keys()],
|
||||
local.map((station) => station.station_id),
|
||||
`${id} keeps only its local stations`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('JCDecaux station names reach the layer without underscores', async () => {
|
||||
for (const id of ['valencia-valenbisi', 'sevilla-sevici']) {
|
||||
const stations = await loadThroughIngestion(
|
||||
id,
|
||||
SYSTEMS[id].stations.map((station, index) => ({
|
||||
station_id: String(index),
|
||||
...station,
|
||||
})),
|
||||
);
|
||||
for (const station of stations.values())
|
||||
assert.doesNotMatch(station.name, /_/, `${id}: ${station.name}`);
|
||||
}
|
||||
const valenbisi = await loadThroughIngestion('valencia-valenbisi', [
|
||||
{ station_id: '81', ...SYSTEMS['valencia-valenbisi'].stations[0] },
|
||||
]);
|
||||
assert.equal(valenbisi.get('81').name, 'CAMPAMENTO 81');
|
||||
});
|
||||
|
||||
test('PBSC station capacity reaches the layer as docks, without the geofenced area', async () => {
|
||||
// Bilbao Bizi's ARAGOI and a BiciMAD station as published on 2026-10-17.
|
||||
const [bilbao] = SYSTEMS['bilbao-bilbaobizi'].stations;
|
||||
const bilbaoStations = await loadThroughIngestion('bilbao-bilbaobizi', [
|
||||
{ station_id: '1', ...bilbao, capacity: 839, geofenced_capacity: 800 },
|
||||
]);
|
||||
assert.equal(bilbaoStations.get('1').capacity, 39);
|
||||
const [madrid] = SYSTEMS['madrid-bicimad'].stations;
|
||||
const madridStations = await loadThroughIngestion('madrid-bicimad', [
|
||||
{ station_id: '627', ...madrid, capacity: 47, geofenced_capacity: 20 },
|
||||
]);
|
||||
assert.equal(madridStations.get('627').capacity, 27);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from '../../geoDistance.js';
|
||||
import { staticFrameRefreshMs } from '../../data/cctvLod.js';
|
||||
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
|
||||
import { DEFAULT_CAMERA_CALIBRATION } from './policy.js';
|
||||
@@ -309,12 +310,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeEarthquakeSnapshot } from './records.js';
|
||||
|
||||
test('normalizeEarthquakeSnapshot parses valid GeoJSON earthquake features', () => {
|
||||
const geojson = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
id: 'us7000m123',
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-122.4194, 37.7749, 10.5],
|
||||
},
|
||||
properties: {
|
||||
mag: 4.2,
|
||||
place: 'San Francisco Bay Area, CA',
|
||||
time: 1711929600000,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rows = normalizeEarthquakeSnapshot(geojson);
|
||||
assert.equal(Array.isArray(rows), true);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.deepEqual(rows[0], {
|
||||
stableId: 'us7000m123',
|
||||
usgsId: 'us7000m123',
|
||||
lon: -122.4194,
|
||||
lat: 37.7749,
|
||||
depthKm: 10.5,
|
||||
mag: 4.2,
|
||||
place: 'San Francisco Bay Area, CA',
|
||||
time: 1711929600000,
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeEarthquakeSnapshot generates index-based stable IDs when id is missing or blank', () => {
|
||||
const geojson = {
|
||||
features: [
|
||||
{
|
||||
id: null,
|
||||
geometry: { type: 'Point', coordinates: [139.6917, 35.6895, 25.0] },
|
||||
properties: { mag: 3.5, place: 'Tokyo, Japan', time: 1711930000000 },
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
geometry: { type: 'Point', coordinates: [-70.6693, -33.4489, 45.2] },
|
||||
properties: { mag: 5.1, place: 'Santiago, Chile', time: 1711931000000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rows = normalizeEarthquakeSnapshot(geojson);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].stableId, 'event-1');
|
||||
assert.equal(rows[0].usgsId, null);
|
||||
assert.equal(rows[1].stableId, 'event-2');
|
||||
assert.equal(rows[1].usgsId, '');
|
||||
});
|
||||
|
||||
test('normalizeEarthquakeSnapshot filters events below M2.5 without dropping the feed', () => {
|
||||
const geojson = {
|
||||
features: [
|
||||
{
|
||||
id: 'ev-sub',
|
||||
geometry: { type: 'Point', coordinates: [10, 20, 5] },
|
||||
properties: { mag: 2.49, place: 'Minor tremor' },
|
||||
},
|
||||
{
|
||||
id: 'ev-null-mag',
|
||||
geometry: { type: 'Point', coordinates: [11, 21, 5] },
|
||||
properties: { mag: null, place: 'Uncalculated' },
|
||||
},
|
||||
{
|
||||
id: 'ev-threshold',
|
||||
geometry: { type: 'Point', coordinates: [12, 22, 5] },
|
||||
properties: { mag: 2.5, place: 'Threshold event' },
|
||||
},
|
||||
{
|
||||
id: 'ev-strong',
|
||||
geometry: { type: 'Point', coordinates: [13, 23, 5] },
|
||||
properties: { mag: 6.8, place: 'Major event' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rows = normalizeEarthquakeSnapshot(geojson);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].stableId, 'ev-threshold');
|
||||
assert.equal(rows[0].mag, 2.5);
|
||||
assert.equal(rows[1].stableId, 'ev-strong');
|
||||
assert.equal(rows[1].mag, 6.8);
|
||||
});
|
||||
|
||||
test('normalizeEarthquakeSnapshot normalizes optional fields to null when omitted or invalid', () => {
|
||||
const geojson = {
|
||||
features: [
|
||||
{
|
||||
id: 'ev-sparse',
|
||||
geometry: { coordinates: [-100, 40] }, // depth omitted
|
||||
properties: { mag: 3.0, place: 12345, time: 'invalid' }, // place not string, time not number
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rows = normalizeEarthquakeSnapshot(geojson);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].depthKm, null);
|
||||
assert.equal(rows[0].place, null);
|
||||
assert.equal(rows[0].time, null);
|
||||
});
|
||||
|
||||
test('normalizeEarthquakeSnapshot returns null for malformed structures or non-array features', () => {
|
||||
assert.equal(normalizeEarthquakeSnapshot(null), null);
|
||||
assert.equal(normalizeEarthquakeSnapshot(undefined), null);
|
||||
assert.equal(normalizeEarthquakeSnapshot({}), null);
|
||||
assert.equal(normalizeEarthquakeSnapshot({ features: 'not-an-array' }), null);
|
||||
assert.equal(normalizeEarthquakeSnapshot({ features: null }), null);
|
||||
});
|
||||
|
||||
test('normalizeEarthquakeSnapshot returns null for invalid geometries or out-of-range coordinates', () => {
|
||||
const baseFeature = {
|
||||
id: 'test',
|
||||
geometry: { type: 'Point', coordinates: [0, 0, 0] },
|
||||
properties: { mag: 3.0 },
|
||||
};
|
||||
|
||||
// Non-Point geometry
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [
|
||||
{
|
||||
...baseFeature,
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
// Missing or short coordinates
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [
|
||||
{ ...baseFeature, geometry: { type: 'Point', coordinates: [0] } },
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
// Longitude out of range [-180, 180]
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [
|
||||
{
|
||||
...baseFeature,
|
||||
geometry: { type: 'Point', coordinates: [180.1, 0, 0] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [
|
||||
{
|
||||
...baseFeature,
|
||||
geometry: { type: 'Point', coordinates: [-180.1, 0, 0] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
// Latitude out of range [-90, 90]
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [
|
||||
{
|
||||
...baseFeature,
|
||||
geometry: { type: 'Point', coordinates: [0, 90.1, 0] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [
|
||||
{
|
||||
...baseFeature,
|
||||
geometry: { type: 'Point', coordinates: [0, -90.1, 0] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
// Non-finite depth
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [
|
||||
{
|
||||
...baseFeature,
|
||||
geometry: { type: 'Point', coordinates: [0, 0, NaN] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
// Magnitude > 10
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [{ ...baseFeature, properties: { mag: 10.1 } }],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
// Non-object or array properties
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [{ ...baseFeature, properties: 'invalid' }],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeEarthquakeSnapshot({
|
||||
features: [{ ...baseFeature, properties: [] }],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeEarthquakeSnapshot returns null when feed contains duplicate IDs', () => {
|
||||
const geojson = {
|
||||
features: [
|
||||
{
|
||||
id: 'duplicate-id',
|
||||
geometry: { type: 'Point', coordinates: [0, 0, 0] },
|
||||
properties: { mag: 3.0 },
|
||||
},
|
||||
{
|
||||
id: 'duplicate-id',
|
||||
geometry: { type: 'Point', coordinates: [1, 1, 1] },
|
||||
properties: { mag: 4.0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(normalizeEarthquakeSnapshot(geojson), null);
|
||||
});
|
||||
@@ -219,8 +219,8 @@ export const COURSE_SLEW_DT_MAX_SEC = 0.25;
|
||||
// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4
|
||||
// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart
|
||||
// (≤5/s — adsbdb is a free community API; the dev-server proxy additionally
|
||||
// caches per-key on disk forever, negative results included, so repeat
|
||||
// sessions never re-hit adsbdb). Each key is requested at most once per
|
||||
// caches successful and 404 responses per key for 24 hours, so later sessions
|
||||
// may refresh them). Each key is requested at most once per
|
||||
// session. Priority jobs (tracked plane, model-eligible planes) jump the
|
||||
// queue; the ambient fleet sweep (below) fills the back at poll cadence.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -243,7 +243,7 @@ export const ENRICH_DISPATCH_GAP_MS = 200;
|
||||
// of drip, so the backlog can't outgrow a poll and re-sorts fresh), and
|
||||
// - a ROLLING token-bucket budget (below) bounding the sustained ambient
|
||||
// request rate (repeat sessions resolve instantly from the proxy's
|
||||
// permanent disk cache).
|
||||
// 24-hour disk cache).
|
||||
// Fail-silent by contract: the sweep never throws into the poll loop, never
|
||||
// blocks rendering, and never touches tracking state. When a type answer
|
||||
// lands, _requestTypeEnrichment's callback swaps the billboard glyph + scale
|
||||
@@ -257,7 +257,7 @@ export const ENRICH_DISPATCH_GAP_MS = 200;
|
||||
// ENRICH_AMBIENT_REFILL_TOKENS every ENRICH_AMBIENT_REFILL_WINDOW_MS, clamped
|
||||
// at the ceiling (no banking beyond one bucket). Numbers: 150 / 5 min sustains
|
||||
// 0.5 req/s worst case — an order of magnitude under the 5/s drip that (with
|
||||
// the 4-concurrent limit + the proxy's permanent disk cache) is the REAL
|
||||
// the 4-concurrent limit + the proxy's 24-hour disk cache) is the REAL
|
||||
// politeness bound on adsbdb; the 300 ceiling preserves the old first-look
|
||||
// burst so a fresh region still classifies quickly.
|
||||
/** Bucket ceiling: max ambient tokens held at once (= the initial burst). */
|
||||
|
||||
Vendored
+1
@@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
orbitMatches: layerState._orbitMatches,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
error: layerState._lastError,
|
||||
stale: layerState._stale,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -168,9 +168,16 @@ export function createIngestion({
|
||||
async function performMissionUpdate(token) {
|
||||
try {
|
||||
ensureActiveTleLookup(token);
|
||||
const payload = await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
});
|
||||
const { payload, stale } = source.getLaunchSnapshot
|
||||
? await source.getLaunchSnapshot({
|
||||
signal: layerState._sourceController.signal,
|
||||
})
|
||||
: {
|
||||
payload: await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
}),
|
||||
stale: false,
|
||||
};
|
||||
const launches = parts.model.normalizeRocketLaunches(payload);
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
@@ -214,6 +221,7 @@ export function createIngestion({
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLaunchSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
async function fixture({ legacySource = false } = {}) {
|
||||
const launch = {
|
||||
id: 'owned-launch',
|
||||
name: 'Owned mission fixture',
|
||||
net: new Date(Date.now() - 3600000).toISOString(),
|
||||
status: { name: 'Launch Successful' },
|
||||
pad: { name: 'Owned pad', latitude: 28.6, longitude: -80.6 },
|
||||
rocket: { payloads: [] },
|
||||
};
|
||||
const mode = { cache: 'HIT', results: [launch], hold: false };
|
||||
let arrival;
|
||||
let release;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/api/celestrak/active') {
|
||||
res.end('');
|
||||
return;
|
||||
}
|
||||
assert.equal(req.url, '/api/launches');
|
||||
const send = () => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GEV-Cache': mode.cache,
|
||||
});
|
||||
res.end(JSON.stringify({ results: mode.results }));
|
||||
};
|
||||
if (mode.hold) {
|
||||
release = send;
|
||||
arrival?.();
|
||||
} else send();
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const launchSource = createLaunchSource({
|
||||
fetchImpl: (url, options) => {
|
||||
assert.ok(['/api/launches', '/api/celestrak/active'].includes(url));
|
||||
return fetch(new URL(url, origin), options);
|
||||
},
|
||||
});
|
||||
const source = legacySource
|
||||
? {
|
||||
getLaunches: launchSource.getLaunches,
|
||||
getActiveTle: launchSource.getActiveTle,
|
||||
}
|
||||
: launchSource;
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
state._enabled = true;
|
||||
state._lifecycleToken = 1;
|
||||
state._dataSource = new Cesium.CustomDataSource('owned-qualification');
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
});
|
||||
const viewer = {
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
await viewer.dataSources.add(state._dataSource);
|
||||
// Only presentation boundaries are inert. Source, normalization, state,
|
||||
// ingestion, lifecycle, stats and feed-state policy execute unchanged.
|
||||
const parts = {
|
||||
panel: {
|
||||
createMissionPanel: noop,
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
setSelectedMission: noop,
|
||||
focusMission: noop,
|
||||
stopMissionZoomAnchor: noop,
|
||||
},
|
||||
orbitRendering: {
|
||||
removeMissionOrbitPrimitives: noop,
|
||||
syncMissionOrbitPrimitiveVisibility: noop,
|
||||
},
|
||||
overlays: {
|
||||
createReplayVehicleOverlay: noop,
|
||||
clearMissionOverlaySources: noop,
|
||||
hideReplayVehicleOverlay: noop,
|
||||
destroyReplayVehicleOverlay: noop,
|
||||
},
|
||||
launchPad: {
|
||||
initLaunchPadZonePrimitive: noop,
|
||||
hideLaunchPadZone: noop,
|
||||
destroyLaunchPadZonePrimitive: noop,
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { state, services, parts, source };
|
||||
parts.model = createModel(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const current = () => {
|
||||
const stats = parts.controls.methods.getStats();
|
||||
return {
|
||||
stats,
|
||||
feed: layerFeedState(stats),
|
||||
rendered: state._dataSource?.entities.values.map((x) => x.id) || [],
|
||||
enabled: state._enabled,
|
||||
};
|
||||
};
|
||||
return {
|
||||
mode,
|
||||
state,
|
||||
source,
|
||||
current,
|
||||
viewer,
|
||||
init: () => parts.lifecycle.methods.init(viewer),
|
||||
update: () => parts.ingestion.methods.update(),
|
||||
disable: () => parts.lifecycle.methods.disable(),
|
||||
destroy: () => parts.lifecycle.methods.destroy(viewer),
|
||||
holdArrival: () =>
|
||||
new Promise((resolve) => {
|
||||
arrival = resolve;
|
||||
}),
|
||||
release: () => release?.(),
|
||||
async close() {
|
||||
await parts.lifecycle.methods.destroy(viewer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('fresh launches remain nominal through ingestion and the layer stats', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.ok(value.stats.lastUpdate);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('STALE-ERROR provenance reaches the layer feed state without dropping records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
assert.equal((await f.source.getLaunchSnapshot()).stale, true);
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a fresh recovery clears stale launch provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
const stale = f.current();
|
||||
f.mode.cache = 'MISS';
|
||||
await f.update();
|
||||
const recovered = f.current();
|
||||
assert.equal(recovered.feed, 'nominal');
|
||||
assert.equal(recovered.stats.count, 1);
|
||||
assert.equal(recovered.stats.error, null);
|
||||
assert.equal(stale.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty stale snapshot still identifies its fallback provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.results = [];
|
||||
await f.update();
|
||||
assert.equal(f.current().stats.count, 0);
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('getLaunches-only injected sources remain supported', async () => {
|
||||
const f = await fixture({ legacySource: true });
|
||||
try {
|
||||
await f.update();
|
||||
assert.equal(f.current().feed, 'nominal');
|
||||
assert.deepEqual(f.current().rendered, ['owned-launch']);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('disable cancels a pending stale response without committing it', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const healthy = f.current();
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.hold = true;
|
||||
const arrived = f.holdArrival();
|
||||
const pending = f.update();
|
||||
await arrived;
|
||||
await f.disable();
|
||||
f.release();
|
||||
await pending;
|
||||
const value = f.current();
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(value.stats.lastUpdate, healthy.stats.lastUpdate);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(f.state._sourceController.signal.aborted, true);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('hiding retained stale records does not relabel them as fresh', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.disable();
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
assert.equal(f.current().stats.count, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy clears the previous feed state and records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.enabled, false);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy followed by actual init starts a clean instance with owned handlers', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
await f.init();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(f.viewer.dataSources.length, 1);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 1);
|
||||
const click = f.state._clickHandler;
|
||||
const move = f.state._moveHandler;
|
||||
assert.ok(click instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.ok(move instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.equal(
|
||||
typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK),
|
||||
'function',
|
||||
);
|
||||
await f.destroy();
|
||||
assert.equal(click.isDestroyed(), true);
|
||||
assert.equal(move.isDestroyed(), true);
|
||||
assert.equal(f.viewer.dataSources.length, 0);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export function createLifecycle({
|
||||
viewer.dataSources.add(layerState._dataSource);
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._lastError = null;
|
||||
layerState._orbitMatches = 0;
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
@@ -160,6 +161,7 @@ export function createLifecycle({
|
||||
layerState._count = 0;
|
||||
layerState._orbitMatches = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._activeTleText = null;
|
||||
layerState._activeTlePromise = null;
|
||||
layerState._activeTlePromiseToken = 0;
|
||||
|
||||
@@ -10,6 +10,9 @@ export const UNKNOWN_LAUNCH_STATUS = msg('Unknown');
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
function finiteCoordinate(value) {
|
||||
// Number(null), Number('') and Number(false) are 0: absent is not zero.
|
||||
if (value == null || typeof value === 'boolean') return null;
|
||||
if (typeof value === 'string' && value.trim() === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
@@ -184,15 +187,11 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
const pad = launch.pad || {};
|
||||
const location = pad.location || {};
|
||||
const coordinates = location.coordinates || '';
|
||||
const [coordinateLon, coordinateLat] = String(coordinates)
|
||||
.split(',')
|
||||
.map(Number);
|
||||
const lat = Number.isFinite(Number(pad.latitude))
|
||||
? Number(pad.latitude)
|
||||
: coordinateLat;
|
||||
const lon = Number.isFinite(Number(pad.longitude))
|
||||
? Number(pad.longitude)
|
||||
: coordinateLon;
|
||||
const [coordinateLon, coordinateLat] = String(coordinates).split(',');
|
||||
const lat =
|
||||
finiteCoordinate(pad.latitude) ?? finiteCoordinate(coordinateLat);
|
||||
const lon =
|
||||
finiteCoordinate(pad.longitude) ?? finiteCoordinate(coordinateLon);
|
||||
const payloads = normalizePayloadFlights(launch);
|
||||
return {
|
||||
id: String(
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
state._stale = false;
|
||||
|
||||
state._orbitMatches = 0;
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { noradIdFromSatnum } from '../../sources/tle.js';
|
||||
import {
|
||||
DENSE_REFRESH_FRAMES,
|
||||
DENSE_GROUP_PATH,
|
||||
@@ -113,7 +114,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
const entry = entries[i];
|
||||
const satrec = twoline2satrec(entry.line1, entry.line2);
|
||||
if (!satrec || satrec.error !== 0) continue;
|
||||
const noradId = Number(satrec.satnum);
|
||||
const noradId = noradIdFromSatnum(satrec.satnum);
|
||||
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
|
||||
const pos = parts.orbits.propagatePosition(satrec, now);
|
||||
if (!pos) continue;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { noradIdFromSatnum } from '../../sources/tle.js';
|
||||
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||
|
||||
export function createIngestion({
|
||||
@@ -111,7 +112,7 @@ export function createIngestion({
|
||||
const satrec = twoline2satrec(entry.line1, entry.line2);
|
||||
if (!satrec || satrec.error !== 0) continue;
|
||||
|
||||
const noradId = Number(satrec.satnum);
|
||||
const noradId = noradIdFromSatnum(satrec.satnum);
|
||||
if (seen.has(noradId)) continue;
|
||||
seen.add(noradId);
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseTleText } from '../../sources/tle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
|
||||
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
|
||||
const ISS_LINE1 =
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 30370-3 0 9993';
|
||||
const ISS_LINE2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432323';
|
||||
|
||||
function tleSet(name, field) {
|
||||
return [
|
||||
name,
|
||||
ISS_LINE1.replace('25544', field),
|
||||
ISS_LINE2.replace('25544', field),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ingestionFor(catalogText) {
|
||||
const state = {
|
||||
_trackingRefreshEpoch: 0,
|
||||
_activeUpdateControllers: new Set(),
|
||||
_pointCollection: {
|
||||
removeAll() {},
|
||||
add: (point) => point,
|
||||
},
|
||||
_points: new Map(),
|
||||
_orbitPaths: new Map(),
|
||||
_catalog: new Map(),
|
||||
_detectionObjects: new Map(),
|
||||
_denseLoadToken: 0,
|
||||
_catalogRevision: 0,
|
||||
_params: { catalog: 'core', showOrbits: false },
|
||||
};
|
||||
const parts = {
|
||||
orbits: {
|
||||
parseTLE: parseTleText,
|
||||
propagatePosition: () => ({ longitude: 0, latitude: 0, altitude: 4e5 }),
|
||||
},
|
||||
labels: { _syncIssOverlay() {} },
|
||||
controls: { _pointStyleFor: () => ({}) },
|
||||
rendering: { _showOrbitPath() {} },
|
||||
tracking: {
|
||||
async _reconcileTrackedSubjectContext() {},
|
||||
_applyPendingTrackingRestore() {},
|
||||
},
|
||||
catalog: {},
|
||||
};
|
||||
const source = {
|
||||
async readGroup() {
|
||||
return { ok: true, text: catalogText };
|
||||
},
|
||||
};
|
||||
const { methods } = createIngestion({ state, services: {}, parts, source });
|
||||
return { state, update: () => methods.update({ scene: {} }) };
|
||||
}
|
||||
|
||||
test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const { state, update } = ingestionFor(
|
||||
[
|
||||
tleSet('FIRST', 'A0000'),
|
||||
tleSet('SECOND', 'B0001'),
|
||||
tleSet('THIRD', 'Z9999'),
|
||||
].join('\n'),
|
||||
);
|
||||
await update();
|
||||
assert.deepEqual(
|
||||
[...state._catalog].map(([id, entry]) => [id, entry.name]),
|
||||
[
|
||||
[100000, 'FIRST'],
|
||||
[110001, 'SECOND'],
|
||||
[339999, 'THIRD'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]);
|
||||
assert.equal(state._count, 3);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
twoline2satrec,
|
||||
} from 'satellite.js';
|
||||
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
||||
import { parseTleText } from '../../sources/tle.js';
|
||||
import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
|
||||
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
||||
|
||||
export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
@@ -284,7 +284,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
const current = propagatePosition(satrec, referenceDate);
|
||||
if (!current) return null;
|
||||
return {
|
||||
noradId: Number(satrec.satnum),
|
||||
noradId: noradIdFromSatnum(satrec.satnum),
|
||||
name: String(name || '').trim(),
|
||||
current,
|
||||
periodSec: orbitalPeriodSeconds(satrec),
|
||||
|
||||
+3
-16
@@ -3,6 +3,7 @@ import { isUnavailableCapability } from './sources/capability.js';
|
||||
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
|
||||
import { applicationServices } from './services/application.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from './geoDistance.js';
|
||||
import {
|
||||
viewportBias,
|
||||
placesNearViewRecovery,
|
||||
@@ -648,18 +649,6 @@ export const PLACE_ANCHOR_OFFSET_RATIO = 0.15;
|
||||
*/
|
||||
const PLACE_FALLBACK_HALF_SPAN_KM = 20;
|
||||
|
||||
/** Great-circle distance in km (small enough here that the spherical model is fine). */
|
||||
function greatCircleKm(lat1, lng1, lat2, lng2) {
|
||||
const dLat = Cesium.Math.toRadians(lat2 - lat1);
|
||||
const dLng = Cesium.Math.toRadians(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(Cesium.Math.toRadians(lat1)) *
|
||||
Math.cos(Cesium.Math.toRadians(lat2)) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity-gate a geocode viewport before framing it. Pure — exported for unit tests.
|
||||
*
|
||||
@@ -694,10 +683,8 @@ export function placeFramingViewport(
|
||||
return viewport;
|
||||
|
||||
const offsetKm = greatCircleKm(
|
||||
anchorLat,
|
||||
anchorLng,
|
||||
metrics.centerLat,
|
||||
metrics.centerLng,
|
||||
{ lat: anchorLat, lon: anchorLng },
|
||||
{ lat: metrics.centerLat, lon: metrics.centerLng },
|
||||
);
|
||||
if (offsetKm <= metrics.spanKm * PLACE_ANCHOR_OFFSET_RATIO) return viewport;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { TRACKABLE_FAMILIES } from './agent/worldPacket.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
import {
|
||||
COCKPIT_REFUSAL_NOUNS,
|
||||
@@ -68,10 +69,8 @@ const NOUN_LITERAL =
|
||||
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
|
||||
|
||||
function trackableKinds() {
|
||||
const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8');
|
||||
const block = source.match(/const TRACKABLE_FAMILIES = \[([\s\S]*?)\];/);
|
||||
assert.ok(block, 'TRACKABLE_FAMILIES is still in gevActions.js');
|
||||
return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]);
|
||||
// This is shared with voice; test the exported owner, not its file position.
|
||||
return TRACKABLE_FAMILIES.map(({ kind }) => kind);
|
||||
}
|
||||
|
||||
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
|
||||
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
|
||||
import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js';
|
||||
|
||||
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js']
|
||||
.map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8'))
|
||||
@@ -23,6 +24,12 @@ function fixture(name, overrides = {}, preview = false) {
|
||||
const logs = [];
|
||||
const deps = {
|
||||
readResponseTextCapped, coalesceProxyRequest,
|
||||
// The rate-limited proxies reach for these; give them the real ones so a
|
||||
// fixture request is throttled exactly as production would be.
|
||||
makeRateLimiter, clientKey,
|
||||
// terrain.js gates cross-site requests first; fixture requests carry no
|
||||
// browser headers, so admit them like a loopback tool.
|
||||
admitSameSiteKeyless: () => false,
|
||||
path, process: { cwd: () => '/fixture', env: {} },
|
||||
fsp: {
|
||||
readFile: async () => { throw new Error('cache absent'); },
|
||||
|
||||
@@ -51,15 +51,25 @@ export function extractStationsArray(payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Station name text as a reader expects it. JCDecaux's Spanish contracts
|
||||
* (Valenbisi, Sevici) prefix every name with "_" and Valenbisi joins words
|
||||
* with it ("_PLAZA_MUSICO_LOPEZ_CHAVARRI"), so an underscore reads as a word
|
||||
* break. Other spacing is the operator's own and stays as published.
|
||||
*/
|
||||
function tidyStationName(text) {
|
||||
return text.replace(/\s*_+\s*/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
|
||||
function stationName(value) {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'string') return tidyStationName(value);
|
||||
if (!Array.isArray(value)) return '';
|
||||
|
||||
let fallback = '';
|
||||
for (const translation of value) {
|
||||
if (typeof translation?.text !== 'string') continue;
|
||||
const text = translation.text.trim();
|
||||
const text = tidyStationName(translation.text);
|
||||
if (!text) continue;
|
||||
if (!fallback) fallback = text;
|
||||
if (
|
||||
@@ -71,6 +81,23 @@ function stationName(value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Docks a station physically has. PBSC adds the bikes a station accepts in its
|
||||
* geofenced area around the docks to `capacity` and reports that part as
|
||||
* `geofenced_capacity` (not a GBFS field): Bilbao Bizi publishes 39 docks as
|
||||
* capacity 839 with geofenced_capacity 800. Without the extension, or when it
|
||||
* would exceed the total, `capacity` is read as given. PBSC also counts the
|
||||
* bikes parked in that area (`num_bikes_overflow`) in `num_bikes_available`, so
|
||||
* a crowded station can report more bikes than docks.
|
||||
*/
|
||||
function dockCapacity(raw) {
|
||||
const capacity = toNonNegativeInteger(raw?.capacity);
|
||||
const geofenced = toNonNegativeInteger(raw?.geofenced_capacity);
|
||||
if (capacity == null || geofenced == null || geofenced > capacity)
|
||||
return capacity;
|
||||
return capacity - geofenced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||
* Handles field-name variations across different GBFS providers
|
||||
@@ -95,7 +122,7 @@ export function parseStationInformation(payload) {
|
||||
name: stationName(raw?.name) || stationName(raw?.short_name),
|
||||
lat,
|
||||
lon,
|
||||
capacity: toNonNegativeInteger(raw?.capacity),
|
||||
capacity: dockCapacity(raw),
|
||||
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
|
||||
isRenting: normalizeGbfsBool(raw?.is_renting, true),
|
||||
isReturning: normalizeGbfsBool(raw?.is_returning, true),
|
||||
|
||||
@@ -15,6 +15,25 @@ const parseName = (fields, version) =>
|
||||
data: { stations: [station(fields)] },
|
||||
}).get('s1').name;
|
||||
|
||||
const parseCapacity = (fields) =>
|
||||
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
|
||||
.capacity;
|
||||
|
||||
test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
|
||||
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
|
||||
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
|
||||
assert.equal(parseCapacity({ capacity: 43, geofenced_capacity: 20 }), 23);
|
||||
assert.equal(parseCapacity({ capacity: 19, geofenced_capacity: 0 }), 19);
|
||||
});
|
||||
|
||||
test('capacity is read as given without a usable geofenced_capacity', () => {
|
||||
assert.equal(parseCapacity({ capacity: 27 }), 27);
|
||||
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: null }), 27);
|
||||
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: 'n/a' }), 27);
|
||||
assert.equal(parseCapacity({ capacity: 10, geofenced_capacity: 30 }), 10);
|
||||
assert.equal(parseCapacity({ capacity: null, geofenced_capacity: 5 }), null);
|
||||
});
|
||||
|
||||
test('GBFS v2 station names remain trimmed strings', () => {
|
||||
assert.equal(
|
||||
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
|
||||
@@ -22,6 +41,42 @@ test('GBFS v2 station names remain trimmed strings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('underscores in station names read as word breaks', () => {
|
||||
// As published by JCDecaux's Spanish contracts (checked 2026-10-17).
|
||||
for (const [published, shown] of [
|
||||
['_PLAZA_MUSICO_LOPEZ_CHAVARRI', 'PLAZA MUSICO LOPEZ CHAVARRI'],
|
||||
['_GLORIETA OLIMPICA', 'GLORIETA OLIMPICA'],
|
||||
['_CAMPAMENTO_81', 'CAMPAMENTO 81'],
|
||||
['Plaza__Mayor_ ', 'Plaza Mayor'],
|
||||
]) {
|
||||
assert.equal(parseName({ name: published }), shown);
|
||||
}
|
||||
});
|
||||
|
||||
test('spacing without underscores stays as the operator published it', () => {
|
||||
assert.equal(parseName({ name: 'C/ NÀPOLS, 82' }), 'C/ NÀPOLS, 82');
|
||||
});
|
||||
|
||||
test('a name of only underscores falls back to short_name, then to empty', () => {
|
||||
assert.equal(parseName({ name: '_', short_name: '_S_1_' }), 'S 1');
|
||||
assert.equal(parseName({ name: '__', short_name: '_' }), '');
|
||||
});
|
||||
|
||||
test('underscores are read the same in GBFS v3 localized names', () => {
|
||||
assert.equal(
|
||||
parseName(
|
||||
{
|
||||
name: [
|
||||
{ language: 'es', text: '_' },
|
||||
{ language: 'en', text: '_COLON_I' },
|
||||
],
|
||||
},
|
||||
'3.0',
|
||||
),
|
||||
'COLON I',
|
||||
);
|
||||
});
|
||||
|
||||
test('GBFS v3 station names prefer English over the first translation', () => {
|
||||
assert.equal(
|
||||
parseName(
|
||||
|
||||
+51
-5
@@ -13,7 +13,14 @@ export function parseTleText(text) {
|
||||
const name = lines[i];
|
||||
const line1 = lines[i + 1];
|
||||
const line2 = lines[i + 2];
|
||||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||||
const cat1 = tleCatalogNumber(line1);
|
||||
const cat2 = tleCatalogNumber(line2);
|
||||
if (
|
||||
line1.startsWith('1 ') &&
|
||||
line2.startsWith('2 ') &&
|
||||
cat1 !== null &&
|
||||
cat1 === cat2
|
||||
) {
|
||||
result.push({ name, line1, line2 });
|
||||
i += 2;
|
||||
}
|
||||
@@ -21,8 +28,47 @@ export function parseTleText(text) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The NORAD catalog number from TLE line 1, or null. */
|
||||
export function tleCatalogNumber(line1) {
|
||||
const number = Number.parseInt(String(line1).slice(2, 7), 10);
|
||||
return Number.isInteger(number) ? number : null;
|
||||
// Alpha-5: a letter A–Z, skipping I and O, then exactly four digits.
|
||||
const ALPHA5 = /^[A-HJ-NP-Z]\d{4}$/;
|
||||
const DIGITS = /^\d+$/;
|
||||
|
||||
/**
|
||||
* The catalog number a catalog field stands for, or null for any other field.
|
||||
*
|
||||
* A field is either plain digits or Alpha-5, the TLE spelling of numbers above
|
||||
* 99999: the letter stands for 10–33 in the ten-thousands place, skipping I
|
||||
* and O, so 100000 is "A0000" and 339999 is "Z9999". Anything else (a sign, a
|
||||
* decimal point, hex, an embedded space, a lowercase or trailing letter) is
|
||||
* not a catalog number.
|
||||
* @param {string} field the field with any leading spaces already removed
|
||||
* @returns {number | null}
|
||||
*/
|
||||
function decodeCatalogField(field) {
|
||||
if (DIGITS.test(field)) return Number(field);
|
||||
if (!ALPHA5.test(field)) return null;
|
||||
const letter = field[0];
|
||||
let tens = letter.charCodeAt(0) - 65 + 10;
|
||||
if (letter > 'I') tens -= 1;
|
||||
if (letter > 'O') tens -= 1;
|
||||
return tens * 10000 + Number(field.slice(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* NORAD catalog number of a satrec as a number, or NaN.
|
||||
*
|
||||
* satellite.js keeps the TLE's five-character field verbatim in
|
||||
* `satrec.satnum`, and `Number("A0000")` is NaN, so every Alpha-5 satellite
|
||||
* would share one NaN key. Digits of any length (an OMM satrec carries the
|
||||
* number unpadded) and Alpha-5 decode; anything else, blank included, is NaN.
|
||||
* @param {string} satnum
|
||||
* @returns {number}
|
||||
*/
|
||||
export function noradIdFromSatnum(satnum) {
|
||||
return decodeCatalogField(String(satnum ?? '').trim()) ?? Number.NaN;
|
||||
}
|
||||
|
||||
/** The NORAD catalog number from TLE line 1 or 2 (columns 3–7), or null. */
|
||||
export function tleCatalogNumber(line) {
|
||||
const field = String(line).slice(2, 7);
|
||||
return decodeCatalogField(field.replace(/^ +/, ''));
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates.
|
||||
//
|
||||
// Every distinct 5dp lon/lat is its own cache key, its own permanent line in
|
||||
// `.gev-cache/terrain-heights.json`, and its own upstream call. Four things
|
||||
// bound that: WGS-84 range at parse, an entry ceiling on the map, a size
|
||||
// ceiling on the file that is read back, and a per-client request limiter.
|
||||
//
|
||||
// These cases drive the real exported route handler. `fetch` is stubbed per
|
||||
// test and restored after, so a case that reaches upstream fails loudly.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
|
||||
import { terrainHeightsProxy } from '../server/providers/terrain.js';
|
||||
import { admitSameSite } from '../server/providers/common/same-site.js';
|
||||
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(path, handler) {
|
||||
routes.set(path, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
function request(
|
||||
handler,
|
||||
{ url = '/', remoteAddress = '127.0.0.1', reqHeaders = {} } = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = new Map();
|
||||
const req = {
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: reqHeaders,
|
||||
socket: { remoteAddress },
|
||||
};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headersSent: false,
|
||||
setHeader(name, value) {
|
||||
headers.set(String(name).toLowerCase(), String(value));
|
||||
},
|
||||
writeHead(status, hdrs = {}) {
|
||||
this.statusCode = status;
|
||||
this.headersSent = true;
|
||||
for (const [name, value] of Object.entries(hdrs))
|
||||
headers.set(String(name).toLowerCase(), String(value));
|
||||
},
|
||||
end(body = '') {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = body ? JSON.parse(String(body)) : null;
|
||||
} catch {
|
||||
parsed = String(body);
|
||||
}
|
||||
resolve({
|
||||
statusCode: this.statusCode,
|
||||
headers: Object.fromEntries(headers),
|
||||
body: parsed,
|
||||
});
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
function stubFetch(t, impl) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = impl;
|
||||
t.after(() => {
|
||||
globalThis.fetch = original;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order.
|
||||
* Records which point strings were actually asked for, so a test can assert on
|
||||
* cache misses rather than on internal state.
|
||||
*/
|
||||
function stubTerrainUpstream(t) {
|
||||
const asked = [];
|
||||
stubFetch(t, async (url) => {
|
||||
const points = decodeURIComponent(
|
||||
new URL(String(url)).searchParams.get('points') || '',
|
||||
)
|
||||
.split(';')
|
||||
.filter(Boolean);
|
||||
asked.push(...points);
|
||||
return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) });
|
||||
});
|
||||
return asked;
|
||||
}
|
||||
|
||||
test('a coordinate off the globe is not a cache key', () => {
|
||||
// Finite but not on Earth — the old guard accepted every one of these.
|
||||
for (const raw of [
|
||||
'0,91',
|
||||
'0,-91',
|
||||
'181,0',
|
||||
'-181,0',
|
||||
'1e9,1e9',
|
||||
'0,90.00001',
|
||||
])
|
||||
assert.equal(parseTerrainPoints(raw), null, raw);
|
||||
|
||||
// The poles and the antimeridian are real places and must still parse.
|
||||
for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26'])
|
||||
assert.ok(parseTerrainPoints(raw), raw);
|
||||
});
|
||||
|
||||
test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => {
|
||||
let upstreamCalls = 0;
|
||||
stubFetch(t, async () => {
|
||||
upstreamCalls += 1;
|
||||
return new Response('[]', { status: 200 });
|
||||
});
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
|
||||
const refused = await request(route, { url: '/?points=0,91' });
|
||||
assert.equal(refused.statusCode, 400);
|
||||
assert.equal(
|
||||
upstreamCalls,
|
||||
0,
|
||||
'an impossible coordinate must not cost an upstream call',
|
||||
);
|
||||
});
|
||||
|
||||
test('one out-of-range point rejects the batch it is hidden in', async (t) => {
|
||||
stubFetch(t, async () => new Response('[]', { status: 200 }));
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
const mixed = await request(route, {
|
||||
url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
|
||||
});
|
||||
assert.equal(mixed.statusCode, 400);
|
||||
});
|
||||
|
||||
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
|
||||
const asked = stubTerrainUpstream(t);
|
||||
// A ceiling of 3 exercises the same code path 50,000 does, in four requests.
|
||||
const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get(
|
||||
'/api/terrain/heights',
|
||||
);
|
||||
|
||||
const point = (i) => `-97.7${i}000,30.26000`;
|
||||
for (let i = 1; i <= 4; i += 1)
|
||||
await request(route, { url: `/?points=${point(i)}` });
|
||||
assert.equal(asked.length, 4, 'four distinct points, four upstream lookups');
|
||||
|
||||
// The newest point is still cached...
|
||||
await request(route, { url: `/?points=${point(4)}` });
|
||||
assert.equal(asked.length, 4, 'a cached point costs nothing');
|
||||
|
||||
// ...and the oldest was evicted, so it costs one refetch — never a wrong
|
||||
// answer, because terrain does not move.
|
||||
await request(route, { url: `/?points=${point(1)}` });
|
||||
assert.equal(asked.length, 5, 'the evicted point was asked for again');
|
||||
assert.equal(asked[4], point(1));
|
||||
});
|
||||
|
||||
test('an over-large cache file is not read back into memory', async (t) => {
|
||||
const asked = stubTerrainUpstream(t);
|
||||
const point = '-97.74000,30.26000';
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
|
||||
const cachePath = path.join(dir, 'terrain-heights.json');
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
|
||||
// A cache file holding the answer, but larger than the ceiling allows.
|
||||
const padded = {
|
||||
version: 2,
|
||||
points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } },
|
||||
_pad: 'x'.repeat(4096),
|
||||
};
|
||||
await writeFile(cachePath, JSON.stringify(padded), 'utf8');
|
||||
|
||||
const route = install(
|
||||
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }),
|
||||
).get('/api/terrain/heights');
|
||||
await request(route, { url: `/?points=${point}` });
|
||||
assert.deepEqual(
|
||||
asked,
|
||||
[point],
|
||||
'the oversized file was skipped, so the point was a miss',
|
||||
);
|
||||
|
||||
// The same file under a ceiling that admits it IS used, which is what proves
|
||||
// the previous assertion came from the size guard and not a broken path.
|
||||
const admitted = install(
|
||||
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }),
|
||||
).get('/api/terrain/heights');
|
||||
await request(admitted, { url: `/?points=${point}` });
|
||||
assert.deepEqual(asked, [point], 'a cache within the ceiling is still read');
|
||||
});
|
||||
|
||||
test('the route refuses a flood of requests from one client', async (t) => {
|
||||
stubTerrainUpstream(t);
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
|
||||
let refusedAt = 0;
|
||||
for (let i = 1; i <= 95; i += 1) {
|
||||
// A different point each time: distinct keys are the growth path.
|
||||
const lon = (-97.7 - i / 1000).toFixed(5);
|
||||
const answer = await request(route, { url: `/?points=${lon},30.26` });
|
||||
if (answer.statusCode === 429) {
|
||||
refusedAt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.equal(refusedAt, 91, 'the 91st request in a minute is refused');
|
||||
});
|
||||
|
||||
test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => {
|
||||
const asked = stubTerrainUpstream(t);
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
const host = 'localhost:5173';
|
||||
const refused = [
|
||||
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
|
||||
{ host, 'sec-fetch-site': 'same-site' },
|
||||
{ host, origin: 'https://evil.example' }, // fetch from another page
|
||||
{ host, origin: 'null' }, // sandboxed frame
|
||||
// Behind a reverse proxy the browser's own Sec-Fetch-Site still decides.
|
||||
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
|
||||
];
|
||||
// More refused requests than the 90/min quota, each a distinct new key.
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const reqHeaders = refused[i % refused.length];
|
||||
const answer = await request(route, {
|
||||
url: `/?points=${(-97 - i / 1000).toFixed(5)},30.26`,
|
||||
reqHeaders,
|
||||
});
|
||||
assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders));
|
||||
}
|
||||
assert.deepEqual(asked, [], 'nothing reached upstream or the cache');
|
||||
|
||||
// The visitor's own app still has its full quota afterwards, and a
|
||||
// loopback tool that sends no browser headers is admitted too.
|
||||
const app = await request(route, {
|
||||
url: '/?points=-97.74,30.26',
|
||||
reqHeaders: { host, 'sec-fetch-site': 'same-origin' },
|
||||
});
|
||||
assert.equal(app.statusCode, 200);
|
||||
const tool = await request(route, { url: '/?points=-97.75,30.26' });
|
||||
assert.equal(tool.statusCode, 200);
|
||||
assert.equal(asked.length, 2);
|
||||
});
|
||||
|
||||
test('the app served behind a reverse proxy still gets terrain', async (t) => {
|
||||
const asked = stubTerrainUpstream(t);
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
const host = 'globe.example.lan';
|
||||
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
|
||||
// cost-bearing routes refuse these; terrain is keyless and needed to render,
|
||||
// so a 403 here would drop every chunk to the geoid fallback.
|
||||
const proxied = [
|
||||
{
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
{
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'cf-connecting-ip': '203.0.113.9',
|
||||
},
|
||||
{
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
forwarded: 'for=203.0.113.9;proto=https',
|
||||
via: '1.1 caddy',
|
||||
},
|
||||
// TLS terminated at the proxy: the page origin is https, the hop is http.
|
||||
{
|
||||
host,
|
||||
origin: `https://${host}`,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
];
|
||||
for (const [i, reqHeaders] of proxied.entries()) {
|
||||
const answer = await request(route, {
|
||||
url: `/?points=${(-97.8 - i / 1000).toFixed(5)},30.26`,
|
||||
reqHeaders,
|
||||
});
|
||||
assert.equal(answer.statusCode, 200, JSON.stringify(reqHeaders));
|
||||
}
|
||||
assert.equal(asked.length, proxied.length);
|
||||
|
||||
// Proxied or not, a cross-site page is still refused before any work.
|
||||
for (const reqHeaders of [
|
||||
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
|
||||
{
|
||||
host,
|
||||
'x-forwarded-proto': 'https',
|
||||
origin: 'https://evil.example',
|
||||
},
|
||||
// A forwarded scheme does not make an http Origin match an https page.
|
||||
{ host, 'x-forwarded-proto': 'https', origin: `http://${host}` },
|
||||
]) {
|
||||
const answer = await request(route, {
|
||||
url: '/?points=-97.9,30.26',
|
||||
reqHeaders,
|
||||
});
|
||||
assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders));
|
||||
}
|
||||
assert.equal(asked.length, proxied.length, 'refusals cost no upstream call');
|
||||
});
|
||||
|
||||
test('cost-bearing routes still refuse proxied requests', () => {
|
||||
// The keyless exemption is scoped to terrain; the shared gate is unchanged.
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
setHeader(name, value) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
const refused = admitSameSite(
|
||||
{
|
||||
headers: {
|
||||
host: 'globe.example.lan',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
socket: {},
|
||||
},
|
||||
res,
|
||||
);
|
||||
assert.equal(refused, true);
|
||||
assert.equal(res.statusCode, 403);
|
||||
assert.deepEqual(JSON.parse(res.body), {
|
||||
error: 'Proxied requests are not accepted',
|
||||
});
|
||||
});
|
||||
|
||||
test('a second client keeps its own quota', async (t) => {
|
||||
stubFetch(t, async () =>
|
||||
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
|
||||
);
|
||||
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
|
||||
|
||||
for (let i = 0; i < 90; i += 1)
|
||||
await request(route, {
|
||||
url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`,
|
||||
remoteAddress: '198.51.100.7',
|
||||
});
|
||||
const exhausted = await request(route, {
|
||||
url: '/?points=-99.5,30.26',
|
||||
remoteAddress: '198.51.100.7',
|
||||
});
|
||||
assert.equal(exhausted.statusCode, 429);
|
||||
|
||||
const neighbour = await request(route, {
|
||||
url: '/?points=-99.6,30.26',
|
||||
remoteAddress: '198.51.100.8',
|
||||
});
|
||||
assert.notEqual(neighbour.statusCode, 429);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import { launchLibraryRecentUrl } from '../data/spaceProviderRequests.js';
|
||||
import {
|
||||
launchLibraryRequestHeaders,
|
||||
rocketLaunchesProxy,
|
||||
} from '../../server/providers/space/launch-library.js';
|
||||
import { keySetupStatus } from '../keySetupCore.mjs';
|
||||
import { initKeySetup } from '../keySetup.js';
|
||||
|
||||
test('LL2 requests from the same UTC minute share a cache key without mutating callers', () => {
|
||||
const first = new Date('2026-10-17T16:20:00.001Z');
|
||||
const last = new Date('2026-10-17T16:20:59.999Z');
|
||||
assert.equal(
|
||||
launchLibraryRecentUrl(first).href,
|
||||
launchLibraryRecentUrl(last).href,
|
||||
);
|
||||
assert.equal(first.toISOString(), '2026-10-17T16:20:00.001Z');
|
||||
assert.equal(last.toISOString(), '2026-10-17T16:20:59.999Z');
|
||||
});
|
||||
|
||||
for (const [date, end] of [
|
||||
['2026-10-17T16:20:00.001Z', '2026-10-17T16:20:00.000Z'],
|
||||
['2026-10-17T16:20:59.999Z', '2026-10-17T16:20:00.000Z'],
|
||||
['2026-10-17T16:21:00.000Z', '2026-10-17T16:21:00.000Z'],
|
||||
['2026-01-01T00:00:00.001Z', '2026-01-01T00:00:00.000Z'],
|
||||
]) {
|
||||
test(`LL2 bounds retain the 30-day UTC window at ${date}`, () => {
|
||||
const url = launchLibraryRecentUrl(new Date(date));
|
||||
assert.equal(url.origin, 'https://ll.thespacedevs.com');
|
||||
assert.equal(url.pathname, '/2.3.0/launches/');
|
||||
assert.equal(url.searchParams.get('net__lte'), end);
|
||||
assert.equal(
|
||||
Date.parse(end) - Date.parse(url.searchParams.get('net__gte')),
|
||||
30 * 86400000,
|
||||
);
|
||||
assert.equal(url.searchParams.get('limit'), '100');
|
||||
assert.equal(url.searchParams.get('mode'), 'detailed');
|
||||
});
|
||||
}
|
||||
|
||||
test('LL2 headers identify the app and preserve optional server-only authorization', () => {
|
||||
const publicHeaders = launchLibraryRequestHeaders('');
|
||||
assert.match(publicHeaders['User-Agent'], /^gods-eye-view.*\/1\.0 /);
|
||||
assert.ok(
|
||||
publicHeaders['User-Agent'].includes(
|
||||
'https://github.com/bilawalsidhu/gods-eye-view',
|
||||
),
|
||||
);
|
||||
assert.equal(publicHeaders.Accept, 'application/json');
|
||||
assert.equal(publicHeaders.Authorization, undefined);
|
||||
const paidHeaders = launchLibraryRequestHeaders(' owned-fixture-token ');
|
||||
assert.equal(paidHeaders.Authorization, 'Token owned-fixture-token');
|
||||
assert.equal(paidHeaders['User-Agent'], publicHeaders['User-Agent']);
|
||||
});
|
||||
|
||||
test('actual setup rendering distinguishes optional paid LL2 from unrelated free keys', async () => {
|
||||
const created = [];
|
||||
const node = () => {
|
||||
const value = {
|
||||
dataset: {},
|
||||
children: [],
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setAttribute() {},
|
||||
remove() {},
|
||||
};
|
||||
created.push(value);
|
||||
return value;
|
||||
};
|
||||
const rows = node();
|
||||
const root = node();
|
||||
root.querySelector = (selector) =>
|
||||
selector === '[data-key-setup-rows]' ? rows : null;
|
||||
const chip = node();
|
||||
const status = keySetupStatus({});
|
||||
status.keys = status.keys.filter((key) =>
|
||||
['launch-library', 'mapillary'].includes(key.id),
|
||||
);
|
||||
const setup = await initKeySetup({
|
||||
documentRef: {
|
||||
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
||||
createElement: node,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
fetchImpl: async () => ({ ok: true, json: async () => status }),
|
||||
});
|
||||
assert.ok(setup);
|
||||
try {
|
||||
const ll2 = rows.children.find(
|
||||
(row) => row.dataset.keyId === 'launch-library',
|
||||
);
|
||||
const free = rows.children.find((row) => row.dataset.keyId === 'mapillary');
|
||||
const tier = (row) =>
|
||||
row.children[0].children.find(
|
||||
(child) => child.className === 'key-setup-tier',
|
||||
);
|
||||
assert.match(tier(ll2).title, /optional paid token/i);
|
||||
assert.match(ll2.children[1].textContent, /leave blank/i);
|
||||
assert.match(ll2.children[1].textContent, /no key/i);
|
||||
assert.equal(tier(free).title, 'Free key — register, paste, done');
|
||||
const serialized = JSON.stringify(status);
|
||||
assert.ok(!serialized.includes('owned-fixture-token'));
|
||||
} finally {
|
||||
setup.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('real LL2 proxy sends cacheable headers and bounds, retaining its memory cache', async (t) => {
|
||||
for (const method of ['readFile', 'stat'])
|
||||
t.mock.method(fsp, method, async () => {
|
||||
throw new Error('owned fixture has no disk cache');
|
||||
});
|
||||
t.mock.method(fsp, 'mkdir', async () => {});
|
||||
t.mock.method(fsp, 'writeFile', async () => {});
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
calls.push({ url: new URL(url), options });
|
||||
return new Response(
|
||||
JSON.stringify({ results: [{ id: 'owned-launch-fixture' }] }),
|
||||
);
|
||||
});
|
||||
let handler;
|
||||
rocketLaunchesProxy().configureServer({
|
||||
middlewares: {
|
||||
use(route, value) {
|
||||
assert.equal(route, '/api/launches');
|
||||
handler = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
async function request() {
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ method: 'GET' }, res);
|
||||
return res;
|
||||
}
|
||||
const first = await request();
|
||||
const second = await request();
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(second.body, first.body);
|
||||
assert.equal(second.headers['X-GEV-Cache'], 'HIT');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].options.headers['User-Agent'], /gods-eye-view/);
|
||||
assert.match(calls[0].url.searchParams.get('net__lte'), /:00\.000Z$/);
|
||||
assert.match(calls[0].url.searchParams.get('net__gte'), /:00\.000Z$/);
|
||||
});
|
||||
|
||||
for (const preview of [false, true]) {
|
||||
test(`LL2 proxy sends its identifying headers over real owned HTTP in ${preview ? 'preview' : 'development'}`, async (t) => {
|
||||
for (const method of ['readFile', 'stat'])
|
||||
t.mock.method(fsp, method, async () => {
|
||||
throw new Error('no owned cache');
|
||||
});
|
||||
t.mock.method(fsp, 'mkdir', async () => {});
|
||||
t.mock.method(fsp, 'writeFile', async () => {});
|
||||
const received = [];
|
||||
const upstream = http.createServer((req, res) => {
|
||||
received.push({
|
||||
url: new URL(req.url, 'http://fixture.invalid'),
|
||||
headers: req.headers,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ results: [{ id: 'owned-wire-fixture' }] }));
|
||||
});
|
||||
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.mock.method(globalThis, 'fetch', (url, options) => {
|
||||
const parsed = new URL(url);
|
||||
assert.equal(parsed.origin, 'https://ll.thespacedevs.com');
|
||||
return originalFetch(
|
||||
`http://127.0.0.1:${upstream.address().port}${parsed.pathname}${parsed.search}`,
|
||||
options,
|
||||
);
|
||||
});
|
||||
let handler;
|
||||
const plugin = rocketLaunchesProxy();
|
||||
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
|
||||
middlewares: {
|
||||
use(_route, value) {
|
||||
handler = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
const proxy = http.createServer((req, res) => {
|
||||
void handler(req, res);
|
||||
});
|
||||
await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const result = await originalFetch(
|
||||
`http://127.0.0.1:${proxy.address().port}/api/launches`,
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
assert.deepEqual(await result.json(), {
|
||||
results: [{ id: 'owned-wire-fixture' }],
|
||||
});
|
||||
assert.equal(received.length, 1);
|
||||
assert.match(
|
||||
received[0].headers['user-agent'],
|
||||
/^gods-eye-view-launch-library-proxy\/1\.0 /,
|
||||
);
|
||||
assert.equal(received[0].headers.authorization, undefined);
|
||||
assert.match(received[0].url.searchParams.get('net__lte'), /:00\.000Z$/);
|
||||
} finally {
|
||||
proxy.closeAllConnections();
|
||||
upstream.closeAllConnections();
|
||||
await Promise.all([
|
||||
new Promise((resolve) => proxy.close(resolve)),
|
||||
new Promise((resolve) => upstream.close(resolve)),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -81,8 +81,8 @@ test('portable requests keep fixed origins, encode group data and preserve the 3
|
||||
assert.equal(url.origin, 'https://ll.thespacedevs.com');
|
||||
assert.equal(url.pathname, '/2.3.0/launches/');
|
||||
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
||||
net__gte: '2026-01-30T12:34:56.000Z',
|
||||
net__lte: '2026-03-01T12:34:56.000Z',
|
||||
net__gte: '2026-01-30T12:34:00.000Z',
|
||||
net__lte: '2026-03-01T12:34:00.000Z',
|
||||
limit: '100',
|
||||
mode: 'detailed',
|
||||
});
|
||||
|
||||
@@ -89,17 +89,34 @@ test('root config retains existing named exports and standalone provider order',
|
||||
assert.equal(compatibility[name], value, name);
|
||||
const config = standaloneConfig({ mode: 'test' });
|
||||
assert.deepEqual(
|
||||
config.plugins.slice(3, -4).map((plugin) => plugin.name),
|
||||
config.plugins.slice(3, -5).map((plugin) => plugin.name),
|
||||
providers.localProviderPlugins().map((plugin) => plugin.name),
|
||||
);
|
||||
assert.equal(config.plugins.at(-5).name, 'gev-key-setup');
|
||||
assert.equal(config.plugins.at(-6).name, 'gev-key-setup');
|
||||
// The local MCP route follows every provider and precedes the API fallback.
|
||||
assert.equal(config.plugins.at(-4).name, 'local-mcp');
|
||||
assert.equal(config.plugins.at(-3).name, 'api-not-found');
|
||||
assert.equal(config.plugins.at(-5).name, 'local-mcp');
|
||||
assert.equal(config.plugins.at(-4).name, 'api-not-found');
|
||||
// Config-only, so it sits after the provider slice rather than in it.
|
||||
assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');
|
||||
assert.equal(config.plugins.at(-2).name, 'embed-framing');
|
||||
assert.equal(config.plugins.at(-1).name, 'panel-build');
|
||||
});
|
||||
|
||||
test('directories the server writes while it runs are kept out of the dev watcher', () => {
|
||||
// Providers write cache files and logs here while the app runs; none of it is
|
||||
// source, so the dev watcher has no reason to stat it.
|
||||
const config = standaloneConfig({ mode: 'test' });
|
||||
const plugin = config.plugins.find(
|
||||
({ name }) => name === 'gev-unwatched-runtime-dirs',
|
||||
);
|
||||
// Dev only: preview and build have no watcher.
|
||||
assert.equal(plugin.apply, 'serve');
|
||||
assert.deepEqual(plugin.config().server.watch.ignored, [
|
||||
'**/.gev-cache/**',
|
||||
'**/.gev-logs/**',
|
||||
]);
|
||||
});
|
||||
|
||||
test('build export resolves in Node and has no browser fallback', async () => {
|
||||
const exported = await import('gods-eye-view/build/vite');
|
||||
assert.equal(exported.createBrowserViteConfig, createBrowserViteConfig);
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
} from '../area.js';
|
||||
import {
|
||||
LIMIT_SCHEMA,
|
||||
breakdown,
|
||||
breakdownPhrase,
|
||||
capRows,
|
||||
countNoun,
|
||||
isoTime,
|
||||
numericSummary,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
|
||||
@@ -32,6 +35,31 @@ const MILITARY_SCHEMA = Object.freeze({
|
||||
description: 'Use the military aircraft feed instead of the general feed.',
|
||||
});
|
||||
const MAX_TRACK_POINTS = 200;
|
||||
const GROUP_LABELS = Object.freeze({
|
||||
airline: 'airline',
|
||||
operator: 'operator',
|
||||
origin_country: 'country',
|
||||
type_code: 'type',
|
||||
});
|
||||
|
||||
/**
|
||||
* The ICAO airline designator a callsign flies under: three letters, then a
|
||||
* flight number that starts with a digit, seven characters at most (SWA1234,
|
||||
* UAL12A, BAW1). Registrations used as callsigns (N123AB, GABCD) have no
|
||||
* designator; Philippine RP-C1234 and Cuban CU-T1240, which read like one
|
||||
* without their hyphen, are excluded too.
|
||||
*/
|
||||
export function airlineDesignator(callsign) {
|
||||
const text = String(callsign ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (/^(?:RPC|CUT)\d{4}$/.test(text)) return null;
|
||||
const match = /^([A-Z]{3})\d[0-9A-Z]{0,3}$/.exec(text);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
const groupValue = (field) =>
|
||||
field === 'airline' ? (row) => airlineDesignator(row.callsign) : undefined;
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
@@ -82,7 +110,11 @@ export const aircraftInArea = defineTool({
|
||||
title: 'Aircraft in an area',
|
||||
description:
|
||||
'Aircraft currently reported inside an area, nearest the center first. ' +
|
||||
'Optionally military aircraft only, airborne only, or within an altitude band.',
|
||||
'Optionally military aircraft only, airborne only, or within an altitude band. ' +
|
||||
'group_by counts every matched aircraft by airline (the ICAO designator ' +
|
||||
'in its callsign), operator, country or type, and ' +
|
||||
'stats summarizes airborne altitude and speed; both cover all matches, not ' +
|
||||
'just the rows returned.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -91,6 +123,18 @@ export const aircraftInArea = defineTool({
|
||||
airborne_only: { type: 'boolean' },
|
||||
min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||
max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||
group_by: {
|
||||
type: 'string',
|
||||
enum: Object.keys(GROUP_LABELS),
|
||||
description:
|
||||
'Count matched aircraft by this field, largest first. Feeds that do not ' +
|
||||
'report the field count those aircraft as unknown.',
|
||||
},
|
||||
stats: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Summarize airborne altitude and speed over matched aircraft.',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
@@ -128,9 +172,14 @@ export const aircraftInArea = defineTool({
|
||||
...(regional ? [`regional feed: ${snapshot.coverage}`] : []),
|
||||
...(snapshot.freshness === 'stale' ? ['data may be stale'] : []),
|
||||
];
|
||||
const groups = args.group_by
|
||||
? breakdown(rows, args.group_by, groupValue(args.group_by))
|
||||
: null;
|
||||
const airborne = rows.filter((row) => !row.on_ground);
|
||||
return {
|
||||
summary:
|
||||
`${rows.length} ${kind} in ${area.label}` +
|
||||
(groups ? breakdownPhrase(groups, GROUP_LABELS[args.group_by]) : '') +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
@@ -138,6 +187,16 @@ export const aircraftInArea = defineTool({
|
||||
layers: [args.military ? 'military' : 'flights'],
|
||||
}),
|
||||
...capRows(rows, args.limit),
|
||||
...(groups ? { breakdown: groups } : {}),
|
||||
...(args.stats
|
||||
? {
|
||||
// Parked aircraft would pull both figures toward zero.
|
||||
stats: {
|
||||
altitude_m: numericSummary(airborne, 'altitude_m'),
|
||||
speed_mps: numericSummary(airborne, 'speed_mps'),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...snapshotInfo(snapshot),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { airlineDesignator } from './aviation.js';
|
||||
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
|
||||
const record = (id, latitude, longitude, extra = {}) => ({
|
||||
@@ -389,6 +390,96 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as
|
||||
assert.match(result.summary, /may be stale\.$/);
|
||||
});
|
||||
|
||||
test('aircraft in an area can be counted by operator over every match', async () => {
|
||||
const fleet = [
|
||||
record('a1', 37.6, -122.3, { operator: 'United' }),
|
||||
record('a2', 37.61, -122.3, { operator: 'Delta' }),
|
||||
record('a3', 37.62, -122.3, { operator: 'United', baroAltitudeM: 3000 }),
|
||||
record('a4', 37.63, -122.3, {
|
||||
onGround: true,
|
||||
baroAltitudeM: 0,
|
||||
speedMps: 0,
|
||||
}),
|
||||
record('a5', 51.5, -0.1, { operator: 'British Airways' }),
|
||||
];
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(fleet) },
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
group_by: 'operator',
|
||||
stats: true,
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1); 1 unknown.',
|
||||
);
|
||||
assert.equal(result.data.returned, 1);
|
||||
assert.deepEqual(result.data.breakdown, {
|
||||
field: 'operator',
|
||||
groups: [
|
||||
{ value: 'United', count: 2 },
|
||||
{ value: 'Delta', count: 1 },
|
||||
],
|
||||
other_count: 0,
|
||||
unknown_count: 1,
|
||||
});
|
||||
// Both figures cover airborne aircraft only: the parked a4 stands at 0 m
|
||||
// and 0 m/s and would pull each mean down.
|
||||
assert.deepEqual(result.data.stats, {
|
||||
altitude_m: {
|
||||
count: 3,
|
||||
min: 3000,
|
||||
median: 10000,
|
||||
mean: 7666.7,
|
||||
max: 10000,
|
||||
},
|
||||
speed_mps: { count: 3, min: 230, median: 230, mean: 230, max: 230 },
|
||||
});
|
||||
});
|
||||
|
||||
test('aircraft stats are empty when every match is on the ground', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
aircraft: feed([
|
||||
record('g1', 37.6, -122.3, {
|
||||
onGround: true,
|
||||
baroAltitudeM: 0,
|
||||
speedMps: 0,
|
||||
}),
|
||||
record('g2', 37.61, -122.3, {
|
||||
onGround: true,
|
||||
baroAltitudeM: 0,
|
||||
speedMps: 4,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
stats: true,
|
||||
});
|
||||
assert.match(result.summary, /^2 aircraft in /);
|
||||
assert.deepEqual(result.data.stats, { altitude_m: null, speed_mps: null });
|
||||
});
|
||||
|
||||
test('aircraft answers carry no breakdown or stats unless asked', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(civil) },
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', { area: bay });
|
||||
assert.equal('breakdown' in result.data, false);
|
||||
assert.equal('stats' in result.data, false);
|
||||
await assert.rejects(
|
||||
catalog.call('aircraft_in_area', { area: bay, group_by: 'callsign' }),
|
||||
/must be one of/,
|
||||
);
|
||||
});
|
||||
|
||||
test('altitude bands require a reported altitude from normalized OpenSky records', async () => {
|
||||
const { normalizeOpenSkyAircraft } =
|
||||
await import('../../sources/live/aircraft.js');
|
||||
@@ -447,3 +538,47 @@ test('altitude bands require a reported altitude from normalized OpenSky records
|
||||
});
|
||||
assert.equal(minimum.data.total, 3);
|
||||
});
|
||||
|
||||
test('an airline designator is read from an ICAO flight callsign only', () => {
|
||||
for (const [callsign, designator] of [
|
||||
['SWA1234', 'SWA'],
|
||||
['ual12a', 'UAL'],
|
||||
[' BAW1 ', 'BAW'],
|
||||
['RCH123', 'RCH'],
|
||||
['N123AB', null],
|
||||
['GABCD', null],
|
||||
['SWA', null],
|
||||
['SWAX123', null],
|
||||
['SWA12345', null],
|
||||
['RPC1234', null],
|
||||
['CUT1240', null],
|
||||
['CUB123', 'CUB'],
|
||||
['', null],
|
||||
[null, null],
|
||||
])
|
||||
assert.equal(airlineDesignator(callsign), designator, String(callsign));
|
||||
});
|
||||
|
||||
test('aircraft in an area can be counted by airline from their callsigns', async () => {
|
||||
const fleet = [
|
||||
record('a1', 37.6, -122.3, { callsign: 'SWA100' }),
|
||||
record('a2', 37.61, -122.3, { callsign: 'SWA2' }),
|
||||
record('a3', 37.62, -122.3, { callsign: 'UAL9' }),
|
||||
record('a4', 37.63, -122.3, { callsign: 'N123AB' }),
|
||||
];
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(fleet) },
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
group_by: 'airline',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'4 aircraft in 60 km around 37.600, -122.300; most by airline: SWA (2), UAL (1); 1 unknown.',
|
||||
);
|
||||
assert.equal(result.data.breakdown.field, 'airline');
|
||||
// Rows are unchanged: the designator is derived, not added to each row.
|
||||
assert.equal('airline' in result.data.rows[0], false);
|
||||
});
|
||||
@@ -12,11 +12,15 @@ import {
|
||||
} from '../area.js';
|
||||
import {
|
||||
LIMIT_SCHEMA,
|
||||
breakdown,
|
||||
breakdownPhrase,
|
||||
capRows,
|
||||
countNoun,
|
||||
isoTime,
|
||||
numericSummary,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
import { normalizeVesselType } from '../../data/vesselTypes.js';
|
||||
|
||||
const MMSI_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
@@ -29,6 +33,18 @@ const ALL_VESSELS = 1_000_000;
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
const text = (value) => (typeof value === 'string' && value ? value : null);
|
||||
// AIS sends ship types as ITU-R M.1371 codes ("70" to "79" are all cargo), so
|
||||
// a code reads as its family ("CARGO"), which is what group_by counts and the
|
||||
// type filter matches; text types pass through. Type 0 is "not available" and
|
||||
// reads as unknown.
|
||||
const vesselType = (value) => normalizeVesselType(text(value) ?? '') || null;
|
||||
|
||||
// Crews type the destination by hand; one with no letters ("0", "---") names
|
||||
// no port and reads as unknown.
|
||||
const vesselDestination = (value) => {
|
||||
const destination = text(value);
|
||||
return destination && /\p{L}/u.test(destination) ? destination : null;
|
||||
};
|
||||
|
||||
function vesselRow(record, from) {
|
||||
const point = { lat: record.latitude, lon: record.longitude };
|
||||
@@ -36,8 +52,8 @@ function vesselRow(record, from) {
|
||||
mmsi: record.id,
|
||||
name: text(record.name),
|
||||
imo: text(record.imo),
|
||||
type: text(record.type),
|
||||
destination: text(record.destination),
|
||||
type: vesselType(record.type),
|
||||
destination: vesselDestination(record.destination),
|
||||
lat: round(record.latitude, 5),
|
||||
lon: round(record.longitude, 5),
|
||||
speed_mps: round(record.speedMps, 1),
|
||||
@@ -88,7 +104,9 @@ export const vesselsInArea = defineTool({
|
||||
title: 'Vessels in an area',
|
||||
description:
|
||||
'Ships currently reported by AIS inside an area, nearest the center ' +
|
||||
'first, optionally filtered by vessel type.',
|
||||
'first, optionally filtered by vessel type. group_by counts every matched ' +
|
||||
'ship by type or destination, and stats summarizes their speed; both ' +
|
||||
'cover all matches, not just the rows returned.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -99,6 +117,15 @@ export const vesselsInArea = defineTool({
|
||||
maxLength: 40,
|
||||
description: 'Vessel type to match, such as "tanker" or "cargo".',
|
||||
},
|
||||
group_by: {
|
||||
type: 'string',
|
||||
enum: ['type', 'destination'],
|
||||
description: 'Count matched vessels by this field, largest first.',
|
||||
},
|
||||
stats: {
|
||||
type: 'boolean',
|
||||
description: 'Summarize speed over matched vessels.',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
@@ -114,18 +141,25 @@ export const vesselsInArea = defineTool({
|
||||
.filter(
|
||||
(record) =>
|
||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||
(!wanted || String(record.type).toLowerCase().includes(wanted)),
|
||||
(!wanted ||
|
||||
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
|
||||
)
|
||||
.map((record) => vesselRow(record, center))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const kind = args.type ? `${args.type} vessel` : 'vessel';
|
||||
const groups = args.group_by ? breakdown(rows, args.group_by) : null;
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, kind)} in ${area.label}` +
|
||||
(groups ? breakdownPhrase(groups, args.group_by) : '') +
|
||||
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['ais-live-vessels'] }),
|
||||
...capRows(rows, args.limit),
|
||||
...(groups ? { breakdown: groups } : {}),
|
||||
...(args.stats
|
||||
? { stats: { speed_mps: numericSummary(rows, 'speed_mps') } }
|
||||
: {}),
|
||||
...snapshotInfo(snapshot),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -224,3 +224,177 @@ test('an area search names its area to the server; a vessel lookup asks for ever
|
||||
assert.equal(lookup.has('lat'), false);
|
||||
assert.equal(lookup.has('radius_km'), false);
|
||||
});
|
||||
|
||||
test('AIS type 0 (not available) is counted as unknown, not as a type', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, { type: '0' }),
|
||||
vessel('366000002', 37.79, -122.41, { type: '0' }),
|
||||
vessel('366000003', 37.77, -122.39, { type: 'Cargo' }),
|
||||
vessel('366000004', 37.76, -122.38, { type: 'Tanker' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'type',
|
||||
});
|
||||
assert.deepEqual(result.data.breakdown, {
|
||||
field: 'type',
|
||||
groups: [
|
||||
{ value: 'Cargo', count: 1 },
|
||||
{ value: 'Tanker', count: 1 },
|
||||
],
|
||||
other_count: 0,
|
||||
unknown_count: 2,
|
||||
});
|
||||
assert.match(
|
||||
result.summary,
|
||||
/most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./,
|
||||
);
|
||||
assert.doesNotMatch(result.summary, /0 \(/);
|
||||
assert.equal(
|
||||
result.data.rows.find((row) => row.mmsi === '366000001').type,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('numeric AIS ship-type codes group and filter by their family', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, { type: '70' }),
|
||||
vessel('366000002', 37.79, -122.41, { type: '74' }),
|
||||
vessel('366000003', 37.77, -122.39, { type: '79' }),
|
||||
vessel('366000004', 37.76, -122.38, { type: '80' }),
|
||||
vessel('366000005', 37.75, -122.37, { type: '52' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const grouped = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'type',
|
||||
});
|
||||
assert.deepEqual(grouped.data.breakdown.groups, [
|
||||
{ value: 'CARGO', count: 3 },
|
||||
{ value: 'TANKER', count: 1 },
|
||||
{ value: 'TUG', count: 1 },
|
||||
]);
|
||||
const tankers = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
type: 'tanker',
|
||||
});
|
||||
assert.deepEqual(
|
||||
tankers.data.rows.map((row) => [row.mmsi, row.type]),
|
||||
[['366000004', 'TANKER']],
|
||||
);
|
||||
});
|
||||
|
||||
test('reserved AIS ship-type codes 100 to 255 count as unknown', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, { type: '100' }),
|
||||
vessel('366000002', 37.79, -122.41, { type: '255' }),
|
||||
vessel('366000003', 37.77, -122.39, { type: '70' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'type',
|
||||
});
|
||||
assert.deepEqual(result.data.breakdown.groups, [
|
||||
{ value: 'CARGO', count: 1 },
|
||||
]);
|
||||
assert.equal(result.data.breakdown.unknown_count, 2);
|
||||
assert.doesNotMatch(result.summary, /\b(100|255) \(/);
|
||||
});
|
||||
|
||||
test('find_vessel answers carry normalized types and destinations', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, {
|
||||
type: '71',
|
||||
destination: '---',
|
||||
}),
|
||||
vessel('366000002', 37.79, -122.41, {
|
||||
type: '0',
|
||||
destination: 'NLRTM',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const cargo = await catalog.call('find_vessel', { mmsi: '366000001' });
|
||||
assert.deepEqual(
|
||||
cargo.data.rows.map((row) => [row.type, row.destination]),
|
||||
[['CARGO', null]],
|
||||
);
|
||||
const unknown = await catalog.call('find_vessel', { mmsi: '366000002' });
|
||||
assert.deepEqual(
|
||||
unknown.data.rows.map((row) => [row.type, row.destination]),
|
||||
[[null, 'NLRTM']],
|
||||
);
|
||||
});
|
||||
|
||||
test('a destination with no letters counts as unknown', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: vessels({
|
||||
records: [
|
||||
vessel('366000001', 37.78, -122.4, { destination: '0' }),
|
||||
vessel('366000002', 37.79, -122.41, { destination: '---' }),
|
||||
vessel('366000003', 37.77, -122.39, { destination: 'NLRTM' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'destination',
|
||||
});
|
||||
assert.deepEqual(result.data.breakdown.groups, [
|
||||
{ value: 'NLRTM', count: 1 },
|
||||
]);
|
||||
assert.equal(result.data.breakdown.unknown_count, 2);
|
||||
});
|
||||
|
||||
test('vessels in an area can be counted by destination with a speed summary', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels() },
|
||||
});
|
||||
const result = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
group_by: 'destination',
|
||||
stats: true,
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1); 1 unknown.',
|
||||
);
|
||||
assert.deepEqual(result.data.breakdown, {
|
||||
field: 'destination',
|
||||
groups: [{ value: 'OAKLAND', count: 1 }],
|
||||
other_count: 0,
|
||||
unknown_count: 1,
|
||||
});
|
||||
assert.deepEqual(result.data.stats, {
|
||||
speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 },
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
|
||||
import {
|
||||
noradIdFromSatnum,
|
||||
parseTleText,
|
||||
tleCatalogNumber,
|
||||
} from '../../sources/tle.js';
|
||||
|
||||
// Element sets published for 1 January 2024.
|
||||
const TLE = `ISS (ZARYA)
|
||||
@@ -51,6 +56,125 @@ for (const missingLine of [1, 2]) {
|
||||
});
|
||||
}
|
||||
|
||||
test('TLE parsing rejects pairs with mismatched catalog numbers and resumes', () => {
|
||||
const text = `SAT A
|
||||
1 25544U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
|
||||
SAT B
|
||||
1 48274U 21035A 24001.50000000 .00020137 00000-0 24481-3 0 9993
|
||||
2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
|
||||
`;
|
||||
const entries = parseTleText(text);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.name),
|
||||
['SAT B'],
|
||||
);
|
||||
assert.equal(tleCatalogNumber(entries[0].line1), 48274);
|
||||
assert.equal(tleCatalogNumber(entries[0].line2), 48274);
|
||||
});
|
||||
|
||||
test('TLE parsing accepts matching Alpha-5 pairs and rejects mismatched Alpha-5 pairs', () => {
|
||||
const text = `SAT MISMATCH
|
||||
1 A0000U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 A0001 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
|
||||
SAT ALPHA5
|
||||
1 A0000U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 A0000 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
|
||||
`;
|
||||
const entries = parseTleText(text);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.name),
|
||||
['SAT ALPHA5'],
|
||||
);
|
||||
assert.equal(tleCatalogNumber(entries[0].line1), 100000);
|
||||
assert.equal(tleCatalogNumber(entries[0].line2), 100000);
|
||||
});
|
||||
|
||||
test('TLE parsing rejects a pair whose catalog fields are malformed and resumes', () => {
|
||||
// Read as digits up to the first non-digit, both fields would be 2554.
|
||||
const text = `SAT MALFORMED
|
||||
1 2554AU 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 2554B 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414
|
||||
SAT HALF MALFORMED
|
||||
1 2554AU 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 02554 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414
|
||||
${TLE}`;
|
||||
assert.deepEqual(
|
||||
parseTleText(text).map((entry) => entry.name),
|
||||
['ISS (ZARYA)', 'CSS (TIANHE)'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Alpha-5 catalog numbers decode, skipping I and O', () => {
|
||||
for (const [field, id] of [
|
||||
['A0000', 100000],
|
||||
['A0404', 100404],
|
||||
['H9999', 179999],
|
||||
['J0000', 180000],
|
||||
['N9999', 229999],
|
||||
['P0000', 230000],
|
||||
['T0449', 270449],
|
||||
['Z9999', 339999],
|
||||
]) {
|
||||
assert.equal(noradIdFromSatnum(field), id, field);
|
||||
assert.equal(tleCatalogNumber(`1 ${field}U`), id, field);
|
||||
}
|
||||
});
|
||||
|
||||
test('a digit catalog field reads as it did before', () => {
|
||||
assert.equal(noradIdFromSatnum('25544'), 25544);
|
||||
assert.equal(noradIdFromSatnum('00404'), 404);
|
||||
assert.equal(noradIdFromSatnum(' 404'), 404);
|
||||
assert.equal(tleCatalogNumber('1 25544U'), 25544);
|
||||
assert.equal(tleCatalogNumber('1 404U'), 404);
|
||||
// An OMM satrec carries the number unpadded, past five digits.
|
||||
assert.equal(noradIdFromSatnum('100000'), 100000);
|
||||
});
|
||||
|
||||
test('a malformed catalog field is no number', () => {
|
||||
for (const field of [
|
||||
'I0000',
|
||||
'O0000',
|
||||
'a0404',
|
||||
'A00',
|
||||
'2554A',
|
||||
'255 4',
|
||||
'-1234',
|
||||
'0x1F0',
|
||||
'1.5',
|
||||
'1e3',
|
||||
'',
|
||||
' ',
|
||||
]) {
|
||||
assert.ok(Number.isNaN(noradIdFromSatnum(field)), JSON.stringify(field));
|
||||
assert.equal(
|
||||
tleCatalogNumber(`1 ${field.padEnd(5)}U`),
|
||||
null,
|
||||
JSON.stringify(field),
|
||||
);
|
||||
}
|
||||
assert.ok(Number.isNaN(noradIdFromSatnum(undefined)));
|
||||
});
|
||||
|
||||
test('a satellite numbered above 99999 keeps its number and is found by it', async () => {
|
||||
// The ISS set with its catalog field in Alpha-5: A0000 is 100000.
|
||||
const text = TLE.replaceAll('25544', 'A0000');
|
||||
const [iss] = parseTleText(text);
|
||||
const { satnum } = twoline2satrec(iss.line1, iss.line2);
|
||||
assert.ok(Number.isNaN(Number(satnum))); // what the catalog builders used
|
||||
assert.equal(noradIdFromSatnum(satnum), 100000);
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { satellites: satellites(text), clock: CLOCK },
|
||||
});
|
||||
const result = await catalog.call('next_satellite_pass', {
|
||||
location: { lat: 40.7, lon: -74 },
|
||||
satellite: '100000',
|
||||
});
|
||||
assert.equal(result.data.satellite, 'ISS (ZARYA)');
|
||||
assert.equal(result.data.norad, 100000);
|
||||
});
|
||||
|
||||
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -22,6 +22,84 @@ export function capRows(rows, limit = DEFAULT_LIMIT) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Most groups a breakdown lists before counting the rest as other. */
|
||||
export const MAX_GROUPS = 10;
|
||||
|
||||
/**
|
||||
* Count rows by one text field over every row, not just those returned.
|
||||
* Values group case-insensitively and keep their first spelling; rows
|
||||
* without a value are counted as unknown. `valueOf` derives the value when
|
||||
* it is not a row field as it stands.
|
||||
*/
|
||||
export function breakdown(rows, field, valueOf = (row) => row[field]) {
|
||||
const groups = new Map();
|
||||
let unknown = 0;
|
||||
for (const row of rows) {
|
||||
const raw = valueOf(row);
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (!value) {
|
||||
unknown += 1;
|
||||
continue;
|
||||
}
|
||||
const key = value.toLowerCase();
|
||||
const group = groups.get(key);
|
||||
if (group) group.count += 1;
|
||||
else groups.set(key, { value, count: 1 });
|
||||
}
|
||||
const sorted = [...groups.values()].sort(
|
||||
(a, b) => b.count - a.count || a.value.localeCompare(b.value),
|
||||
);
|
||||
const listed = sorted.slice(0, MAX_GROUPS);
|
||||
return {
|
||||
field,
|
||||
groups: listed,
|
||||
other_count: rows.length - unknown - sum(listed.map((g) => g.count)),
|
||||
unknown_count: unknown,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimum, median, mean and maximum of one numeric field, or null. */
|
||||
export function numericSummary(rows, field) {
|
||||
const values = rows
|
||||
.map((row) => row[field])
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
if (!values.length) return null;
|
||||
const middle = values.length >> 1;
|
||||
const median =
|
||||
values.length % 2
|
||||
? values[middle]
|
||||
: (values[middle - 1] + values[middle]) / 2;
|
||||
const round1 = (value) => Number(value.toFixed(1));
|
||||
return {
|
||||
count: values.length,
|
||||
min: values[0],
|
||||
median: round1(median),
|
||||
mean: round1(sum(values) / values.length),
|
||||
max: values.at(-1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "; most by operator: Delta (12), United (9), American (7); 4 unknown", or
|
||||
* "; no operator reported" when no row carries the field.
|
||||
*/
|
||||
export function breakdownPhrase(result, label) {
|
||||
if (!result.groups.length)
|
||||
return result.unknown_count ? `; no ${label} reported` : '';
|
||||
const top = result.groups
|
||||
.slice(0, 3)
|
||||
.map((group) => `${group.value} (${group.count})`);
|
||||
const unknown = result.unknown_count
|
||||
? `; ${result.unknown_count} unknown`
|
||||
: '';
|
||||
return `; most by ${label}: ${top.join(', ')}${unknown}`;
|
||||
}
|
||||
|
||||
function sum(values) {
|
||||
return values.reduce((total, value) => total + value, 0);
|
||||
}
|
||||
|
||||
/** Format an epoch millisecond time as ISO 8601, or null. */
|
||||
export function isoTime(ms) {
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
MAX_GROUPS,
|
||||
breakdown,
|
||||
breakdownPhrase,
|
||||
numericSummary,
|
||||
} from './results.js';
|
||||
|
||||
test('a breakdown counts every row, case-insensitively, largest first', () => {
|
||||
const rows = [
|
||||
{ operator: 'Delta' },
|
||||
{ operator: 'United' },
|
||||
{ operator: ' delta ' },
|
||||
{ operator: null },
|
||||
{ operator: '' },
|
||||
{ operator: 'American' },
|
||||
{ operator: 'United' },
|
||||
{ operator: 'DELTA' },
|
||||
];
|
||||
assert.deepEqual(breakdown(rows, 'operator'), {
|
||||
field: 'operator',
|
||||
groups: [
|
||||
{ value: 'Delta', count: 3 },
|
||||
{ value: 'United', count: 2 },
|
||||
{ value: 'American', count: 1 },
|
||||
],
|
||||
other_count: 0,
|
||||
unknown_count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('groups past the limit are counted as other', () => {
|
||||
const rows = Array.from({ length: MAX_GROUPS + 3 }, (_, index) => ({
|
||||
type: `T${String(index).padStart(2, '0')}`,
|
||||
}));
|
||||
rows.push({ type: 'T00' });
|
||||
const result = breakdown(rows, 'type');
|
||||
assert.equal(result.groups.length, MAX_GROUPS);
|
||||
assert.deepEqual(result.groups[0], { value: 'T00', count: 2 });
|
||||
assert.equal(result.other_count, 3);
|
||||
assert.equal(
|
||||
result.groups.reduce((total, group) => total + group.count, 0) +
|
||||
result.other_count +
|
||||
result.unknown_count,
|
||||
rows.length,
|
||||
);
|
||||
});
|
||||
|
||||
test('a numeric summary skips missing values', () => {
|
||||
const rows = [{ v: 4 }, { v: null }, { v: 1 }, { v: 10 }, { v: 3 }, {}];
|
||||
assert.deepEqual(numericSummary(rows, 'v'), {
|
||||
count: 4,
|
||||
min: 1,
|
||||
median: 3.5,
|
||||
mean: 4.5,
|
||||
max: 10,
|
||||
});
|
||||
assert.equal(numericSummary([{ v: 2 }, { v: 7 }, { v: 5 }], 'v').median, 5);
|
||||
assert.equal(numericSummary([{ v: null }], 'v'), null);
|
||||
assert.equal(numericSummary([], 'v'), null);
|
||||
});
|
||||
|
||||
test('the breakdown phrase names the three largest groups', () => {
|
||||
const result = {
|
||||
unknown_count: 0,
|
||||
groups: [
|
||||
{ value: 'A', count: 4 },
|
||||
{ value: 'B', count: 3 },
|
||||
{ value: 'C', count: 2 },
|
||||
{ value: 'D', count: 1 },
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
breakdownPhrase(result, 'type'),
|
||||
'; most by type: A (4), B (3), C (2)',
|
||||
);
|
||||
assert.equal(
|
||||
breakdownPhrase({ ...result, unknown_count: 5 }, 'type'),
|
||||
'; most by type: A (4), B (3), C (2); 5 unknown',
|
||||
);
|
||||
// A feed that never reports the field says so instead of staying silent.
|
||||
assert.equal(
|
||||
breakdownPhrase({ groups: [], unknown_count: 236 }, 'operator'),
|
||||
'; no operator reported',
|
||||
);
|
||||
assert.equal(breakdownPhrase({ groups: [], unknown_count: 0 }, 'type'), '');
|
||||
});
|
||||
+18
-176
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
feedProvenanceEnvelope,
|
||||
} from '../data/layerSnapshot.js';
|
||||
import { readLayerLifecycleSummary } from './layerSummary.js';
|
||||
@@ -11,6 +10,7 @@ import { defaultGeospatial } from '../search/defaults.js';
|
||||
import { msg } from '../i18n/index.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CITY_POIS } from '../cityPois.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
import {
|
||||
findPoiByName,
|
||||
flyToGlobeView,
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
isContextRecordActive,
|
||||
} from '../data/contextStore.js';
|
||||
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
||||
import { contextModeWord } from '../contextModePolicy.js';
|
||||
import {
|
||||
createAnalystEngine,
|
||||
normalizeAnalystSpec,
|
||||
@@ -47,6 +46,12 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
|
||||
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
||||
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
||||
import {
|
||||
getWorldPacket,
|
||||
withContextModeVocabulary,
|
||||
activeContactsWindow,
|
||||
TRACKABLE_FAMILIES,
|
||||
} from '../agent/worldPacket.js';
|
||||
|
||||
const ALLOWED_STYLES = new Set([
|
||||
'normal',
|
||||
@@ -118,64 +123,6 @@ const CONTEXT_MODE_ALIASES = new Map([
|
||||
['space-missions', 'space-missions'],
|
||||
['missions', 'space-missions'],
|
||||
]);
|
||||
/**
|
||||
* Every model-readable field that carries a context-mode id, and what an
|
||||
* absent value means for each.
|
||||
*
|
||||
* `mode` always names a mode, so nothing is 'off'. `entering` and `priorMode`
|
||||
* are absent when there is no such mode at all — calling those 'off' would
|
||||
* assert a state that does not exist.
|
||||
*/
|
||||
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
|
||||
{ field: 'mode', emptyAs: 'off' },
|
||||
{ field: 'entering', emptyAs: null },
|
||||
{ field: 'priorMode', emptyAs: null },
|
||||
]);
|
||||
|
||||
/** Nested results that are themselves context-mode payloads the model reads. */
|
||||
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
||||
'context',
|
||||
'contextRollback',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Report a context-mode payload in the tools' own vocabulary.
|
||||
*
|
||||
* `set_context_mode` accepts 'contacts' while the mode's internal id is
|
||||
* 'flights'. Reporting the internal id back made the model read
|
||||
* `mode:'flights'` as "Contacts is off" and refuse to answer from the Contacts
|
||||
* window counts sitting in the very same payload (owner field session
|
||||
* 2026-08-21). Secondary fields and nested transition/rollback results are
|
||||
* translated too — one leaked internal id is enough to recreate the confusion,
|
||||
* and a rollback result is exactly what the model reads when something went
|
||||
* wrong. Each internal id is kept alongside as `<field>Internal` for anything
|
||||
* reasoning about layers.
|
||||
* @param {object|null|undefined} state Any payload carrying context-mode fields.
|
||||
* @returns {object|null|undefined} The same payload, modes translated.
|
||||
*/
|
||||
function withContextModeVocabulary(state) {
|
||||
if (!state || typeof state !== 'object') return state;
|
||||
let out = state;
|
||||
const mutable = () => {
|
||||
if (out === state) out = { ...state };
|
||||
return out;
|
||||
};
|
||||
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
|
||||
if (!(field in state)) continue;
|
||||
const internal = state[field] ?? null;
|
||||
const target = mutable();
|
||||
target[field] = contextModeWord(internal, { emptyAs });
|
||||
target[`${field}Internal`] = internal;
|
||||
}
|
||||
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
|
||||
const nested = state[field];
|
||||
if (!nested || typeof nested !== 'object') continue;
|
||||
const translated = withContextModeVocabulary(nested);
|
||||
if (translated !== nested) mutable()[field] = translated;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const COCKPIT_ACTION_ALIASES = new Map([
|
||||
['next', 'next'],
|
||||
['previous', 'previous'],
|
||||
@@ -245,14 +192,6 @@ const STACK_ALIASES = new Map([
|
||||
['road map', 'osm'],
|
||||
]);
|
||||
|
||||
/** Search order for track_entity across entity layer families. */
|
||||
const TRACKABLE_FAMILIES = [
|
||||
{ layerId: 'flights', kind: 'aircraft' },
|
||||
{ layerId: 'military', kind: 'aircraft' },
|
||||
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
||||
{ layerId: 'satellites', kind: 'satellite' },
|
||||
];
|
||||
|
||||
const FRAME_TARGETS = new Map([
|
||||
['flights', 'flights'],
|
||||
['planes', 'flights'],
|
||||
@@ -2552,26 +2491,6 @@ function runManagedVoiceNavigation(
|
||||
};
|
||||
}
|
||||
|
||||
/** Gathers tracked/selected entities across layer families for read-back. */
|
||||
function collectTrackedEntities(dataManager) {
|
||||
const tracked = [];
|
||||
for (const family of TRACKABLE_FAMILIES) {
|
||||
const module = dataManager.layers.get(family.layerId)?.module;
|
||||
if (!module) continue;
|
||||
try {
|
||||
const info =
|
||||
family.kind === 'vessel'
|
||||
? module.getSelectedInfo?.()
|
||||
: module.getTrackedInfo?.();
|
||||
if (info)
|
||||
tracked.push({ kind: family.kind, layerId: family.layerId, ...info });
|
||||
} catch {
|
||||
// layer not ready
|
||||
}
|
||||
}
|
||||
return tracked;
|
||||
}
|
||||
|
||||
export async function getBasemapLabelContext(
|
||||
viewer,
|
||||
service = defaultGeospatial,
|
||||
@@ -3209,60 +3128,12 @@ function getCurrentViewState(
|
||||
dataManager,
|
||||
sceneDirector = null,
|
||||
) {
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
camera: {
|
||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
heightM: cartographic.height,
|
||||
},
|
||||
style: styleManager.activeStyle || 'normal',
|
||||
context:
|
||||
typeof styleManager.getContextModeState === 'function'
|
||||
? {
|
||||
...withContextModeVocabulary(styleManager.getContextModeState()),
|
||||
// The numbers on the operator's Contacts panel, so a window/count
|
||||
// question can be answered from what they are looking at.
|
||||
...(activeContactsWindow(dataManager)
|
||||
? { contactsWindow: activeContactsWindow(dataManager) }
|
||||
: {}),
|
||||
}
|
||||
: null,
|
||||
cockpit:
|
||||
typeof styleManager.getCockpitState === 'function'
|
||||
? styleManager.getCockpitState()
|
||||
: null,
|
||||
controls:
|
||||
typeof styleManager.getControlState === 'function'
|
||||
? styleManager.getControlState()
|
||||
: null,
|
||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||
tracked: collectTrackedEntities(dataManager),
|
||||
// Enabled layers only: the full registry cost ~1k tokens per call and
|
||||
// the model only reasons about what is on.
|
||||
disabledLayerCount: dataManager.getAll().filter((layer) => !layer.enabled)
|
||||
.length,
|
||||
layers: dataManager
|
||||
.getAll()
|
||||
.filter((layer) => layer.enabled)
|
||||
.map((layer) => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
enabled: layer.enabled,
|
||||
count: layer.stats?.count || 0,
|
||||
error: layer.stats?.error || null,
|
||||
feedState: layerSnapshot(layer).feedState,
|
||||
source: layerSnapshot(layer).source,
|
||||
lastUpdate: layerSnapshot(layer).lastUpdate,
|
||||
})),
|
||||
feedProvenance: feedProvenanceEnvelope(
|
||||
layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled),
|
||||
),
|
||||
};
|
||||
return getWorldPacket({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager,
|
||||
sceneDirector,
|
||||
});
|
||||
}
|
||||
|
||||
async function getEntityContext(
|
||||
@@ -3742,8 +3613,10 @@ function centerMatchesSubject(center, subjectPosition) {
|
||||
const subjectLat = Cesium.Math.toDegrees(carto.latitude);
|
||||
const subjectLon = Cesium.Math.toDegrees(carto.longitude);
|
||||
return (
|
||||
haversineKm(subjectLat, subjectLon, center.lat, center.lon) <=
|
||||
SUBJECT_CENTER_TOLERANCE_KM
|
||||
greatCircleKm(
|
||||
{ lat: subjectLat, lon: subjectLon },
|
||||
{ lat: center.lat, lon: center.lon },
|
||||
) <= SUBJECT_CENTER_TOLERANCE_KM
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4033,7 +3906,7 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) {
|
||||
for (const [cityId, city] of Object.entries(CITY_POIS)) {
|
||||
for (let poiIndex = 0; poiIndex < city.pois.length; poiIndex++) {
|
||||
const poi = city.pois[poiIndex];
|
||||
const distanceKm = haversineKm(latitude, longitude, poi.lat, poi.lon);
|
||||
const distanceKm = greatCircleKm({ lat: latitude, lon: longitude }, poi);
|
||||
if (distanceKm > maxDistanceKm) continue;
|
||||
matches.push({
|
||||
name: poi.name,
|
||||
@@ -4048,20 +3921,6 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) {
|
||||
return matches.sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 5);
|
||||
}
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const toRad = (value) => Cesium.Math.toRadians(value);
|
||||
const radiusKm = 6371.0088;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) *
|
||||
Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
return 2 * radiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function coarseBasemapPlace(
|
||||
viewScale,
|
||||
latitude,
|
||||
@@ -4646,23 +4505,6 @@ function clampNumber(value, min, max, fallback) {
|
||||
* ("which of those is closest?") scoped to the session.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Contacts panel's own counts, or null when Contacts has no subject.
|
||||
* Read through the awareness snapshot the panel renders, so the two cannot
|
||||
* diverge no matter which surface asks.
|
||||
* @returns {object|null} Panel-equivalent window counts.
|
||||
*/
|
||||
function activeContactsWindow(dataManager) {
|
||||
try {
|
||||
const layer = dataManager?.layers?.get('military-awareness')?.module;
|
||||
return (
|
||||
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Most records one layer hands a single query. Above every loaded feed seen
|
||||
* so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so
|
||||
|
||||
Reference in new issue
Block a user